├── .babelrc.json ├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ ├── build.yml │ └── test-coverage.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── README.md ├── demo ├── .gitignore ├── .prettierrc ├── README.md ├── eslint.config.js ├── index.html ├── package-lock.json ├── package.json ├── public │ ├── click.mp3 │ ├── images │ │ ├── icons │ │ │ ├── dark │ │ │ │ ├── angle-left.svg │ │ │ │ ├── angle-right.svg │ │ │ │ └── angle-up.svg │ │ │ ├── light │ │ │ │ ├── angle-left.svg │ │ │ │ ├── angle-right.svg │ │ │ │ └── angle-up.svg │ │ │ ├── monokai │ │ │ │ ├── angle-left.svg │ │ │ │ ├── angle-right.svg │ │ │ │ └── angle-up.svg │ │ │ ├── retro │ │ │ │ ├── angle-left.svg │ │ │ │ ├── angle-right.svg │ │ │ │ └── angle-up.svg │ │ │ └── synthwave │ │ │ │ ├── angle-left.svg │ │ │ │ ├── angle-right.svg │ │ │ │ └── angle-up.svg │ │ └── logos │ │ │ └── logo.svg │ └── vite.svg ├── src │ ├── App.css │ ├── App.tsx │ ├── index.css │ ├── main.tsx │ ├── themes │ │ ├── dark.css │ │ ├── light.css │ │ ├── monokai.css │ │ ├── retro.css │ │ └── synthwave.css │ └── vite-env.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── jasonrundell-react-mega-menu-2.2.2.tgz ├── jest.config.cjs ├── jest.setup.js ├── next-demo ├── .eslintrc.json ├── .gitignore ├── README.md ├── jsconfig.json ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── src │ └── app │ │ ├── favicon.ico │ │ ├── fonts │ │ ├── GeistMonoVF.woff │ │ └── GeistVF.woff │ │ ├── globals.css │ │ ├── layout.js │ │ └── page.js └── tailwind.config.js ├── package-lock.json ├── package.json ├── src ├── Menu.jsx ├── Menu.test.js ├── components │ ├── Hamburger.jsx │ ├── Logo.jsx │ ├── MainList.jsx │ ├── MainNavItem.jsx │ ├── MainNavItemLink.jsx │ ├── MegaList.jsx │ ├── Nav.jsx │ ├── Nav.test.jsx │ ├── NavItem.jsx │ ├── NavItemDescription.jsx │ ├── NavItemLink.jsx │ ├── NavList.jsx │ ├── TopBar.jsx │ └── TopBarTitle.jsx ├── config │ ├── breakpoints.js │ └── menuItemTypes.js ├── context │ ├── MenuContext.jsx │ └── MenuContext.test.js ├── helpers │ ├── a11y.js │ ├── a11y.test.js │ ├── animationStyles.js │ ├── animationStyles.test.js │ ├── menu.jsx │ ├── menu.test.js │ └── responsive.js ├── index.jsx └── index.test.js ├── tsconfig.json └── vite.config.js /.babelrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "sourceType": "unambiguous", 3 | "presets": [ 4 | [ 5 | "@babel/preset-env", 6 | { 7 | "targets": { 8 | "chrome": 100, 9 | "safari": 15, 10 | "firefox": 91 11 | } 12 | } 13 | ], 14 | "@babel/preset-react" 15 | ], 16 | "plugins": ["istanbul"] 17 | } 18 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Ignore node_modules directory 2 | node_modules/ 3 | 4 | # Ignore build output directories 5 | dist/ 6 | build/ 7 | 8 | # Ignore Next.js specific directories 9 | .next/ 10 | out/ 11 | 12 | # Ignore configuration files 13 | *.config.js 14 | 15 | # Ignore specific files 16 | *.min.js 17 | 18 | # Ignore all JavaScript files in a specific directory 19 | public/**/*.js 20 | 21 | # Ignore all files in a specific directory 22 | coverage/ -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | jest: true 6 | }, 7 | extends: ['plugin:react/recommended', 'standard'], 8 | overrides: [], 9 | parserOptions: { 10 | ecmaVersion: 'latest', 11 | sourceType: 'module' 12 | }, 13 | plugins: ['react'], 14 | rules: {} 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build-next-demo: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Set up Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: 20 20 | 21 | - name: Install dependencies and run build 22 | run: | 23 | npm ci 24 | npm run build 25 | -------------------------------------------------------------------------------- /.github/workflows/test-coverage.yml: -------------------------------------------------------------------------------- 1 | name: Test Coverage 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | test-coverage: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Set up Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: 20 20 | 21 | - name: Install dependencies 22 | run: npm ci 23 | 24 | - name: Run tests with coverage 25 | run: npm test -- --coverage 26 | 27 | - name: Upload coverage report 28 | uses: actions/upload-artifact@v3 29 | with: 30 | name: coverage-report 31 | path: coverage 32 | -------------------------------------------------------------------------------- /.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 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | .npmrc 26 | .parcel-cache -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | demo/ 2 | src/ 3 | .babelrc.json 4 | .eslintrc.cjs 5 | .prettierrc 6 | .gitignore 7 | tsconfig*.json 8 | vite.config.ts -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": false, 4 | "singleQuote": true, 5 | "trailingComma": "none", 6 | "proseWrap": "always", 7 | "htmlWhitespaceSensitivity": "strict" 8 | } 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Mega Menu 2 | 3 | A React project which aims to be an accessible, responsive, boilerplate top 4 | navigation menu with a "Mega Menu"! 5 | 6 | ## Features 7 | 8 | - WCAG 2.1 AA compliant 9 | - W3C valid markup 10 | - Fly-out menus 11 | - Supports keyboard navigation and screen readers 12 | - Responsively designed to adapt to modern mobile and desktop screen sizes 13 | - Lightly styled with [Emotion](https://emotion.sh) 14 | - Supports theme customization with vanilla CSS, as demonstrated in the `synthwave.css` theme 15 | - Tested and supported on Edge, Safari, Firefox, and Chrome 16 | - Includes CSS animations that respect the [prefers-reduced-motion](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion) media query for users who prefer reduced motion 17 | - Includes a demo project using Next.js, showcasing how to integrate the menu with a Next.js application 18 | 19 | ## FAQ 20 | 21 | - **What is a "Mega Menu"?** A Mega Menu is a large dropdown navigation menu that displays multiple links and categories at once. It often organizes content into columns or sections, allowing users to see a broader range of options at a glance. It is typically used for websites with a lot of content or categories, like e-commerce or large informational sites. 22 | - **Why would someone use a Mega Menu?** Mega menus are important for building an online e-commerce presence because they enhance navigation by allowing users to quickly browse and access a wide range of products or categories. They help improve the user experience by organizing complex inventories into clear, accessible sections, reducing the time it takes for customers to find what they're looking for, which can lead to higher engagement and conversion rates. 23 | - **Can I use this just for a simple website menu?** Yes! This project is designed to be flexible and can be used for any type of website navigation. You can customize the menu to fit your needs, whether you want a simple dropdown menu or a more complex Mega Menu. 24 | 25 | ## View Demo 26 | 27 | Visit: 28 | [https://jasonrundell-react-mega-menu.vercel.app/](https://jasonrundell-react-mega-menu.vercel.app) 29 | 30 | ## Deploy 31 | 32 | ### Vercel 33 | 34 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/project?template=https://github.com/jasonrundell/react-mega-menu) 35 | 36 | ## Special Thanks 37 | 38 | [Donna Vitan for the accessibility consultation](https://donnavitan.com) 39 | 40 | ## Resources 41 | 42 | [Web Accessibility Tutorials (WCAG) Menu Structure](https://www.w3.org/WAI/tutorials/menus/structure/) 43 | 44 | [Web Accessibility Tutorials (WCAG) Fly-out Menus](https://www.w3.org/WAI/tutorials/menus/flyout/) 45 | 46 | [JavaScript Event KeyCodes by Wes Bos](https://keycode.info/) 47 | 48 | [Supporting the Keyboard for Mobile](http://simplyaccessible.com/article/mobile-keyboard-support/) 49 | 50 | [a11y Project: Resources](https://www.a11yproject.com/resources/) 51 | 52 | ## Accessible Menus 53 | 54 | [Deque University](https://dequeuniversity.com/) 55 | 56 | ["Building Accessible Menu Systems" by Heydon Pickering](https://www.smashingmagazine.com/2017/11/building-accessible-menu-systems/) 57 | 58 | [Using the aria-hidden Attribute](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques/Using_the_aria-hidden_attribute) 59 | 60 | ## Reduced Motion Support 61 | 62 | Learn by reading these: 63 | 64 | - ["Your Interactive Makes Me Sick"](https://source.opennews.org/articles/motion-sick/) 65 | - ["Accessibility for Vestibular Disorders: How My Temporary Disability Changed My Perspective"](https://alistapart.com/article/accessibility-for-vestibular/) 66 | - ["An Introduction to the Reduced Motion Media Query"](https://css-tricks.com/introduction-reduced-motion-media-query/) 67 | - ["prefers-reduced-motion: Sometimes Less Movement is More"](https://web.dev/prefers-reduced-motion/) 68 | - [W3C: Understanding Success Criterion 2.3.3: Animation from Interactions](https://www.w3.org/WAI/WCAG21/Understanding/animation-from-interactions.html) 69 | - [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion) 70 | 71 | ### How to Test prefers-reduced-motion on macOS 72 | 73 | 1. Open settings for **Accessibility** 74 | 2. Toggle **Reduce Motion** On/Off 75 | 76 | ### How to Test prefers-reduced-motion on iOS 77 | 78 | 1. Open settings for **Accessibility** 79 | 2. Toggle **Reduce Motion** On/Off 80 | 81 | ### How to Test prefers-reduced-motion on Windows 10 82 | 83 | 1. Press the Win+R keys to open Run, type `SystemPropertiesPerformance.exe` into Run, and click/tap on OK to directly open to the Visual Effects tab in Performance Options. 84 | 2. Check (enable - default) or uncheck (disable) `Animate controls and elements inside windows`. 85 | 3. If you don't see an immediate change, then you can restart the explorer process or sign out and sign in to apply instead. 86 | 87 | ### How to Test prefers-reduced-motion on Android 88 | 89 | 1. Search in your system settings for **Remove Animations** and toggle On/Off, 90 | or 91 | 2. Go to your system settings > **Accessibility** and look for a toggle to reduce motion or turn off animations 92 | 3. If you have a browser app already open, you'll have to force quit it to have the setting take effect 93 | 94 | ## Icons 95 | 96 | Icons from the **Free for Web** download pack by [Font Awesome](https://fontawesome.com/download) -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /demo/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": false, 4 | "singleQuote": true, 5 | "trailingComma": "none", 6 | "proseWrap": "always", 7 | "htmlWhitespaceSensitivity": "strict" 8 | } 9 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default tseslint.config({ 18 | languageOptions: { 19 | // other options... 20 | parserOptions: { 21 | project: ['./tsconfig.node.json', './tsconfig.app.json'], 22 | tsconfigRootDir: import.meta.dirname, 23 | }, 24 | }, 25 | }) 26 | ``` 27 | 28 | - Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` 29 | - Optionally add `...tseslint.configs.stylisticTypeChecked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: 31 | 32 | ```js 33 | // eslint.config.js 34 | import react from 'eslint-plugin-react' 35 | 36 | export default tseslint.config({ 37 | // Set the react version 38 | settings: { react: { version: '18.3' } }, 39 | plugins: { 40 | // Add the react plugin 41 | react, 42 | }, 43 | rules: { 44 | // other rules... 45 | // Enable its recommended rules 46 | ...react.configs.recommended.rules, 47 | ...react.configs['jsx-runtime'].rules, 48 | }, 49 | }) 50 | ``` 51 | -------------------------------------------------------------------------------- /demo/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "start": "vite", 8 | "dev": "vite", 9 | "build": "vite build", 10 | "lint": "eslint .", 11 | "preview": "vite preview" 12 | }, 13 | "dependencies": { 14 | "@emotion/react": "^11.13.3", 15 | "@emotion/styled": "^11.13.0", 16 | "@jasonrundell/react-mega-menu": "file:../jasonrundell-react-mega-menu-2.2.2.tgz", 17 | "react": "^18.3.1", 18 | "react-dom": "^18.3.1" 19 | }, 20 | "devDependencies": { 21 | "@eslint/js": "^9.9.0", 22 | "@types/react": "^18.3.3", 23 | "@types/react-dom": "^18.3.0", 24 | "@vitejs/plugin-react": "^4.3.1", 25 | "eslint": "^9.9.0", 26 | "eslint-plugin-react-hooks": "^5.1.0-rc.0", 27 | "eslint-plugin-react-refresh": "^0.4.9", 28 | "globals": "^15.9.0", 29 | "prettier": "^2.7.1", 30 | "typescript": "^5.5.3", 31 | "typescript-eslint": "^8.0.1", 32 | "vite": "^5.4.6" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /demo/public/click.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasonrundell/react-mega-menu/745d52d94a1e891593b8d6567748754765ea135c/demo/public/click.mp3 -------------------------------------------------------------------------------- /demo/public/images/icons/dark/angle-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /demo/public/images/icons/dark/angle-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /demo/public/images/icons/dark/angle-up.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /demo/public/images/icons/light/angle-left.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/public/images/icons/light/angle-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /demo/public/images/icons/light/angle-up.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/public/images/icons/monokai/angle-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /demo/public/images/icons/monokai/angle-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /demo/public/images/icons/monokai/angle-up.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /demo/public/images/icons/retro/angle-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /demo/public/images/icons/retro/angle-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /demo/public/images/icons/retro/angle-up.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /demo/public/images/icons/synthwave/angle-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /demo/public/images/icons/synthwave/angle-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /demo/public/images/icons/synthwave/angle-up.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /demo/public/images/logos/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | React Logo 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /demo/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/src/App.css: -------------------------------------------------------------------------------- 1 | /* App.css */ 2 | body { 3 | background-color: #bebebe; 4 | } 5 | 6 | main { 7 | margin-top: 10rem; 8 | } 9 | -------------------------------------------------------------------------------- /demo/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from 'react' 2 | // import { Menu } from '../../src/index' // local development mode 3 | import { Menu } from '@jasonrundell/react-mega-menu' 4 | import './App.css' 5 | 6 | export interface MenuItem { 7 | id: string 8 | label: string 9 | type: string 10 | url: string 11 | description?: string 12 | items?: MenuItem[] 13 | } 14 | 15 | export interface MenuConfig { 16 | topbar: { 17 | id: string 18 | logo: { 19 | src: string 20 | alt: string 21 | rel: string 22 | } 23 | title: string 24 | } 25 | menu: { 26 | items: MenuItem[] 27 | } 28 | } 29 | 30 | /** 31 | * Here's a static configuration example of a menu configuration object. 32 | * If menuConfig doesn't depend on any state or props of App, hoisting it can help improve performance 33 | * and code clarity. Otherwise, move it to App's state. 34 | */ 35 | const menuConfig: MenuConfig = { 36 | topbar: { 37 | id: 'topbar', 38 | logo: { 39 | src: 'https://via.placeholder.com/150x50', 40 | alt: 'Placeholder Logo', 41 | rel: 'home' 42 | }, 43 | title: 'React Mega Menu' 44 | }, 45 | menu: { 46 | items: [ 47 | { 48 | id: 'home', 49 | label: 'Home', 50 | type: 'main', 51 | url: '/' 52 | }, 53 | { 54 | id: 'about', 55 | label: 'About', 56 | type: 'main', 57 | url: '/about/' 58 | }, 59 | { 60 | id: 'store', 61 | label: 'Store', 62 | type: 'mega', 63 | url: '/store/', 64 | items: [ 65 | { 66 | id: 'store-deals', 67 | label: 'Deals', 68 | type: 'link', 69 | url: '/store/deals/', 70 | description: 71 | "Three lined small description that accompanies link in the React Mega Menu project. This maybe too much text? Who's to say, really. We'll leave it to fate to decide." 72 | }, 73 | { 74 | id: 'store-kitchen', 75 | label: 'Kitchen', 76 | type: 'link', 77 | url: '/store/kitchen/', 78 | description: 79 | "Three lined small description that accompanies link in the React Mega Menu project. This maybe too much text? Who's to say, really. We'll leave it to fate to decide." 80 | }, 81 | { 82 | id: 'store-outdoors', 83 | label: 'Outdoors', 84 | type: 'sub', 85 | url: '/store/outdoors/', 86 | description: 87 | "Three lined small description that accompanies link in the React Mega Menu project. This maybe too much text? Who's to say, really. We'll leave it to fate to decide.", 88 | items: [ 89 | { 90 | id: 'store-outdoors-tools', 91 | label: 'Tools', 92 | type: 'link', 93 | url: '/store/outdoors/tools/', 94 | description: 'Single line description that accompanies link' 95 | }, 96 | { 97 | id: 'store-outdoors-plants', 98 | label: 'Plants', 99 | type: 'link', 100 | url: '/store/outdoors/plants/', 101 | description: 'Single line description that accompanies link' 102 | }, 103 | { 104 | id: 'store-outdoors-patio', 105 | label: 'Patio', 106 | type: 'link', 107 | url: '/store/outdoors/patio/', 108 | description: 'Single line description that accompanies link' 109 | }, 110 | { 111 | id: 'store-outdoors-decking', 112 | label: 'Decking', 113 | type: 'link', 114 | url: '/store/outdoors/decking/', 115 | description: 'Single line description that accompanies link' 116 | } 117 | ] 118 | }, 119 | { 120 | id: 'store-bedroom', 121 | label: 'Bedroom', 122 | type: 'sub', 123 | url: '/store/bedroom/', 124 | description: 125 | "Three lined small description that accompanies link in the React Mega Menu project. This maybe too much text? Who's to say, really. We'll leave it to fate to decide.", 126 | items: [ 127 | { 128 | id: 'store-bedroom-beds', 129 | label: 'Beds', 130 | type: 'link', 131 | url: '/store/bedroom/beds/', 132 | description: 'Single line description that accompanies link' 133 | }, 134 | { 135 | id: 'store-bedroom-dressers', 136 | label: 'Dressers', 137 | type: 'link', 138 | url: '/store/bedroom/dressers/', 139 | description: 140 | 'Double lined small description that accompanies link in the React Mega Menu project' 141 | }, 142 | { 143 | id: 'store-bedroom-nightstands', 144 | label: 'Nightstands', 145 | type: 'link', 146 | url: '/store/bedroom/nightstands/', 147 | description: 148 | 'Double lined small description that accompanies link in the React Mega Menu project' 149 | }, 150 | { 151 | id: 'store-bedroom-benches', 152 | label: 'Benches', 153 | type: 'link', 154 | url: '/store/bedroom/benches/', 155 | description: 156 | 'Double lined small description that accompanies link in the React Mega Menu project' 157 | } 158 | ] 159 | } 160 | ] 161 | }, 162 | { 163 | id: 'blog', 164 | label: 'Blog', 165 | type: 'mega', 166 | url: '/blog/', 167 | items: [ 168 | { 169 | id: 'blog-latest-post-title', 170 | label: 'Latest Post Title', 171 | type: 'link', 172 | url: '/blog/posts/latest-post-title/', 173 | description: 174 | 'Double lined small description that accompanies link in the React Mega Menu project' 175 | }, 176 | { 177 | id: 'blog-categories', 178 | label: 'Categories', 179 | type: 'sub', 180 | url: '/blog/categories/', 181 | items: [ 182 | { 183 | id: 'blog-news', 184 | label: 'News', 185 | type: 'link', 186 | url: '/blog/news/' 187 | }, 188 | { 189 | id: 'blog-recipes', 190 | label: 'Recipes', 191 | type: 'link', 192 | url: '/blog/recipes/' 193 | }, 194 | { 195 | id: 'blog-health', 196 | label: 'Health', 197 | type: 'link', 198 | url: '/blog/health/' 199 | }, 200 | { 201 | id: 'blog-diet', 202 | label: 'Diet', 203 | type: 'link', 204 | url: '/blog/diet/' 205 | } 206 | ] 207 | } 208 | ] 209 | }, 210 | { 211 | id: 'help', 212 | label: 'Help', 213 | type: 'mega', 214 | url: '/help/', 215 | items: [ 216 | { 217 | id: 'help-react-mega-menu', 218 | label: 'React Mega Menu', 219 | type: 'link', 220 | url: 'https://github.com/jasonrundell/react-mega-menu', 221 | description: 222 | 'A React project which aims to be an accessible, responsive, boilerplate top navigation menu with a "Mega Menu"!' 223 | }, 224 | { 225 | id: 'help-faq', 226 | label: 'FAQ', 227 | type: 'link', 228 | url: '/help/faq/', 229 | description: 'Single line description that accompanies link' 230 | }, 231 | { 232 | id: 'help-knowledge-base', 233 | label: 'Knowledge Base', 234 | type: 'link', 235 | url: '/help/knowledge-base/', 236 | description: 237 | 'Double lined small description that accompanies link in the React Mega Menu project' 238 | } 239 | ] 240 | }, 241 | { 242 | id: 'settings', 243 | label: 'Settings', 244 | type: 'mega', 245 | url: '/settings/', 246 | items: [ 247 | { 248 | id: 'settings-profile', 249 | label: 'Profile', 250 | type: 'link', 251 | url: '/settings/profile/', 252 | description: 'Single line description that accompanies link' 253 | }, 254 | { 255 | id: 'settings-billing', 256 | label: 'Billing', 257 | type: 'link', 258 | url: '/settings/billing/', 259 | description: 'Single line description that accompanies link' 260 | }, 261 | { 262 | id: 'settings-theme', 263 | label: 'Theme', 264 | type: 'sub', 265 | url: '#', 266 | description: 'Change the React Mega Menu theme', 267 | items: [ 268 | { 269 | id: 'settings-theme-light', 270 | label: 'Light', 271 | type: 'link', 272 | url: '/?theme=light' 273 | }, 274 | { 275 | id: 'settings-theme-dark', 276 | label: 'Dark', 277 | type: 'link', 278 | url: '/?theme=dark' 279 | }, 280 | { 281 | id: 'settings-theme-monokai', 282 | label: 'Monokai', 283 | type: 'link', 284 | url: '/?theme=monokai' 285 | }, 286 | { 287 | id: 'settings-theme-retro', 288 | label: 'Retro', 289 | type: 'link', 290 | url: '/?theme=retro' 291 | }, 292 | { 293 | id: 'settings-theme-synthwave', 294 | label: 'Synthwave', 295 | type: 'link', 296 | url: '/?theme=synthwave' 297 | } 298 | ] 299 | }, 300 | { 301 | id: 'settings-logout', 302 | label: 'Logout', 303 | type: 'link', 304 | url: '/settings/logout/', 305 | description: 'Single line description that accompanies link' 306 | } 307 | ] 308 | }, 309 | { 310 | id: 'contact', 311 | label: 'Contact', 312 | type: 'main', 313 | url: '#contact' 314 | } 315 | ] 316 | } 317 | } 318 | 319 | function App() { 320 | const themes = ['light', 'dark', 'monokai', 'retro', 'synthwave'] 321 | 322 | // states for toggling head styling and changing themes 323 | const [headEnabled, setHeadEnabled] = useState(true) 324 | const [headElement] = useState(document.head) 325 | const [currentTheme, setCurrentTheme] = useState(themes[0]) 326 | 327 | // Apply the theme class to the menu when the component mounts 328 | useEffect(() => { 329 | const rmmNav = document.getElementById('rmm__menu') 330 | if (rmmNav) { 331 | themes.forEach((theme) => rmmNav.classList.remove(`rmm__theme--${theme}`)) 332 | rmmNav.classList.add(`rmm__theme--${currentTheme}`) 333 | } 334 | }, [currentTheme, themes]) 335 | 336 | // Insert or remove the head element based on the headEnabled state 337 | useEffect(() => { 338 | if (headEnabled) { 339 | if (headElement && !document.documentElement.contains(headElement)) { 340 | document.documentElement.insertBefore(headElement, document.body) 341 | } 342 | } else { 343 | if (headElement && document.documentElement.contains(headElement)) { 344 | headElement.remove() 345 | } 346 | } 347 | }, [headEnabled, headElement]) 348 | 349 | /** 350 | * This useEffect hook will check the URL query string for a theme parameter 351 | * and apply the theme if it exists. 352 | * This is useful for sharing a specific theme with others. For example: 353 | * https://example.com?theme=dark will load the dark theme. 354 | * https://example.com?theme=light will load the light theme. 355 | */ 356 | useEffect(() => { 357 | const params = new URLSearchParams(window.location.search) 358 | const themeParam = params.get('theme') 359 | if (themeParam) { 360 | setCurrentTheme(themeParam) 361 | } 362 | }, []) 363 | 364 | const toggleHead = () => { 365 | setHeadEnabled(!headEnabled) 366 | } 367 | 368 | const handleThemeChange = (theme: string) => { 369 | setCurrentTheme(theme) 370 | } 371 | 372 | // Function to dynamically import the theme CSS 373 | const loadTheme = useCallback( 374 | async (theme: string) => { 375 | try { 376 | // Remove the previous theme classes if necessary 377 | const rmmNav = document.getElementById('rmm__menu') 378 | if (rmmNav) { 379 | themes.forEach((theme) => 380 | rmmNav.classList.remove(`rmm__theme--${theme}`) 381 | ) 382 | } 383 | 384 | // Dynamically import the theme CSS file 385 | if (theme) { 386 | await import(`./themes/${theme}.css`) 387 | 388 | // Apply the selected theme class 389 | if (rmmNav) { 390 | rmmNav.classList.add(`rmm__theme--${theme}`) 391 | } 392 | } 393 | } catch (err) { 394 | console.error(`Failed to load the ${theme} theme`, err) 395 | } 396 | }, 397 | [themes] 398 | ) 399 | 400 | useEffect(() => { 401 | loadTheme(currentTheme) 402 | }, [currentTheme, loadTheme]) 403 | 404 | return ( 405 |
406 | 407 |
408 |

React Mega Menu Demo

409 |
410 |

411 | A React library project which aims to be an accessible, responsive, 412 | boilerplate top navigation menu with a "Mega Menu"! 413 |

414 |

Features

415 |
    416 |
  • WCAG 2.1 AA compliant
  • 417 |
  • W3C valid markup
  • 418 |
  • Fly-out menus
  • 419 |
  • Supports keyboard navigation and screen readers
  • 420 |
  • 421 | Responsively designed to adapt to modern mobile and desktop screen 422 | sizes 423 |
  • 424 |
  • 425 | Styled (lightly) with Emotion 426 |
  • 427 |
  • 428 | The project supports theme customization with vanilla CSS, as 429 | demonstrated in the synthwave.css theme. 430 |
  • 431 |
  • Supports and tested against Edge, Safari, FireFox, and Chrome
  • 432 |
  • 433 | Includes CSS animations that respect the{' '} 434 | 435 | prefers-reduced-motion 436 | {' '} 437 | media query for users who prefer reduced motion 438 |
  • 439 |
  • 440 | Includes a demo project using Next.js, showcasing how to integrate 441 | the menu with a Next.js application 442 |
  • 443 |
444 |
445 |

Semantically designed structure

446 |

447 | The menu is designed to be as semantically correct as possible. The 448 | top-level menu items are nav elements, and the submenus 449 | are ul elements. The menu items are li{' '} 450 | elements, and the links are a elements. The menu is 451 | accessible through keyboard navigation and screen readers. 452 |

453 | 456 |
457 |

Styling the menu

458 |

459 | This menu component is designed to be highly customizable. You can 460 | apply your own CSS styles to the menu by targeting the appropriate 461 | classes. The menu structure is built using semantic HTML elements, 462 | which makes it easy to style using CSS. 463 |

464 |

465 | The top-level menu items are wrapped in nav elements, and 466 | the submenus are wrapped in ul elements. Each menu item 467 | is an li element, and the links are a{' '} 468 | elements. This structure allows you to use standard CSS selectors to 469 | apply styles to different parts of the menu. 470 |

471 |

472 | Additionally, the menu supports themes, which are applied by adding a 473 | theme-specific class to the menu container. You can create your own 474 | themes by defining CSS classes that follow the naming convention{' '} 475 | .rmm__theme--your-theme-name and applying them to the 476 | menu container. 477 |

478 |

Try out a theme:

479 |
    480 | {themes.map((theme) => ( 481 |
  • 482 | 485 |
  • 486 | ))} 487 |
  • 488 | 489 |
  • 490 |
491 |

492 | 493 | Note how changing the theme only affects the mega menu and not the 494 | rest of the page/application. 495 | 496 |

497 |

Showcase your theme

498 |

499 | Submit a{' '} 500 | 504 | pull request 505 | {' '} 506 | to add your theme to the demo! 507 |

508 |
509 |
510 | ) 511 | } 512 | 513 | export default App 514 | -------------------------------------------------------------------------------- /demo/src/index.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasonrundell/react-mega-menu/745d52d94a1e891593b8d6567748754765ea135c/demo/src/index.css -------------------------------------------------------------------------------- /demo/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import App from './App.tsx' 4 | 5 | createRoot(document.getElementById('root')!).render( 6 | 7 | 8 | 9 | ) 10 | -------------------------------------------------------------------------------- /demo/src/themes/dark.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --dark-bg-color: #222; 3 | --dark-text-color: #fff; 4 | --dark-border-color: #fff; 5 | --dark-icon-right: url(/images/icons/dark/angle-right.svg); 6 | --dark-icon-left: url(/images/icons/dark/angle-left.svg); 7 | --dark-icon-up: url(/images/icons/dark/angle-up.svg); 8 | --menu-top-spacing: 8rem; 9 | --menu-padding-left: 1rem; 10 | --nav-item-padding-left: 1rem; 11 | --menu-border-width: 0.0625rem; 12 | --margin-large-screen-topbar: 1rem; 13 | } 14 | 15 | .rmm__theme--dark[class*='css-'], 16 | .rmm__theme--dark[class*='css-'] #rmm__topbar, 17 | .rmm__theme--dark[class*='css-'] .rmm__nav, 18 | .rmm__theme--dark[class*='css-'] .rmm__mega-list, 19 | .rmm__theme--dark[class*='css-'] .rmm__nav-list, 20 | .rmm__theme--dark[class*='css-'] .rmm__nav-list--sub, 21 | .rmm__theme--dark[class*='css-'] #rmm__hamburger--label { 22 | background-color: var(--dark-bg-color); 23 | color: var(--dark-text-color); 24 | font-family: Arial, Helvetica, sans-serif; 25 | } 26 | 27 | .rmm__theme--dark[class*='css-'] #rmm__topbar { 28 | height: 2rem; 29 | padding: var(--menu-padding-left); 30 | } 31 | 32 | .rmm__theme--dark[class*='css-'] #rmm__hamburger { 33 | border: none; 34 | background: none; 35 | } 36 | 37 | .rmm__theme--dark[class*='css-'] #rmm__hamburger--label { 38 | margin-left: var(--menu-padding-left); 39 | color: var(--dark-text-color); 40 | } 41 | 42 | .rmm__theme--dark[class*='css-'] .rmm__hamburger--slice { 43 | background-color: var(--dark-text-color); 44 | } 45 | 46 | .rmm__theme--dark[class*='css-'] #rmm__nav { 47 | border-top: var(--menu-border-width) solid var(--dark-border-color); 48 | border-bottom: var(--menu-border-width) solid var(--dark-border-color); 49 | top: var(--menu-top-spacing); 50 | } 51 | 52 | .rmm__theme--dark[class*='css-'] .rmm__nav-list { 53 | margin-top: var(--menu-padding-left); 54 | } 55 | 56 | .rmm__theme--dark[class*='css-'] .rmm__nav-list--sub { 57 | margin-top: 0; 58 | margin-left: 0; 59 | } 60 | 61 | .rmm__theme--dark[class*='css-'] .rmm__main-nav-item-link--icon { 62 | content: var(--dark-icon-right); 63 | position: absolute; 64 | right: 2rem; 65 | bottom: -30%; 66 | width: 1rem; 67 | height: 2rem; 68 | } 69 | 70 | .rmm__theme--dark[class*='css-'] .rmm__nav-item { 71 | padding-left: var(--nav-item-padding-left); 72 | } 73 | 74 | .rmm__theme--dark[class*='css-'] .rmm__nav-item-link { 75 | color: var(--dark-text-color); 76 | align-items: center; 77 | padding: 0; 78 | margin: 0; 79 | } 80 | 81 | .rmm__theme--dark[class*='css-'] .rmm__nav-item-link--forward { 82 | height: 2rem; 83 | } 84 | .rmm__theme--dark[class*='css-'] .rmm__nav-item-link--forward:after { 85 | content: var(--dark-icon-right); 86 | position: absolute; 87 | right: 2rem; 88 | top: 0; 89 | width: 1rem; 90 | } 91 | 92 | .rmm__theme--dark[class*='css-'] .rmm__nav-item-link--back { 93 | padding-left: 2rem; 94 | height: 2rem; 95 | } 96 | 97 | .rmm__theme--dark[class*='css-'] .rmm__nav-item-link--back:before { 98 | content: var(--dark-icon-left); 99 | position: absolute; 100 | left: 0; 101 | top: 0; 102 | width: 1rem; 103 | } 104 | 105 | .rmm__theme--dark[class*='css-'] h1, 106 | .rmm__theme--dark[class*='css-'] #rmm__title, 107 | .rmm__theme--dark[class*='css-'] .rmm__main-nav-item-link, 108 | .rmm__theme--dark[class*='css-'] .rmm__nav-item-description { 109 | color: var(--dark-text-color); 110 | } 111 | 112 | .rmm__theme--dark[class*='css-'] .rmm__nav-item-description { 113 | padding-right: 2.5rem; 114 | } 115 | 116 | .rmm__theme--dark[class*='css-'] .rmm__main-nav-item, 117 | .rmm__theme--dark[class*='css-'] #rmm__nav { 118 | background-color: var(--dark-bg-color); 119 | } 120 | 121 | .rmm__theme--dark[class*='css-'] .rmm__main-nav-item { 122 | padding-left: var(--menu-padding-left); 123 | } 124 | 125 | .rmm__theme--dark[class*='css-'] .rmm__nav-item--heading { 126 | font-weight: 700; 127 | } 128 | 129 | @media (min-width: 64rem) { 130 | .rmm__theme--dark[class*='css-'] #rmm__topbar { 131 | border-top: none; 132 | border-right: none; 133 | } 134 | 135 | .rmm__theme--dark[class*='css-'] #rmm__nav { 136 | top: 4rem; 137 | height: auto; 138 | border-top: var(--menu-border-width) solid var(--dark-border-color); 139 | } 140 | 141 | .rmm__theme--dark[class*='css-'] .rmm__nav { 142 | border-top: none; 143 | border-bottom: var(--menu-border-width) solid var(--dark-border-color); 144 | } 145 | 146 | .rmm__theme--dark[class*='css-'] .rmm__nav-list, 147 | .rmm__theme--dark[class*='css-'] .rmm__mega-list { 148 | margin-top: 0; 149 | } 150 | 151 | .rmm__theme--dark[class*='css-'] .rmm__mega-list { 152 | border-top: none; 153 | border-right: 0.25rem solid #999; 154 | border-bottom: 0.25rem solid #666; 155 | margin-left: 0; 156 | } 157 | 158 | .rmm__theme--dark[class*='css-'] .rmm__nav-list .rmm__nav-list { 159 | padding-bottom: 1rem; 160 | } 161 | 162 | .rmm__theme--dark[class*='css-'] .rmm__nav-list--sub { 163 | display: flex; 164 | flex-direction: column; 165 | } 166 | 167 | .rmm__theme--dark[class*='css-'] .rmm__nav-list--sub .rmm__nav-item { 168 | padding-bottom: 1rem; 169 | } 170 | 171 | .rmm__theme--dark[class*='css-'] .rmm__nav-item--heading { 172 | display: none; 173 | } 174 | 175 | .rmm__theme--dark[class*='css-'] .rmm__main-nav-item-link--icon { 176 | content: var(--dark-icon-up); 177 | position: relative; 178 | left: 0.5rem; 179 | bottom: initial; 180 | width: 1rem; 181 | } 182 | 183 | .rmm__theme--dark[class*='css-'] .rmm__main-nav-item-link--icon.active { 184 | transform: rotate(180deg); 185 | top: 0px; 186 | } 187 | 188 | .rmm__theme--dark[class*='css-'] .rmm__nav-item-link--forward:after, 189 | .rmm__theme--dark[class*='css-'] .rmm__nav-item-link--back:before { 190 | content: none; 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /demo/src/themes/light.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --light-bg-color: #fff; 3 | --light-text-color: #000; 4 | --light-border-color: #000; 5 | --light-icon-angle-right: url(/images/icons/light/angle-right.svg); 6 | --light-icon-angle-left: url(/images/icons/light/angle-left.svg); 7 | --light-icon-angle-up: url(/images/icons/light/angle-up.svg); 8 | --hamburger-padding-left: 1rem; 9 | --nav-item-padding-left: 1rem; 10 | --topbar-height: 2rem; 11 | --topbar-padding: 1rem; 12 | --menu-top: 8rem; 13 | --menu-border-width: 0.0625rem; 14 | --forward-icon-right: 2rem; 15 | --back-icon-left: 0; 16 | } 17 | 18 | .rmm__theme--light[class*='css-'], 19 | .rmm__theme--light[class*='css-'] #rmm__topbar, 20 | .rmm__theme--light[class*='css-'] .rmm__nav, 21 | .rmm__theme--light[class*='css-'] .rmm__mega-list, 22 | .rmm__theme--light[class*='css-'] .rmm__nav-list, 23 | .rmm__theme--light[class*='css-'] .rmm__nav-list--sub { 24 | background-color: var(--light-bg-color); 25 | color: var(--light-text-color); 26 | font-family: Arial, Helvetica, sans-serif; 27 | } 28 | 29 | .rmm__theme--light[class*='css-'] #rmm__topbar { 30 | height: var(--topbar-height); 31 | padding: var(--topbar-padding); 32 | } 33 | 34 | .rmm__theme--light[class*='css-'] #rmm__hamburger { 35 | border: none; 36 | background: none; 37 | } 38 | 39 | .rmm__theme--light[class*='css-'] #rmm__hamburger--label { 40 | margin-left: var(--hamburger-padding-left); 41 | color: var(--light-text-color); 42 | } 43 | 44 | .rmm__theme--light[class*='css-'] .rmm__hamburger--slice { 45 | background-color: var(--light-text-color); 46 | } 47 | 48 | .rmm__theme--light[class*='css-'] #rmm__nav { 49 | border-top: var(--menu-border-width) solid var(--light-border-color); 50 | border-bottom: var(--menu-border-width) solid var(--light-border-color); 51 | top: var(--menu-top); 52 | } 53 | 54 | .rmm__theme--light[class*='css-'] .rmm__nav-list { 55 | margin-top: 1rem; 56 | } 57 | 58 | .rmm__theme--light[class*='css-'] .rmm__nav-list--sub { 59 | margin-top: 0; 60 | margin-left: 0; 61 | } 62 | 63 | .rmm__theme--light[class*='css-'] .rmm__main-nav-item-link--icon { 64 | content: var(--light-icon-angle-right); 65 | position: absolute; 66 | right: var(--forward-icon-right); 67 | bottom: -30%; 68 | width: 1rem; 69 | height: 2rem; 70 | } 71 | 72 | .rmm__theme--light[class*='css-'] .rmm__nav-item { 73 | padding-left: var(--nav-item-padding-left); 74 | } 75 | 76 | .rmm__theme--light[class*='css-'] .rmm__nav-item-link { 77 | color: var(--light-text-color); 78 | align-items: center; 79 | padding: 0; 80 | margin: 0; 81 | } 82 | 83 | .rmm__theme--light[class*='css-'] .rmm__nav-item-link--forward { 84 | height: 2rem; 85 | } 86 | 87 | .rmm__theme--light[class*='css-'] .rmm__nav-item-link--forward:after { 88 | content: var(--light-icon-angle-right); 89 | position: absolute; 90 | right: var(--forward-icon-right); 91 | top: 0; 92 | width: 1rem; 93 | } 94 | 95 | .rmm__theme--light[class*='css-'] .rmm__nav-item-link--back { 96 | padding-left: 2rem; 97 | height: 2rem; 98 | } 99 | 100 | .rmm__theme--light[class*='css-'] .rmm__nav-item-link--back:before { 101 | content: var(--light-icon-angle-left); 102 | position: absolute; 103 | left: var(--back-icon-left); 104 | top: 0; 105 | width: 1rem; 106 | } 107 | 108 | .rmm__theme--light[class*='css-'] h1, 109 | .rmm__theme--light[class*='css-'] #rmm__title, 110 | .rmm__theme--light[class*='css-'] .rmm__main-nav-item-link, 111 | .rmm__theme--light[class*='css-'] .rmm__nav-item-description { 112 | color: var(--light-text-color); 113 | } 114 | 115 | .rmm__theme--light[class*='css-'] .rmm__nav-item-description { 116 | padding-right: 2.5rem; 117 | } 118 | 119 | .rmm__theme--light[class*='css-'] .rmm__main-nav-item, 120 | .rmm__theme--light[class*='css-'] #rmm__nav { 121 | background-color: var(--light-bg-color); 122 | } 123 | 124 | .rmm__theme--light[class*='css-'] .rmm__main-nav-item { 125 | padding-left: var(--nav-item-padding-left); 126 | } 127 | 128 | .rmm__theme--light[class*='css-'] .rmm__nav-item--heading { 129 | font-weight: 700; 130 | } 131 | 132 | @media (min-width: 64rem) { 133 | .rmm__theme--light[class*='css-'] #rmm__topbar { 134 | border-top: none; 135 | border-right: none; 136 | } 137 | 138 | .rmm__theme--light[class*='css-'] #rmm__nav { 139 | top: 4rem; 140 | height: auto; 141 | border-top: var(--menu-border-width) solid var(--light-border-color); 142 | } 143 | 144 | .rmm__theme--light[class*='css-'] .rmm__nav { 145 | border-top: none; 146 | border-bottom: var(--menu-border-width) solid var(--light-border-color); 147 | } 148 | 149 | .rmm__theme--light[class*='css-'] .rmm__nav-list, 150 | .rmm__theme--light[class*='css-'] .rmm__mega-list { 151 | margin-top: 0; 152 | } 153 | 154 | .rmm__theme--light[class*='css-'] .rmm__mega-list { 155 | border-top: none; 156 | border-right: 0.25rem solid #666; 157 | border-bottom: 0.25rem solid #333; 158 | margin-left: 0; 159 | } 160 | 161 | .rmm__theme--light[class*='css-'] .rmm__nav-list .rmm__nav-list { 162 | padding-bottom: 1rem; 163 | } 164 | 165 | .rmm__theme--light[class*='css-'] .rmm__nav-list--sub { 166 | display: flex; 167 | flex-direction: column; 168 | } 169 | 170 | .rmm__theme--light[class*='css-'] .rmm__nav-list--sub .rmm__nav-item { 171 | padding-bottom: 1rem; 172 | } 173 | 174 | .rmm__theme--light[class*='css-'] .rmm__nav-item { 175 | display: flex; 176 | flex-direction: column; 177 | align-items: flex-start; 178 | justify-content: flex-start; 179 | height: auto; 180 | margin-top: 0; 181 | margin-right: 0; 182 | margin-bottom: 0; 183 | margin-left: 0; 184 | padding-left: 1rem; 185 | padding-right: 1rem; 186 | } 187 | 188 | .rmm__theme--light[class*='css-'] .rmm__nav-item--heading { 189 | display: none; 190 | } 191 | 192 | .rmm__theme--light[class*='css-'] .rmm__main-nav-item-link--icon { 193 | content: var(--light-icon-angle-up); 194 | position: relative; 195 | left: 0.5rem; 196 | bottom: initial; 197 | width: 1rem; 198 | } 199 | 200 | .rmm__theme--light[class*='css-'] .rmm__main-nav-item-link--icon.active { 201 | transform: rotate(180deg); 202 | top: 0px; 203 | } 204 | 205 | .rmm__theme--light[class*='css-'] .rmm__nav-item-link--forward:after, 206 | .rmm__theme--light[class*='css-'] .rmm__nav-item-link--back:before { 207 | content: none; 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /demo/src/themes/monokai.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&family=Press+Start+2P&display=swap'); 2 | 3 | :root { 4 | --monokai-bg-color: #272822; /* Dark background */ 5 | --monokai-text-color: #f8f8f2; /* Light text */ 6 | --monokai-border-color: #66d9ef; /* Cyan border */ 7 | --monokai-icon-angle-right: url(/images/icons/monokai/angle-right.svg); 8 | --monokai-icon-angle-left: url(/images/icons/monokai/angle-left.svg); 9 | --monokai-icon-angle-up: url(/images/icons/monokai/angle-up.svg); 10 | --hamburger-padding-left: 1rem; 11 | --nav-item-padding-left: 1rem; 12 | --topbar-height: 2rem; 13 | --topbar-padding: 1rem; 14 | --menu-top: 8rem; 15 | --menu-border-width: 0.0625rem; 16 | --forward-icon-right: 2rem; 17 | --back-icon-left: 0; 18 | } 19 | 20 | .rmm__theme--monokai[class*='css-'], 21 | .rmm__theme--monokai[class*='css-'] #rmm__topbar, 22 | .rmm__theme--monokai[class*='css-'] .rmm__nav, 23 | .rmm__theme--monokai[class*='css-'] .rmm__mega-list, 24 | .rmm__theme--monokai[class*='css-'] .rmm__nav-list, 25 | .rmm__theme--monokai[class*='css-'] .rmm__nav-list--sub, 26 | .rmm__theme--monokai[class*='css-'] #rmm__hamburger--label { 27 | font-family: 'Fira Code', monospace; 28 | background-color: var(--monokai-bg-color); 29 | color: var(--monokai-text-color); 30 | } 31 | 32 | .rmm__theme--monokai[class*='css-'] #rmm__topbar { 33 | height: var(--topbar-height); 34 | padding: var(--topbar-padding); 35 | } 36 | 37 | .rmm__theme--monokai[class*='css-'] #rmm__hamburger { 38 | border: none; 39 | background: none; 40 | } 41 | 42 | .rmm__theme--monokai[class*='css-'] #rmm__hamburger--label { 43 | margin-left: var(--hamburger-padding-left); 44 | color: var(--monokai-text-color); 45 | } 46 | 47 | .rmm__theme--monokai[class*='css-'] .rmm__hamburger--slice { 48 | background-color: var(--monokai-text-color); 49 | } 50 | 51 | .rmm__theme--monokai[class*='css-'] #rmm__nav { 52 | border-top: var(--menu-border-width) solid var(--monokai-border-color); 53 | border-bottom: var(--menu-border-width) solid var(--monokai-border-color); 54 | top: var(--menu-top); 55 | } 56 | 57 | .rmm__theme--monokai[class*='css-'] .rmm__nav-list { 58 | margin-top: 1rem; 59 | } 60 | 61 | .rmm__theme--monokai[class*='css-'] .rmm__nav-list--sub { 62 | margin-top: 0; 63 | margin-left: 0; 64 | } 65 | 66 | .rmm__theme--monokai[class*='css-'] .rmm__main-nav-item-link--icon { 67 | content: var(--monokai-icon-angle-right); 68 | position: absolute; 69 | right: var(--forward-icon-right); 70 | bottom: -30%; 71 | width: 1rem; 72 | height: 2rem; 73 | } 74 | 75 | .rmm__theme--monokai[class*='css-'] .rmm__nav-item { 76 | padding-left: var(--nav-item-padding-left); 77 | } 78 | 79 | .rmm__theme--monokai[class*='css-'] .rmm__nav-item-link { 80 | color: var(--monokai-text-color); 81 | align-items: center; 82 | padding: 0; 83 | margin: 0; 84 | } 85 | 86 | .rmm__theme--monokai[class*='css-'] .rmm__nav-item-link--forward { 87 | height: 2rem; 88 | } 89 | 90 | .rmm__theme--monokai[class*='css-'] .rmm__nav-item-link--forward:after { 91 | content: var(--monokai-icon-angle-right); 92 | position: absolute; 93 | right: var(--forward-icon-right); 94 | top: 0; 95 | width: 1rem; 96 | } 97 | 98 | .rmm__theme--monokai[class*='css-'] .rmm__nav-item-link--back { 99 | padding-left: 2rem; 100 | height: 2rem; 101 | } 102 | 103 | .rmm__theme--monokai[class*='css-'] .rmm__nav-item-link--back:before { 104 | content: var(--monokai-icon-angle-left); 105 | position: absolute; 106 | left: var(--back-icon-left); 107 | top: 0; 108 | width: 1rem; 109 | } 110 | 111 | .rmm__theme--monokai[class*='css-'] h1, 112 | .rmm__theme--monokai[class*='css-'] #rmm__title, 113 | .rmm__theme--monokai[class*='css-'] .rmm__main-nav-item-link, 114 | .rmm__theme--monokai[class*='css-'] .rmm__nav-item-description { 115 | color: var(--monokai-text-color); 116 | } 117 | 118 | .rmm__theme--monokai[class*='css-'] .rmm__nav-item-description { 119 | padding-right: 2.5rem; 120 | } 121 | 122 | .rmm__theme--monokai[class*='css-'] .rmm__main-nav-item, 123 | .rmm__theme--monokai[class*='css-'] #rmm__nav { 124 | background-color: var(--monokai-bg-color); 125 | } 126 | 127 | .rmm__theme--monokai[class*='css-'] .rmm__main-nav-item { 128 | padding-left: var(--nav-item-padding-left); 129 | } 130 | 131 | .rmm__theme--monokai[class*='css-'] .rmm__nav-item--heading { 132 | font-weight: 700; 133 | } 134 | 135 | @media (min-width: 64rem) { 136 | .rmm__theme--monokai[class*='css-'] #rmm__topbar { 137 | border-top: none; 138 | border-right: none; 139 | } 140 | 141 | .rmm__theme--monokai[class*='css-'] #rmm__nav { 142 | top: 4rem; 143 | height: auto; 144 | border-top: var(--menu-border-width) solid var(--monokai-border-color); 145 | } 146 | 147 | .rmm__theme--monokai[class*='css-'] .rmm__nav { 148 | border-top: none; 149 | border-bottom: var(--menu-border-width) solid var(--monokai-border-color); 150 | } 151 | 152 | .rmm__theme--monokai[class*='css-'] .rmm__nav-list, 153 | .rmm__theme--monokai[class*='css-'] .rmm__mega-list { 154 | margin-top: 0; 155 | } 156 | 157 | .rmm__theme--monokai[class*='css-'] .rmm__mega-list { 158 | border-top: none; 159 | border-right: 0.25rem solid #66d9ef; 160 | border-bottom: 0.25rem solid #a6e22e; /* Monokai green */ 161 | margin-left: 0; 162 | } 163 | 164 | .rmm__theme--monokai[class*='css-'] .rmm__nav-list .rmm__nav-list { 165 | padding-bottom: 1rem; 166 | } 167 | 168 | .rmm__theme--monokai[class*='css-'] .rmm__nav-list--sub { 169 | display: flex; 170 | flex-direction: column; 171 | } 172 | 173 | .rmm__theme--monokai[class*='css-'] .rmm__nav-list--sub .rmm__nav-item { 174 | padding-bottom: 1rem; 175 | } 176 | 177 | .rmm__theme--monokai[class*='css-'] .rmm__nav-item--heading { 178 | display: none; 179 | } 180 | 181 | .rmm__theme--monokai[class*='css-'] .rmm__main-nav-item-link--icon { 182 | content: var(--monokai-icon-angle-up); 183 | position: relative; 184 | left: 0.5rem; 185 | bottom: initial; 186 | width: 1rem; 187 | } 188 | 189 | .rmm__theme--monokai[class*='css-'] .rmm__main-nav-item-link--icon.active { 190 | transform: rotate(180deg); 191 | top: 0px; 192 | } 193 | 194 | .rmm__theme--monokai[class*='css-'] .rmm__nav-item-link--forward:after, 195 | .rmm__theme--monokai[class*='css-'] .rmm__nav-item-link--back:before { 196 | content: none; 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /demo/src/themes/retro.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&family=Fredoka:wght@300..700&family=Press+Start+2P&display=swap'); 2 | 3 | :root { 4 | --retro-bg-color: #f4e3c1; /* Warm beige background */ 5 | --retro-text-color: #4a3f35; /* Dark brown text */ 6 | --retro-border-color: #d94f04; /* Orange border */ 7 | --retro-icon-angle-right: url(/images/icons/retro/angle-right.svg); /* Retro-style icons */ 8 | --retro-icon-angle-left: url(/images/icons/retro/angle-left.svg); 9 | --retro-icon-angle-up: url(/images/icons/retro/angle-up.svg); 10 | --hamburger-padding-left: 1rem; 11 | --nav-item-padding-left: 1rem; 12 | --topbar-height: 2rem; 13 | --topbar-padding: 1rem; 14 | --menu-top: 8rem; 15 | --menu-border-width: 0.0625rem; 16 | --forward-icon-right: 2rem; 17 | --back-icon-left: 0; 18 | --retro-accent-color: #f7996e; /* Muted peach accent */ 19 | } 20 | 21 | .rmm__theme--retro[class*='css-'], 22 | .rmm__theme--retro[class*='css-'] #rmm__topbar, 23 | .rmm__theme--retro[class*='css-'] .rmm__nav, 24 | .rmm__theme--retro[class*='css-'] .rmm__mega-list, 25 | .rmm__theme--retro[class*='css-'] .rmm__nav-list, 26 | .rmm__theme--retro[class*='css-'] .rmm__nav-list--sub, 27 | .rmm__theme--retro[class*='css-'] #rmm__hamburger--label { 28 | font-family: 'Fredoka', sans-serif; 29 | background-color: var(--retro-bg-color); 30 | color: var(--retro-text-color); 31 | } 32 | 33 | .rmm__theme--retro[class*='css-'] #rmm__topbar { 34 | height: var(--topbar-height); 35 | padding: var(--topbar-padding); 36 | background-color: var(--retro-accent-color); /* Topbar with accent color */ 37 | } 38 | 39 | .rmm__theme--retro[class*='css-'] #rmm__hamburger { 40 | border: none; 41 | background: none; 42 | } 43 | 44 | .rmm__theme--retro[class*='css-'] #rmm__hamburger--label { 45 | margin-left: var(--hamburger-padding-left); 46 | color: var(--retro-text-color); 47 | } 48 | 49 | .rmm__theme--retro[class*='css-'] .rmm__hamburger--slice { 50 | background-color: var(--retro-text-color); 51 | } 52 | 53 | .rmm__theme--retro[class*='css-'] #rmm__nav { 54 | border-top: var(--menu-border-width) solid var(--retro-border-color); 55 | border-bottom: var(--menu-border-width) solid var(--retro-border-color); 56 | top: var(--menu-top); 57 | } 58 | 59 | .rmm__theme--retro[class*='css-'] .rmm__nav-list { 60 | margin-top: 1rem; 61 | } 62 | 63 | .rmm__theme--retro[class*='css-'] .rmm__nav-list--sub { 64 | margin-top: 0; 65 | margin-left: 0; 66 | } 67 | 68 | .rmm__theme--retro[class*='css-'] .rmm__main-nav-item-link--icon { 69 | content: var(--retro-icon-angle-right); 70 | position: absolute; 71 | right: var(--forward-icon-right); 72 | bottom: -30%; 73 | width: 1rem; 74 | height: 2rem; 75 | } 76 | 77 | .rmm__theme--retro[class*='css-'] .rmm__nav-item { 78 | padding-left: var(--nav-item-padding-left); 79 | } 80 | 81 | .rmm__theme--retro[class*='css-'] .rmm__nav-item-link { 82 | color: var(--retro-text-color); 83 | align-items: center; 84 | padding: 0; 85 | margin: 0; 86 | } 87 | 88 | .rmm__theme--retro[class*='css-'] .rmm__nav-item-link--forward { 89 | height: 2rem; 90 | } 91 | 92 | .rmm__theme--retro[class*='css-'] .rmm__nav-item-link--forward:after { 93 | content: var(--retro-icon-angle-right); 94 | position: absolute; 95 | right: var(--forward-icon-right); 96 | top: 0; 97 | width: 1rem; 98 | } 99 | 100 | .rmm__theme--retro[class*='css-'] .rmm__nav-item-link--back { 101 | padding-left: 2rem; 102 | height: 2rem; 103 | } 104 | 105 | .rmm__theme--retro[class*='css-'] .rmm__nav-item-link--back:before { 106 | content: var(--retro-icon-angle-left); 107 | position: absolute; 108 | left: var(--back-icon-left); 109 | top: 0; 110 | width: 1rem; 111 | } 112 | 113 | .rmm__theme--retro[class*='css-'] h1, 114 | .rmm__theme--retro[class*='css-'] #rmm__title, 115 | .rmm__theme--retro[class*='css-'] .rmm__main-nav-item-link, 116 | .rmm__theme--retro[class*='css-'] .rmm__nav-item-description { 117 | color: var(--retro-text-color); 118 | } 119 | 120 | .rmm__theme--retro[class*='css-'] .rmm__nav-item-description { 121 | padding-right: 2.5rem; 122 | } 123 | 124 | .rmm__theme--retro[class*='css-'] .rmm__main-nav-item, 125 | .rmm__theme--retro[class*='css-'] #rmm__nav { 126 | background-color: var(--retro-bg-color); 127 | } 128 | 129 | .rmm__theme--retro[class*='css-'] .rmm__main-nav-item { 130 | padding-left: var(--nav-item-padding-left); 131 | } 132 | 133 | .rmm__theme--retro[class*='css-'] .rmm__nav-item--heading { 134 | font-weight: 700; 135 | } 136 | 137 | @media (min-width: 64rem) { 138 | .rmm__theme--retro[class*='css-'] #rmm__topbar { 139 | border-top: none; 140 | border-right: none; 141 | background-color: var(--retro-accent-color); 142 | } 143 | 144 | .rmm__theme--retro[class*='css-'] #rmm__nav { 145 | top: 4rem; 146 | height: auto; 147 | border-top: var(--menu-border-width) solid var(--retro-border-color); 148 | } 149 | 150 | .rmm__theme--retro[class*='css-'] .rmm__nav { 151 | border-top: none; 152 | border-bottom: var(--menu-border-width) solid var(--retro-border-color); 153 | } 154 | 155 | .rmm__theme--retro[class*='css-'] .rmm__nav-list, 156 | .rmm__theme--retro[class*='css-'] .rmm__mega-list { 157 | margin-top: 0; 158 | } 159 | 160 | .rmm__theme--retro[class*='css-'] .rmm__mega-list { 161 | border-top: none; 162 | border-right: 0.25rem solid #d94f04; /* Darker orange */ 163 | border-bottom: 0.25rem solid #e1ba70; /* Muted yellow */ 164 | margin-left: 0; 165 | } 166 | 167 | .rmm__theme--retro[class*='css-'] .rmm__nav-list .rmm__nav-list { 168 | padding-bottom: 1rem; 169 | } 170 | 171 | .rmm__theme--retro[class*='css-'] .rmm__nav-list--sub { 172 | display: flex; 173 | flex-direction: column; 174 | } 175 | 176 | .rmm__theme--retro[class*='css-'] .rmm__nav-list--sub .rmm__nav-item { 177 | padding-bottom: 1rem; 178 | } 179 | 180 | .rmm__theme--retro[class*='css-'] .rmm__nav-item--heading { 181 | display: none; 182 | } 183 | 184 | .rmm__theme--retro[class*='css-'] .rmm__main-nav-item-link--icon { 185 | content: var(--retro-icon-angle-up); 186 | position: relative; 187 | left: 0.5rem; 188 | bottom: initial; 189 | width: 1rem; 190 | } 191 | 192 | .rmm__theme--retro[class*='css-'] .rmm__main-nav-item-link--icon.active { 193 | transform: rotate(180deg); 194 | top: 0px; 195 | } 196 | 197 | .rmm__theme--retro[class*='css-'] .rmm__nav-item-link--forward:after, 198 | .rmm__theme--retro[class*='css-'] .rmm__nav-item-link--back:before { 199 | content: none; 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /demo/src/themes/synthwave.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap'); 2 | 3 | :root { 4 | --synthwave-bg-color-dark: #1a1a2e; 5 | --synthwave-bg-color-gradient-start: #0f0c29; 6 | --synthwave-bg-color-gradient-middle: #302b63; 7 | --synthwave-bg-color-gradient-end: #24243e; 8 | --synthwave-neon-pink: #ff6ec7; 9 | --synthwave-neon-cyan: #14ffec; 10 | --synthwave-text-color-light: #f5f5f5; 11 | --synthwave-link-color: #ff6ec7; 12 | --synthwave-title-color: #14ffec; 13 | --synthwave-gradient-topbar-large-start: #2b5876; 14 | --synthwave-gradient-topbar-large-end: #4e4376; 15 | } 16 | 17 | /* Theme: synthwave */ 18 | .rmm__theme--synthwave[class*='css-'], 19 | .rmm__theme--synthwave[class*='css-'] #rmm__topbar, 20 | .rmm__theme--synthwave[class*='css-'] .rmm__nav, 21 | .rmm__theme--synthwave[class*='css-'] .rmm__mega-list, 22 | .rmm__theme--synthwave[class*='css-'] .rmm__nav-list, 23 | .rmm__theme--synthwave[class*='css-'] .rmm__nav-list--sub, 24 | .rmm__theme--synthwave[class*='css-'] #rmm__hamburger--label { 25 | font-family: 'Press Start 2P', system-ui; 26 | background-color: var(--synthwave-bg-color-dark); 27 | color: var(--synthwave-text-color-light); 28 | } 29 | 30 | .rmm__theme--synthwave[class*='css-'] #rmm__topbar { 31 | height: 2rem; 32 | padding: 1rem; 33 | background-color: var(--synthwave-bg-color-gradient-start); 34 | background-image: linear-gradient( 35 | 315deg, 36 | var(--synthwave-bg-color-gradient-start) 0%, 37 | var(--synthwave-bg-color-gradient-middle) 74%, 38 | var(--synthwave-bg-color-gradient-end) 100% 39 | ); 40 | } 41 | 42 | .rmm__theme--synthwave[class*='css-'] #rmm__hamburger { 43 | border: none; 44 | background: none; 45 | } 46 | 47 | .rmm__theme--synthwave[class*='css-'] #rmm__hamburger--label { 48 | margin-left: 1rem; 49 | color: var(--synthwave-text-color-light); 50 | } 51 | 52 | .rmm__theme--synthwave[class*='css-'] .rmm__hamburger--slice { 53 | background-color: var(--synthwave-neon-pink); 54 | } 55 | 56 | .rmm__theme--synthwave[class*='css-'] #rmm__nav { 57 | border-top: 0.0625rem solid var(--synthwave-neon-pink); 58 | border-bottom: 0.0625rem solid var(--synthwave-neon-cyan); 59 | top: 8rem; 60 | } 61 | 62 | .rmm__theme--synthwave[class*='css-'] .rmm__nav-list { 63 | margin-top: 1rem; 64 | } 65 | 66 | .rmm__theme--synthwave[class*='css-'] .rmm__nav-list--sub { 67 | margin-top: 0; 68 | margin-left: 0; 69 | } 70 | 71 | .rmm__theme--synthwave[class*='css-'] .rmm__main-nav-item-link--icon { 72 | content: url(/images/icons/synthwave/angle-right.svg); 73 | position: absolute; 74 | right: 2rem; 75 | bottom: -30%; 76 | width: 1rem; 77 | height: 2rem; 78 | } 79 | 80 | .rmm__theme--synthwave[class*='css-'] .rmm__nav-item { 81 | padding-left: 1rem; 82 | } 83 | 84 | .rmm__theme--synthwave[class*='css-'] .rmm__nav-item-link { 85 | color: var(--synthwave-link-color); 86 | align-items: center; 87 | padding: 0; 88 | margin: 0; 89 | } 90 | 91 | .rmm__theme--synthwave[class*='css-'] .rmm__nav-item-link--forward { 92 | height: 2rem; 93 | } 94 | 95 | .rmm__theme--synthwave[class*='css-'] .rmm__nav-item-link--forward:after { 96 | content: url(/images/icons/synthwave/angle-right.svg); 97 | position: absolute; 98 | right: 2rem; 99 | top: 0; 100 | width: 1rem; 101 | } 102 | 103 | .rmm__theme--synthwave[class*='css-'] .rmm__nav-item-link--back { 104 | padding-left: 2rem; 105 | height: 2rem; 106 | } 107 | 108 | .rmm__theme--synthwave[class*='css-'] .rmm__nav-item-link--back:before { 109 | content: url(/images/icons/synthwave/angle-left.svg); 110 | position: absolute; 111 | left: 0; 112 | top: 0; 113 | width: 1rem; 114 | } 115 | 116 | .rmm__theme--synthwave[class*='css-'] h1, 117 | .rmm__theme--synthwave[class*='css-'] #rmm__title, 118 | .rmm__theme--synthwave[class*='css-'] .rmm__main-nav-item-link, 119 | .rmm__theme--synthwave[class*='css-'] .rmm__nav-item-description { 120 | color: var(--synthwave-title-color); 121 | } 122 | 123 | .rmm__theme--synthwave[class*='css-'] .rmm__nav-item-description { 124 | padding-right: 2.5rem; 125 | } 126 | 127 | .rmm__theme--synthwave[class*='css-'] .rmm__main-nav-item, 128 | .rmm__theme--synthwave[class*='css-'] #rmm__nav { 129 | background-color: var(--synthwave-bg-color-dark); 130 | } 131 | 132 | .rmm__theme--synthwave[class*='css-'] .rmm__main-nav-item { 133 | padding-left: 1rem; 134 | } 135 | 136 | .rmm__theme--synthwave[class*='css-'] .rmm__nav-item--heading { 137 | font-weight: 700; 138 | color: var(--synthwave-text-color-light); 139 | } 140 | 141 | @media (min-width: 64rem) { 142 | .rmm__theme--synthwave[class*='css-'] #rmm__topbar { 143 | border-top: none; 144 | border-right: none; 145 | background-image: linear-gradient( 146 | 315deg, 147 | var(--synthwave-gradient-topbar-large-start) 0%, 148 | var(--synthwave-gradient-topbar-large-end) 100% 149 | ); 150 | } 151 | 152 | .rmm__theme--synthwave[class*='css-'] #rmm__nav { 153 | top: 4rem; 154 | height: auto; 155 | border-top: 0.0625rem solid var(--synthwave-neon-pink); 156 | } 157 | 158 | .rmm__theme--synthwave[class*='css-'] .rmm__nav { 159 | border-top: none; 160 | border-bottom: 0.0625rem solid var(--synthwave-neon-cyan); 161 | } 162 | 163 | .rmm__theme--synthwave[class*='css-'] .rmm__nav-list, 164 | .rmm__theme--synthwave[class*='css-'] .rmm__mega-list { 165 | margin-top: 0; 166 | } 167 | 168 | .rmm__theme--synthwave[class*='css-'] .rmm__mega-list { 169 | border-top: none; 170 | border-right: 0.25rem solid var(--synthwave-neon-pink); 171 | border-bottom: 0.25rem solid var(--synthwave-neon-cyan); 172 | margin-left: 0; 173 | } 174 | 175 | .rmm__theme--synthwave[class*='css-'] .rmm__nav-list .rmm__nav-list { 176 | padding-bottom: 1rem; 177 | } 178 | 179 | .rmm__theme--synthwave[class*='css-'] .rmm__nav-list--sub { 180 | display: flex; 181 | flex-direction: column; 182 | } 183 | 184 | .rmm__theme--synthwave[class*='css-'] .rmm__nav-list--sub .rmm__nav-item { 185 | padding-bottom: 1rem; 186 | } 187 | 188 | .rmm__theme--synthwave[class*='css-'] .rmm__nav-item--heading { 189 | display: none; 190 | } 191 | 192 | .rmm__theme--synthwave[class*='css-'] .rmm__main-nav-item-link--icon { 193 | content: url(/images/icons/synthwave/angle-up.svg); 194 | position: relative; 195 | left: 0.5rem; 196 | bottom: initial; 197 | width: 1rem; 198 | } 199 | 200 | .rmm__theme--synthwave[class*='css-'] .rmm__main-nav-item-link--icon.active { 201 | transform: rotate(180deg); 202 | top: 0px; 203 | } 204 | 205 | .rmm__theme--synthwave[class*='css-'] .rmm__nav-item-link--forward:after, 206 | .rmm__theme--synthwave[class*='css-'] .rmm__nav-item-link--back:before { 207 | content: none; 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /demo/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /demo/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "moduleResolution": "bundler", 9 | "allowImportingTsExtensions": true, 10 | "isolatedModules": true, 11 | "moduleDetection": "force", 12 | "noEmit": true, 13 | "jsx": "react-jsx", 14 | "sourceMap": true, 15 | "inlineSources": true, 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true, 20 | "declaration": true, 21 | "declarationMap": true 22 | }, 23 | "include": ["src"] 24 | } 25 | -------------------------------------------------------------------------------- /demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /demo/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2023"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | 8 | /* Bundler mode */ 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "isolatedModules": true, 12 | "moduleDetection": "force", 13 | "noEmit": true, 14 | 15 | /* Linting */ 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true 20 | }, 21 | "include": ["vite.config.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /demo/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /jasonrundell-react-mega-menu-2.2.2.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasonrundell/react-mega-menu/745d52d94a1e891593b8d6567748754765ea135c/jasonrundell-react-mega-menu-2.2.2.tgz -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverage: true, 3 | collectCoverageFrom: [ 4 | 'src/**/*.{js,jsx,ts,tsx}', 5 | '!src/demo/**', 6 | '!src/next-demo/**', 7 | '!.github/**', 8 | '!**/node_modules/**' 9 | ], 10 | coverageDirectory: 'coverage', 11 | coverageReporters: ['json', 'lcov', 'text', 'clover'], 12 | testEnvironment: 'jest-environment-jsdom', 13 | transform: { 14 | '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest' 15 | }, 16 | moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx'], 17 | setupFilesAfterEnv: ['/jest.setup.js'] 18 | } 19 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | // jest.setup.js 2 | import '@testing-library/jest-dom' 3 | -------------------------------------------------------------------------------- /next-demo/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "rules": { 4 | "space-before-function-paren": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /next-demo/.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 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /next-demo/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.js`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /next-demo/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@/*": ["./src/*"] 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /next-demo/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /next-demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-demo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@jasonrundell/react-mega-menu": "file:../jasonrundell-react-mega-menu-2.2.2.tgz", 13 | "next": "14.2.15", 14 | "react": "^18", 15 | "react-dom": "^18" 16 | }, 17 | "devDependencies": { 18 | "eslint": "^8", 19 | "eslint-config-next": "14.2.11", 20 | "postcss": "^8", 21 | "tailwindcss": "^3.4.1" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /next-demo/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /next-demo/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasonrundell/react-mega-menu/745d52d94a1e891593b8d6567748754765ea135c/next-demo/src/app/favicon.ico -------------------------------------------------------------------------------- /next-demo/src/app/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasonrundell/react-mega-menu/745d52d94a1e891593b8d6567748754765ea135c/next-demo/src/app/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /next-demo/src/app/fonts/GeistVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasonrundell/react-mega-menu/745d52d94a1e891593b8d6567748754765ea135c/next-demo/src/app/fonts/GeistVF.woff -------------------------------------------------------------------------------- /next-demo/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --background: #ffffff; 7 | --foreground: #171717; 8 | } 9 | 10 | @media (prefers-color-scheme: dark) { 11 | :root { 12 | --background: #0a0a0a; 13 | --foreground: #ededed; 14 | } 15 | } 16 | 17 | body { 18 | color: var(--foreground); 19 | background: var(--background); 20 | font-family: Arial, Helvetica, sans-serif; 21 | } 22 | 23 | @layer utilities { 24 | .text-balance { 25 | text-wrap: balance; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /next-demo/src/app/layout.js: -------------------------------------------------------------------------------- 1 | import localFont from 'next/font/local' 2 | import './globals.css' 3 | 4 | const geistSans = localFont({ 5 | src: './fonts/GeistVF.woff', 6 | variable: '--font-geist-sans', 7 | weight: '100 900' 8 | }) 9 | const geistMono = localFont({ 10 | src: './fonts/GeistMonoVF.woff', 11 | variable: '--font-geist-mono', 12 | weight: '100 900' 13 | }) 14 | 15 | export const metadata = { 16 | title: 'Create Next App', 17 | description: 'Generated by create next app' 18 | } 19 | 20 | export default function RootLayout({ children }) { 21 | return ( 22 | 23 | 26 | {children} 27 | 28 | 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /next-demo/src/app/page.js: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Image from 'next/image' 4 | import { Menu } from '@jasonrundell/react-mega-menu' 5 | 6 | /** 7 | * Here's a static configuration example of a menu configuration object. 8 | * If menuConfig doesn't depend on any state or props of App, hoisting it can help improve performance 9 | * and code clarity. Otherwise move it to App's state. 10 | */ 11 | const menuConfig = { 12 | topbar: { 13 | id: 'topbar', 14 | logo: { 15 | src: 'https://via.placeholder.com/150x50', 16 | alt: 'Placeholder Logo', 17 | rel: 'home' 18 | }, 19 | title: 'React Mega Menu' 20 | }, 21 | menu: { 22 | items: [ 23 | { 24 | id: 'home', 25 | label: 'Home', 26 | type: 'main', 27 | url: '/' 28 | }, 29 | { 30 | id: 'about', 31 | label: 'About', 32 | type: 'main', 33 | url: '/about/' 34 | }, 35 | { 36 | id: 'store', 37 | label: 'Store', 38 | type: 'mega', 39 | url: '/store/', 40 | items: [ 41 | { 42 | id: 'store-deals', 43 | label: 'Deals', 44 | type: 'link', 45 | url: '/store/deals/', 46 | description: 47 | "Three lined small description that accompanies link in the React Mega Menu project. This maybe too much text? Who's to say, really. We'll leave it to fate to decide." 48 | }, 49 | { 50 | id: 'store-kitchen', 51 | label: 'Kitchen', 52 | type: 'link', 53 | url: '/store/kitchen/', 54 | description: 55 | "Three lined small description that accompanies link in the React Mega Menu project. This maybe too much text? Who's to say, really. We'll leave it to fate to decide." 56 | }, 57 | { 58 | id: 'store-outdoors', 59 | label: 'Outdoors', 60 | type: 'sub', 61 | url: '/store/outdoors/', 62 | description: 63 | "Three lined small description that accompanies link in the React Mega Menu project. This maybe too much text? Who's to say, really. We'll leave it to fate to decide.", 64 | items: [ 65 | { 66 | id: 'store-outdoors-tools', 67 | label: 'Tools', 68 | type: 'link', 69 | url: '/store/outdoors/tools/', 70 | description: 'Single line description that accompanies link' 71 | }, 72 | { 73 | id: 'store-outdoors-plants', 74 | label: 'Plants', 75 | type: 'link', 76 | url: '/store/outdoors/plants/', 77 | description: 'Single line description that accompanies link' 78 | }, 79 | { 80 | id: 'store-outdoors-patio', 81 | label: 'Patio', 82 | type: 'link', 83 | url: '/store/outdoors/patio/', 84 | description: 'Single line description that accompanies link' 85 | }, 86 | { 87 | id: 'store-outdoors-decking', 88 | label: 'Decking', 89 | type: 'link', 90 | url: '/store/outdoors/decking/', 91 | description: 'Single line description that accompanies link' 92 | } 93 | ] 94 | }, 95 | { 96 | id: 'store-bedroom', 97 | label: 'Bedroom', 98 | type: 'sub', 99 | url: '/store/bedroom/', 100 | description: 101 | "Three lined small description that accompanies link in the React Mega Menu project. This maybe too much text? Who's to say, really. We'll leave it to fate to decide.", 102 | items: [ 103 | { 104 | id: 'store-bedroom-beds', 105 | label: 'Beds', 106 | type: 'link', 107 | url: '/store/bedroom/beds/', 108 | description: 'Single line description that accompanies link' 109 | }, 110 | { 111 | id: 'store-bedroom-dressers', 112 | label: 'Dressers', 113 | type: 'link', 114 | url: '/store/bedroom/dressers/', 115 | description: 116 | 'Double lined small description that accompanies link in the React Mega Menu project' 117 | }, 118 | { 119 | id: 'store-bedroom-nightstands', 120 | label: 'Nightstands', 121 | type: 'link', 122 | url: '/store/bedroom/nightstands/', 123 | description: 124 | 'Double lined small description that accompanies link in the React Mega Menu project' 125 | }, 126 | { 127 | id: 'store-bedroom-benches', 128 | label: 'Benches', 129 | type: 'link', 130 | url: '/store/bedroom/benches/', 131 | description: 132 | 'Double lined small description that accompanies link in the React Mega Menu project' 133 | } 134 | ] 135 | } 136 | ] 137 | }, 138 | { 139 | id: 'blog', 140 | label: 'Blog', 141 | type: 'mega', 142 | url: '/blog/', 143 | items: [ 144 | { 145 | id: 'blog-latest-post-title', 146 | label: 'Latest Post Title', 147 | type: 'link', 148 | url: '/blog/posts/latest-post-title/', 149 | description: 150 | 'Double lined small description that accompanies link in the React Mega Menu project' 151 | }, 152 | { 153 | id: 'blog-categories', 154 | label: 'Categories', 155 | type: 'sub', 156 | url: '/blog/categories/', 157 | items: [ 158 | { 159 | id: 'blog-news', 160 | label: 'News', 161 | type: 'link', 162 | url: '/blog/news/' 163 | }, 164 | { 165 | id: 'blog-recipes', 166 | label: 'Recipes', 167 | type: 'link', 168 | url: '/blog/recipes/' 169 | }, 170 | { 171 | id: 'blog-health', 172 | label: 'Health', 173 | type: 'link', 174 | url: '/blog/health/' 175 | }, 176 | { 177 | id: 'blog-diet', 178 | label: 'Diet', 179 | type: 'link', 180 | url: '/blog/diet/' 181 | } 182 | ] 183 | } 184 | ] 185 | }, 186 | { 187 | id: 'help', 188 | label: 'Help', 189 | type: 'mega', 190 | url: '/help/', 191 | items: [ 192 | { 193 | id: 'help-react-mega-menu', 194 | label: 'React Mega Menu', 195 | type: 'link', 196 | url: 'https://github.com/jasonrundell/react-mega-menu', 197 | description: 198 | 'A React project which aims to be an accessible, responsive, boilerplate top navigation menu with a "Mega Menu"!' 199 | }, 200 | { 201 | id: 'help-faq', 202 | label: 'FAQ', 203 | type: 'link', 204 | url: '/help/faq/', 205 | description: 'Single line description that accompanies link' 206 | }, 207 | { 208 | id: 'help-knowledge-base', 209 | label: 'Knowledge Base', 210 | type: 'link', 211 | url: '/help/knowledge-base/', 212 | description: 213 | 'Double lined small description that accompanies link in the React Mega Menu project' 214 | } 215 | ] 216 | }, 217 | { 218 | id: 'settings', 219 | label: 'Settings', 220 | type: 'mega', 221 | url: '/settings/', 222 | items: [ 223 | { 224 | id: 'settings-profile', 225 | label: 'Profile', 226 | type: 'link', 227 | url: '/settings/profile/', 228 | description: 'Single line description that accompanies link' 229 | }, 230 | { 231 | id: 'settings-billing', 232 | label: 'Billing', 233 | type: 'link', 234 | url: '/settings/billing/', 235 | description: 'Single line description that accompanies link' 236 | }, 237 | { 238 | id: 'settings-theme', 239 | label: 'Theme', 240 | type: 'sub', 241 | url: '#', 242 | description: 'Change the React Mega Menu theme', 243 | items: [ 244 | { 245 | id: 'settings-theme-light', 246 | label: 'Light', 247 | type: 'link', 248 | url: '/?theme=light' 249 | }, 250 | { 251 | id: 'settings-theme-dark', 252 | label: 'Dark', 253 | type: 'link', 254 | url: '/?theme=dark' 255 | }, 256 | { 257 | id: 'settings-theme-monokai', 258 | label: 'Monokai', 259 | type: 'link', 260 | url: '/?theme=monokai' 261 | }, 262 | { 263 | id: 'settings-theme-retro', 264 | label: 'Retro', 265 | type: 'link', 266 | url: '/?theme=retro' 267 | }, 268 | { 269 | id: 'settings-theme-synthwave', 270 | label: 'Synthwave', 271 | type: 'link', 272 | url: '/?theme=synthwave' 273 | } 274 | ] 275 | }, 276 | { 277 | id: 'settings-logout', 278 | label: 'Logout', 279 | type: 'link', 280 | url: '/settings/logout/', 281 | description: 'Single line description that accompanies link' 282 | } 283 | ] 284 | }, 285 | { 286 | id: 'contact', 287 | label: 'Contact', 288 | type: 'main', 289 | url: '#contact' 290 | } 291 | ] 292 | } 293 | } 294 | 295 | export default function Home() { 296 | return ( 297 |
298 | 299 |
300 | Next.js logo 308 |
    309 |
  1. 310 | Get started by editing{' '} 311 | 312 | src/app/page.js 313 | 314 | . 315 |
  2. 316 |
  3. Save and see your changes instantly.
  4. 317 |
318 | 319 | 344 |
345 | 392 |
393 | ) 394 | } 395 | -------------------------------------------------------------------------------- /next-demo/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 5 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 7 | ], 8 | theme: { 9 | extend: { 10 | colors: { 11 | background: "var(--background)", 12 | foreground: "var(--foreground)", 13 | }, 14 | }, 15 | }, 16 | plugins: [], 17 | }; 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jasonrundell/react-mega-menu", 3 | "version": "2.2.2", 4 | "author": "jasonrundell ", 5 | "main": "dist/index.es.js", 6 | "module": "dist/index.es.js", 7 | "types": "dist/index.d.ts", 8 | "type": "module", 9 | "files": [ 10 | "dist" 11 | ], 12 | "exports": { 13 | ".": { 14 | "import": "./dist/index.es.js", 15 | "require": "./dist/index.cjs.js", 16 | "types": "./dist/index.d.ts" 17 | } 18 | }, 19 | "scripts": { 20 | "build": "vite build", 21 | "format": "prettier --write src/**/*.{js,jsx}", 22 | "test": "jest --coverage" 23 | }, 24 | "dependencies": { 25 | "uuid": "^9.0.1" 26 | }, 27 | "devDependencies": { 28 | "@babel/preset-env": "^7.25.4", 29 | "@babel/preset-react": "^7.24.7", 30 | "@testing-library/jest-dom": "^6.5.0", 31 | "@testing-library/react": "^16.0.1", 32 | "@vitejs/plugin-react": "^4.3.1", 33 | "babel-jest": "^29.7.0", 34 | "babel-plugin-istanbul": "^7.0.0", 35 | "eslint": "^8.23.0", 36 | "eslint-config-standard": "^17.0.0", 37 | "eslint-plugin-import": "^2.26.0", 38 | "eslint-plugin-n": "^15.2.5", 39 | "eslint-plugin-promise": "^6.0.1", 40 | "eslint-plugin-react": "^7.31.1", 41 | "jest": "^29.7.0", 42 | "jest-environment-jsdom": "^29.7.0", 43 | "prettier": "^2.7.1", 44 | "prop-types": "^15.8.1", 45 | "typescript": "^5.6.2", 46 | "vite": "^5.4.6" 47 | }, 48 | "peerDependencies": { 49 | "@emotion/react": "^11.11.1", 50 | "@emotion/styled": "^11.11.0", 51 | "react": "^18.0.0", 52 | "react-dom": "^18.0.0" 53 | }, 54 | "repository": { 55 | "type": "git", 56 | "url": "git+ssh://git@github.com/jasonrundell/react-mega-menu.git" 57 | }, 58 | "browserslist": "> 0.5%, last 2 versions, not dead", 59 | "husky": { 60 | "hooks": { 61 | "pre-commit": "npm run format" 62 | } 63 | }, 64 | "license": "MIT", 65 | "description": "A React project which aims to be an accessible, responsive, boilerplate top navigation menu with a \"Mega Menu\"!", 66 | "bugs": { 67 | "url": "https://github.com/jasonrundell/react-mega-menu/issues" 68 | }, 69 | "homepage": "https://github.com/jasonrundell/react-mega-menu#readme", 70 | "keywords": [ 71 | "react", 72 | "navigation", 73 | "mega", 74 | "menu", 75 | "mega menu" 76 | ] 77 | } 78 | -------------------------------------------------------------------------------- /src/Menu.jsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect } from 'react' 2 | import PropTypes from 'prop-types' 3 | import styled from '@emotion/styled' 4 | 5 | // Context 6 | import { useMenu } from './context/MenuContext' // Adjust the path as necessary 7 | 8 | // Helpers 9 | import { click as a11yClick, escape as a11yEscape } from './helpers/a11y' 10 | import { respondTo, viewportLarge } from './helpers/responsive' 11 | import { 12 | config, 13 | renderMainMenuItem, 14 | renderLinkMenuItem, 15 | renderMegaMenuItem, 16 | renderSubMenuItem 17 | } from './helpers/menu' 18 | import { MENU_ITEM_TYPE_MEGA } from './config/menuItemTypes' 19 | 20 | // Components 21 | import TopBar from './components/TopBar' 22 | import Logo from './components/Logo' 23 | import TopBarTitle from './components/TopBarTitle' 24 | import Hamburger from './components/Hamburger' 25 | import Nav from './components/Nav' 26 | import MainList from './components/MainList' 27 | 28 | const StyledMenu = styled.div` 29 | position: fixed; 30 | top: 0; 31 | left: 0; 32 | height: 8rem; 33 | display: flex; 34 | justify-content: flex-start; 35 | align-content: center; 36 | flex-direction: row; 37 | width: 100%; 38 | z-index: 9000; 39 | 40 | ${respondTo('large')} { 41 | height: 4rem; 42 | } 43 | ` 44 | const defaultMenuConfig = config 45 | 46 | export const Menu = ({ config = defaultMenuConfig, ...props }) => { 47 | const { resetMenus, megaMenuState, toggleMegaMenu, setIsMobile } = useMenu() 48 | 49 | const wrapperRef = useRef(null) // used to detect clicks outside of component 50 | 51 | const useOutsideAlerter = (ref) => { 52 | useEffect(() => { 53 | const handleClickOutside = (e) => { 54 | if (ref.current && !ref.current.contains(e.target)) { 55 | resetMenus() 56 | } 57 | } 58 | 59 | document.addEventListener('mousedown', handleClickOutside) 60 | document.addEventListener('keydown', handleClickOutside) 61 | return () => { 62 | document.removeEventListener('mousedown', handleClickOutside) 63 | document.removeEventListener('keydown', handleClickOutside) 64 | } 65 | }, [ref, resetMenus]) 66 | } 67 | 68 | useEffect(() => { 69 | const handleEscape = (e) => a11yEscape(e, resetMenus) 70 | window.addEventListener('keydown', handleEscape) 71 | return () => { 72 | window.removeEventListener('keydown', handleEscape) 73 | } 74 | }, [resetMenus]) 75 | 76 | useEffect(() => { 77 | const updateIsMobile = () => { 78 | if (window.innerWidth >= viewportLarge) { 79 | setIsMobile(false) 80 | } else { 81 | setIsMobile(true) 82 | } 83 | } 84 | 85 | updateIsMobile() 86 | window.addEventListener('resize', updateIsMobile) 87 | 88 | return () => { 89 | window.removeEventListener('resize', updateIsMobile) 90 | } 91 | }, []) 92 | 93 | useOutsideAlerter(wrapperRef) // create bindings for closing menu from outside events 94 | 95 | return ( 96 | 103 | 104 | 110 | {config.topbar.title} 111 | 112 | toggleMegaMenu(e)} 116 | id="rmm__hamburger" 117 | /> 118 | 144 | 145 | ) 146 | } 147 | 148 | Menu.propTypes = { 149 | config: PropTypes.shape({ 150 | topbar: PropTypes.shape({ 151 | id: PropTypes.string.isRequired, 152 | logo: PropTypes.shape({ 153 | src: PropTypes.string.isRequired, 154 | alt: PropTypes.string, 155 | rel: PropTypes.string 156 | }), 157 | title: PropTypes.string.isRequired 158 | }), 159 | menu: PropTypes.shape({ 160 | items: PropTypes.arrayOf( 161 | PropTypes.shape({ 162 | id: PropTypes.string.isRequired, 163 | label: PropTypes.string.isRequired, 164 | type: PropTypes.string.isRequired, 165 | url: PropTypes.string.isRequired, 166 | description: PropTypes.string 167 | }) 168 | ) 169 | }) 170 | }), 171 | className: PropTypes.string, 172 | id: PropTypes.string 173 | } 174 | 175 | export default Menu 176 | -------------------------------------------------------------------------------- /src/Menu.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render, fireEvent } from '@testing-library/react' 3 | import '@testing-library/jest-dom' 4 | import { Menu } from './Menu' 5 | import { useMenu } from './context/MenuContext' 6 | import { config } from './helpers/menu' 7 | 8 | // Mock the useMenu hook 9 | jest.mock('./context/MenuContext', () => ({ 10 | useMenu: jest.fn() 11 | })) 12 | 13 | describe('Menu component', () => { 14 | const resetMenusMock = jest.fn() 15 | const toggleMegaMenuMock = jest.fn() 16 | const setIsMobileMock = jest.fn() 17 | 18 | beforeEach(() => { 19 | useMenu.mockReturnValue({ 20 | resetMenus: resetMenusMock, 21 | megaMenuState: false, 22 | toggleMegaMenu: toggleMegaMenuMock, 23 | setIsMobile: setIsMobileMock, 24 | activeMenus: [] 25 | }) 26 | }) 27 | 28 | afterEach(() => { 29 | jest.clearAllMocks() 30 | }) 31 | 32 | const defaultConfig = config 33 | 34 | test('renders different components of Menu', () => { 35 | const { container, getByRole } = render() 36 | 37 | // Check if the Menu component is rendered 38 | const menuComponent = container.querySelector('#rmm__menu') 39 | expect(menuComponent).toBeInTheDocument() 40 | 41 | // Check if the TopBar component is rendered 42 | const topBarComponent = container.querySelector('#rmm__topbar') 43 | expect(topBarComponent).toBeInTheDocument() 44 | expect(topBarComponent).toHaveTextContent('React Mega Menu') 45 | expect(getByRole('img', { name: 'Placeholder Logo' })).toBeInTheDocument() 46 | 47 | // Check if the TopBarTitle component is rendered 48 | const topBarTitleComponent = container.querySelector('#rmm__title') 49 | expect(topBarTitleComponent).toBeInTheDocument() 50 | 51 | // Check if the Hamburger component is rendered 52 | const hamburgerComponent = container.querySelector('#rmm__hamburger') 53 | expect(hamburgerComponent).toBeInTheDocument() 54 | 55 | // Check if the MainList component is rendered 56 | const mainListComponent = container.querySelector('#rmm__main') 57 | expect(mainListComponent).toBeInTheDocument() 58 | 59 | // Check if the Nav component is rendered 60 | const navComponent = container.querySelector('#rmm__nav') 61 | expect(navComponent).toBeInTheDocument() 62 | 63 | // Check if the export const renderSubMenuItem component is rendered 64 | const renderSubMenuItemComponent = container.querySelector( 65 | '#rmm-nav-item-store-outdoors' 66 | ) 67 | expect(renderSubMenuItemComponent).toBeInTheDocument() 68 | }) 69 | 70 | test('handles outside clicks to reset menus', () => { 71 | render() 72 | fireEvent.mouseDown(document) 73 | expect(resetMenusMock).toHaveBeenCalled() 74 | }) 75 | 76 | test('handles escape key to reset menus', () => { 77 | render() 78 | fireEvent.keyDown(window, { key: 'Escape', code: 'Escape', keyCode: 27 }) 79 | expect(resetMenusMock).toHaveBeenCalled() 80 | }) 81 | 82 | test('updates mobile state based on window resize', () => { 83 | render() 84 | global.innerWidth = 500 85 | fireEvent.resize(window) 86 | expect(setIsMobileMock).toHaveBeenCalledWith(true) 87 | 88 | global.innerWidth = 1200 89 | fireEvent.resize(window) 90 | expect(setIsMobileMock).toHaveBeenCalledWith(false) 91 | }) 92 | 93 | test('onClick event fires for Hamburger', () => { 94 | const { container } = render() 95 | const hamburger = container.querySelector('#rmm__hamburger') 96 | fireEvent.click(hamburger) 97 | expect(toggleMegaMenuMock).toHaveBeenCalled() 98 | }) 99 | }) 100 | -------------------------------------------------------------------------------- /src/components/Hamburger.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import styled from '@emotion/styled' 4 | import { respondTo } from '../helpers/responsive' 5 | 6 | const StyledHamburger = styled.button` 7 | transition: 0.5s ease-in-out; 8 | position: absolute; 9 | top: 5.25rem; 10 | left: 1rem; 11 | display: flex; 12 | flex-direction: row; 13 | align-items: center; 14 | 15 | ${({ state }) => 16 | state === 'closed' && 17 | ` 18 | display: flex; 19 | ${respondTo('large')} { 20 | display: none; 21 | } 22 | `} 23 | 24 | ${({ state }) => 25 | state === '' || 26 | (state === 'open' && 27 | ` 28 | span:nth-of-type(1) { 29 | top: 1rem; 30 | width: 0%; 31 | left: 50%; 32 | } 33 | span:nth-of-type(2) { 34 | transform: rotate(45deg); 35 | } 36 | span:nth-of-type(3) { 37 | transform: rotate(-45deg); 38 | } 39 | span:nth-of-type(4) { 40 | top: 1rem; 41 | width: 0%; 42 | left: 50%; 43 | } 44 | `)} 45 | 46 | ${respondTo('large')} { 47 | display: none; 48 | } 49 | ` 50 | 51 | const StyledHamburgerSliceContainer = styled.div` 52 | width: 2rem; 53 | position: relative; 54 | display: block; 55 | height: 1.25rem; 56 | ` 57 | 58 | const StyledHamburgerSlice = styled.span` 59 | display: block; 60 | position: absolute; 61 | height: 0.25rem; 62 | width: 100%; 63 | opacity: 1; 64 | left: 0; 65 | transform: rotate(0deg); 66 | transition: 0.25s ease-in-out; 67 | 68 | &:nth-of-type(1) { 69 | top: 0; 70 | } 71 | 72 | &:nth-of-type(2), 73 | &:nth-of-type(3) { 74 | top: 0.5rem; 75 | } 76 | 77 | &:nth-of-type(4) { 78 | top: 1rem; 79 | } 80 | ` 81 | 82 | const StyledHamburgerLabel = styled.span` 83 | font-size: 1rem; 84 | font-weight: 700; 85 | ` 86 | 87 | const Hamburger = ({ label = null, state = 'closed', onClick, ...props }) => ( 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | {label && ( 96 |
97 | 98 | {label} 99 | 100 |
101 | )} 102 |
103 | ) 104 | 105 | Hamburger.propTypes = { 106 | /** 107 | * The text label to display next to the hamburger icon 108 | */ 109 | label: PropTypes.string, 110 | /** 111 | * The current state of the hamburger icon 112 | */ 113 | state: PropTypes.oneOf(['', 'open', 'closed']), 114 | /** 115 | * The function to call when the hamburger icon is clicked 116 | */ 117 | onClick: PropTypes.func 118 | } 119 | 120 | export default Hamburger 121 | -------------------------------------------------------------------------------- /src/components/Logo.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import styled from '@emotion/styled' 4 | import { respondTo } from '../helpers/responsive' 5 | 6 | const StyledLogo = styled.img` 7 | display: flex; 8 | position: relative; 9 | height: 2rem; 10 | margin-right: 1rem; 11 | 12 | ${respondTo('large')} { 13 | align-items: center; 14 | } 15 | ` 16 | 17 | const Logo = ({ id, src, rel = '', alt = '', ...props }) => ( 18 | 19 | ) 20 | 21 | Logo.propTypes = { 22 | /** 23 | * The id of the logo. 24 | */ 25 | id: PropTypes.string.isRequired, 26 | /** 27 | * The src of the logo. 28 | */ 29 | src: PropTypes.string.isRequired, 30 | /** 31 | * The rel of the logo. 32 | */ 33 | rel: PropTypes.string, 34 | /** 35 | * The alt of the logo. 36 | */ 37 | alt: PropTypes.string 38 | } 39 | 40 | export default Logo 41 | -------------------------------------------------------------------------------- /src/components/MainList.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import styled from '@emotion/styled' 4 | import { respondTo } from '../helpers/responsive' 5 | 6 | const StyledList = styled.ul` 7 | position: absolute; 8 | top: 0; 9 | left: 0; 10 | display: flex; 11 | flex-direction: column; 12 | justify-content: flex-start; 13 | align-content: center; 14 | margin: 0; 15 | padding: 0; 16 | width: 100%; 17 | height: calc(100vh - 4rem); 18 | z-index: 1; 19 | 20 | ${respondTo('large')} { 21 | flex-direction: row; 22 | height: 4rem; 23 | } 24 | ` 25 | 26 | const MainList = ({ id, ariaLabel = 'Main menu', children, ...props }) => ( 27 | 28 | {children} 29 | 30 | ) 31 | 32 | MainList.propTypes = { 33 | /** 34 | * The id of the main list. 35 | */ 36 | id: PropTypes.string.isRequired, 37 | /** 38 | * The aria-label of the main list. 39 | */ 40 | ariaLabel: PropTypes.string.isRequired, 41 | /** 42 | * The children of the main list. 43 | */ 44 | children: PropTypes.node.isRequired 45 | } 46 | 47 | export default MainList 48 | -------------------------------------------------------------------------------- /src/components/MainNavItem.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import styled from '@emotion/styled' 4 | import { respondTo } from '../helpers/responsive' 5 | 6 | const StyledMainNavItem = styled.li` 7 | list-style: none; 8 | margin-top: 0; 9 | margin-right: 0; 10 | margin-bottom: 1rem; 11 | margin-left: 0; 12 | 13 | ${respondTo('large')} { 14 | display: flex; 15 | flex-direction: column; 16 | height: auto; 17 | margin-top: 0; 18 | margin-right: 0; 19 | margin-bottom: 0; 20 | margin-left: 2rem; 21 | align-items: center; 22 | } 23 | ` 24 | 25 | const MainNavItem = ({ id, role = 'none', children, ...props }) => ( 26 | 27 | {children} 28 | 29 | ) 30 | 31 | MainNavItem.propTypes = { 32 | /** 33 | * The id attribute of the list item. 34 | */ 35 | id: PropTypes.string.isRequired, 36 | /** 37 | * The role attribute of the list item. 38 | */ 39 | role: PropTypes.string, 40 | /** 41 | * The content of the list item. 42 | */ 43 | children: PropTypes.node.isRequired 44 | } 45 | 46 | export default MainNavItem 47 | -------------------------------------------------------------------------------- /src/components/MainNavItemLink.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import styled from '@emotion/styled' 4 | 5 | import { respondTo } from '../helpers/responsive' 6 | import { 7 | MENU_ITEM_TYPE_LINK, 8 | MENU_ITEM_TYPE_MEGA, 9 | MENU_ITEM_TYPES 10 | } from '../config/menuItemTypes' 11 | 12 | const StyledMainNavItemLink = styled.a` 13 | width: 100%; 14 | display: flex; 15 | position: relative; 16 | 17 | ${respondTo('large')} { 18 | align-items: center; 19 | height: 4rem; 20 | } 21 | ` 22 | 23 | const MainNavItemLink = ({ 24 | id, 25 | role = 'menuItem', 26 | type = MENU_ITEM_TYPE_LINK, 27 | href, 28 | isActive = false, 29 | onClick, 30 | onKeyDown, 31 | ariaHaspopup, 32 | ariaControls, 33 | children, 34 | ...props 35 | }) => ( 36 | 47 | {children} 48 | {type === MENU_ITEM_TYPE_MEGA && ( 49 | 52 | )} 53 | 54 | ) 55 | 56 | MainNavItemLink.propTypes = { 57 | id: PropTypes.string.isRequired, 58 | role: PropTypes.string, 59 | type: PropTypes.oneOf(MENU_ITEM_TYPES), 60 | href: PropTypes.string.isRequired, 61 | isActive: PropTypes.bool, 62 | onClick: PropTypes.func, 63 | onKeyDown: PropTypes.func, 64 | ariaHaspopup: PropTypes.string, 65 | ariaControls: PropTypes.string, 66 | children: PropTypes.node.isRequired 67 | } 68 | 69 | export default MainNavItemLink 70 | -------------------------------------------------------------------------------- /src/components/MegaList.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import styled from '@emotion/styled' 4 | import { respondTo } from '../helpers/responsive' 5 | 6 | const StyledMegaList = styled.ul` 7 | position: absolute; 8 | z-index: 2; 9 | top: 0; 10 | left: -100%; 11 | display: flex; 12 | flex-direction: column; 13 | justify-content: flex-start; 14 | align-content: center; 15 | width: 100%; 16 | height: calc(100vh - 4rem); 17 | margin: 0; 18 | padding: 0; 19 | 20 | ${respondTo('large')} { 21 | position: absolute; 22 | left: 0; 23 | top: 4rem; 24 | display: flex; 25 | flex-direction: row; 26 | flex-direction: row; 27 | width: 100%; 28 | height: auto; 29 | opacity: 0; 30 | } 31 | 32 | ${({ activeState }) => 33 | activeState === 'open' && 34 | ` 35 | animation-duration: 0.75s; 36 | animation-fill-mode: both; 37 | animation-name: slideOpen; 38 | animation-iteration-count: 1; 39 | @media (prefers-reduced-motion: reduce) { 40 | animation: none; 41 | } 42 | 43 | @keyframes slideOpen { 44 | from { 45 | transform: translate3d(-100%, 0, 0); 46 | } 47 | 48 | to { 49 | transform: translate3d(100%, 0, 0); 50 | } 51 | } 52 | 53 | @media (prefers-reduced-motion: reduce) { 54 | transform: translate3d(100%, 0, 0); 55 | } 56 | 57 | ${respondTo('large')} { 58 | animation: none; 59 | display: flex; 60 | opacity: 1; 61 | @media (prefers-reduced-motion: reduce) { 62 | transform: none; 63 | } 64 | } 65 | `} 66 | 67 | ${({ activeState }) => 68 | activeState === 'closed' && 69 | ` 70 | animation-duration: 0.75s; 71 | animation-fill-mode: both; 72 | animation-name: slideClosed; 73 | animation-iteration-count: 1; 74 | @media (prefers-reduced-motion: reduce) { 75 | animation: none; 76 | } 77 | 78 | @keyframes slideClosed { 79 | from { 80 | transform: translate3d(100%, 0, 0); 81 | } 82 | 83 | to { 84 | transform: translate3d(-100%, 0, 0); 85 | } 86 | } 87 | 88 | @media (prefers-reduced-motion: reduce) { 89 | transform: translate3d(-100%, 0, 0); 90 | } 91 | 92 | ${respondTo('large')} { 93 | animation: none; 94 | display: none; 95 | opacity: 0; 96 | @media (prefers-reduced-motion: reduce) { 97 | transform: none; 98 | } 99 | } 100 | `} 101 | ` 102 | 103 | const MegaList = ({ id, activeState = 'closed', children, ...props }) => ( 104 | 111 | {children} 112 | 113 | ) 114 | 115 | MegaList.propTypes = { 116 | /** 117 | * The id of the element that labels the mega list. 118 | */ 119 | id: PropTypes.string.isRequired, 120 | /** 121 | * The state of the mega list. 122 | */ 123 | activeState: PropTypes.oneOf(['open', 'closed']).isRequired, 124 | /** 125 | * The content of the mega list. 126 | */ 127 | children: PropTypes.node.isRequired 128 | } 129 | 130 | export default MegaList 131 | -------------------------------------------------------------------------------- /src/components/Nav.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import styled from '@emotion/styled' 4 | import { respondTo } from '../helpers/responsive' 5 | import { getAnimationStyles } from '../helpers/animationStyles' 6 | 7 | const StyledNav = styled.nav` 8 | position: absolute; 9 | top: 8rem; 10 | left: -100%; 11 | width: 100%; 12 | height: calc(100vh - 4rem); 13 | display: flex; 14 | flex-direction: column; 15 | margin: 0; 16 | padding: 0; 17 | overflow-y: scroll; 18 | 19 | ${respondTo('large')} { 20 | top: 4rem; 21 | left: 0; 22 | height: 4rem; 23 | flex-direction: row; 24 | overflow-y: initial; 25 | } 26 | 27 | ${({ activeState }) => getAnimationStyles(activeState)} 28 | 29 | li:first-of-type { 30 | ${respondTo('large')} { 31 | margin-left: 0; 32 | } 33 | } 34 | ` 35 | 36 | const Nav = ({ 37 | id, 38 | ariaLabel = 'Main Navigation', 39 | activeState = 'closed', 40 | children, 41 | ...props 42 | }) => ( 43 | 50 | {children} 51 | 52 | ) 53 | 54 | Nav.propTypes = { 55 | /** 56 | * The id of the element. 57 | */ 58 | id: PropTypes.string.isRequired, 59 | /** 60 | * The aria-label of the element. 61 | */ 62 | ariaLabel: PropTypes.string, 63 | /** 64 | * The state of the mega list. 65 | */ 66 | activeState: PropTypes.oneOf(['', 'open', 'closed']), 67 | /** 68 | * The content of the mega list. 69 | */ 70 | children: PropTypes.node.isRequired 71 | } 72 | 73 | export default Nav 74 | -------------------------------------------------------------------------------- /src/components/Nav.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from '@testing-library/react' 3 | import '@testing-library/jest-dom' 4 | import Nav from './Nav' 5 | 6 | describe('Nav Component', () => { 7 | test('renders correctly with default props', () => { 8 | const { getByLabelText } = render( 9 | 15 | ) 16 | const navElement = getByLabelText('Main Navigation') 17 | expect(navElement).toBeInTheDocument() 18 | expect(navElement).toHaveAttribute('id', 'main-nav') 19 | }) 20 | 21 | test('applies correct styles for open state', () => { 22 | const { getByLabelText } = render( 23 | 29 | ) 30 | const navElement = getByLabelText('Main Navigation') 31 | expect(navElement).toHaveStyle(` 32 | animation-duration: 0.75s; 33 | animation-fill-mode: both; 34 | animation-name: slideOpen; 35 | animation-iteration-count: 1; 36 | `) 37 | }) 38 | 39 | test('applies correct styles for closed state', () => { 40 | const { getByLabelText } = render( 41 | 47 | ) 48 | const navElement = getByLabelText('Main Navigation') 49 | expect(navElement).toHaveStyle(` 50 | animation-duration: 0.75s; 51 | animation-fill-mode: both; 52 | animation-name: slideClosed; 53 | animation-iteration-count: 1; 54 | `) 55 | }) 56 | 57 | test('renders children correctly', () => { 58 | const { getByText } = render( 59 | 65 | ) 66 | expect(getByText('Home')).toBeInTheDocument() 67 | expect(getByText('About')).toBeInTheDocument() 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /src/components/NavItem.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import styled from '@emotion/styled' 4 | import { respondTo } from '../helpers/responsive' 5 | 6 | const StyledNavItem = styled.li` 7 | list-style: none; 8 | margin-top: 0; 9 | margin-right: 0; 10 | margin-bottom: 1rem; 11 | margin-left: 0; 12 | 13 | ${respondTo('large')} { 14 | display: flex; 15 | flex-direction: column; 16 | align-items: flex-start; 17 | justify-content: flex-start; 18 | height: auto; 19 | margin-top: 0; 20 | margin-bottom: 0; 21 | } 22 | ` 23 | 24 | const NavItem = ({ id, role = 'none', children, ...props }) => ( 25 | 26 | {children} 27 | 28 | ) 29 | 30 | NavItem.propTypes = { 31 | id: PropTypes.string.isRequired, 32 | role: PropTypes.string, 33 | children: PropTypes.node.isRequired 34 | } 35 | 36 | export default NavItem 37 | -------------------------------------------------------------------------------- /src/components/NavItemDescription.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import styled from '@emotion/styled' 4 | 5 | const StyledItemDescription = styled.p` 6 | font-size: 0.75rem; 7 | margin-bottom: 1rem; 8 | ` 9 | 10 | const NavItemDescription = ({ children, ...props }) => ( 11 | {children} 12 | ) 13 | 14 | NavItemDescription.propTypes = { 15 | children: PropTypes.node.isRequired 16 | } 17 | 18 | export default NavItemDescription 19 | -------------------------------------------------------------------------------- /src/components/NavItemLink.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import styled from '@emotion/styled' 4 | import { respondTo } from '../helpers/responsive' 5 | 6 | const StylesNavItemLink = styled.a` 7 | width: 100%; 8 | display: flex; 9 | flex-direction: row; 10 | position: relative; 11 | margin-bottom: 1rem; 12 | 13 | ${({ isActive }) => 14 | isActive && 15 | ` 16 | &::after { 17 | ${respondTo('large')} { 18 | transform: rotate(180deg); 19 | top: 0; 20 | } 21 | } 22 | `} 23 | ` 24 | 25 | const NavItemLink = ({ 26 | id, 27 | role = 'menuitem', 28 | href, 29 | isActive = false, 30 | onClick, 31 | onKeyDown, 32 | ariaHaspopup, 33 | ariaControls, 34 | children, 35 | ...props 36 | }) => ( 37 | 48 | {children} 49 | 50 | ) 51 | 52 | NavItemLink.propTypes = { 53 | id: PropTypes.string.isRequired, 54 | role: PropTypes.string, 55 | href: PropTypes.string.isRequired, 56 | isActive: PropTypes.bool, 57 | onClick: PropTypes.func, 58 | onKeyDown: PropTypes.func, 59 | ariaHaspopup: PropTypes.string, 60 | ariaControls: PropTypes.string, 61 | children: PropTypes.node.isRequired 62 | } 63 | 64 | export default NavItemLink 65 | -------------------------------------------------------------------------------- /src/components/NavList.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import styled from '@emotion/styled' 4 | import { respondTo } from '../helpers/responsive' 5 | import { getAnimationStyles } from '../helpers/animationStyles' 6 | 7 | import { 8 | MENU_ITEM_TYPE_LINK, 9 | MENU_ITEM_TYPE_SUB, 10 | MENU_ITEM_TYPES 11 | } from '../config/menuItemTypes' 12 | 13 | const StyledNavList = styled.ul` 14 | z-index: 1; 15 | position: absolute; 16 | top: 0; 17 | left: 0; 18 | display: flex; 19 | flex-direction: column; 20 | justify-content: flex-start; 21 | align-content: center; 22 | margin: 0; 23 | padding: 0; 24 | width: 100%; 25 | height: calc(100vh - 4rem); 26 | 27 | ${respondTo('large')} { 28 | flex-direction: row; 29 | height: 4rem; 30 | } 31 | 32 | ${({ type }) => 33 | type === MENU_ITEM_TYPE_SUB && 34 | ` 35 | position: absolute; 36 | top: 0; 37 | left: -100%; 38 | width: 100%; 39 | height: calc(100vh - 4rem); 40 | display: flex; 41 | flex-direction: column; 42 | z-index: 2; 43 | 44 | ${respondTo('large')} { 45 | animation: none; 46 | display: block; 47 | position: static; 48 | top: 0; 49 | left: 0; 50 | opacity: 1; 51 | height: auto; 52 | } 53 | `} 54 | 55 | ${({ activeState }) => getAnimationStyles(activeState)} 56 | ` 57 | 58 | const NavList = ({ 59 | id, 60 | role = 'menubar', 61 | type = MENU_ITEM_TYPE_LINK, 62 | activeState = 'closed', 63 | ariaLabelledby, 64 | children, 65 | ...props 66 | }) => ( 67 | 75 | {children} 76 | 77 | ) 78 | 79 | NavList.propTypes = { 80 | id: PropTypes.string.isRequired, 81 | role: PropTypes.string, 82 | type: PropTypes.oneOf(MENU_ITEM_TYPES), 83 | activeState: PropTypes.oneOf(['open', 'closed']).isRequired, 84 | ariaLabelledby: PropTypes.string.isRequired, 85 | children: PropTypes.node.isRequired 86 | } 87 | 88 | export default NavList 89 | -------------------------------------------------------------------------------- /src/components/TopBar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import styled from '@emotion/styled' 4 | import { respondTo } from '../helpers/responsive' 5 | 6 | const StyledTopBar = styled.div` 7 | position: absolute; 8 | top: 0; 9 | left: 0; 10 | width: 100%; 11 | height: 4rem; 12 | display: flex; 13 | flex-direction: row; 14 | flex-wrap: nowrap; 15 | align-items: center; 16 | overflow: hidden; 17 | 18 | ${respondTo('large')} { 19 | left: 0; 20 | flex-direction: row; 21 | } 22 | ` 23 | 24 | const TopBar = ({ id = 'top', children, ...props }) => ( 25 | 26 | {children} 27 | 28 | ) 29 | TopBar.propTypes = { 30 | id: PropTypes.string, 31 | children: PropTypes.node.isRequired 32 | } 33 | 34 | export default TopBar 35 | -------------------------------------------------------------------------------- /src/components/TopBarTitle.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import styled from '@emotion/styled' 4 | 5 | const StyledTopBarTitle = styled.h1` 6 | font-size: 1.5rem; 7 | ` 8 | 9 | const TopBarTitle = ({ id, children, ...props }) => ( 10 | 11 | {children} 12 | 13 | ) 14 | 15 | TopBarTitle.propTypes = { 16 | id: PropTypes.string, 17 | children: PropTypes.node.isRequired 18 | } 19 | 20 | export default TopBarTitle 21 | -------------------------------------------------------------------------------- /src/config/breakpoints.js: -------------------------------------------------------------------------------- 1 | export const breakpoints = { 2 | small: { 3 | 'min-width': '23rem' 4 | }, 5 | medium: { 6 | 'min-width': '48rem' 7 | }, 8 | large: { 9 | 'min-width': '64rem' 10 | }, 11 | xlarge: { 12 | 'min-width': '75rem' 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/config/menuItemTypes.js: -------------------------------------------------------------------------------- 1 | export const MENU_ITEM_TYPE_MAIN = 'main' 2 | export const MENU_ITEM_TYPE_LINK = 'link' 3 | export const MENU_ITEM_TYPE_MEGA = 'mega' 4 | export const MENU_ITEM_TYPE_SUB = 'sub' 5 | export const MENU_ITEM_TYPES = [ 6 | MENU_ITEM_TYPE_MAIN, 7 | MENU_ITEM_TYPE_LINK, 8 | MENU_ITEM_TYPE_MEGA, 9 | MENU_ITEM_TYPE_SUB 10 | ] 11 | -------------------------------------------------------------------------------- /src/context/MenuContext.jsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useState, useContext, useEffect } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import { stateMachine } from '../helpers/menu' 5 | 6 | const MenuContext = createContext() 7 | 8 | export const MenuProvider = ({ children }) => { 9 | const [megaMenuState, setMegaMenuState] = useState('closed') 10 | const [subMenuState, setSubMenuState] = useState('closed') 11 | const [subSubMenuState, setSubSubMenuState] = useState('closed') 12 | const [activeMenus, setActiveMenus] = useState([]) // array that captures the ids of active menus 13 | const [isMobile, setIsMobile] = useState(true) // array that captures the ids of active menus 14 | 15 | // Debugger 16 | // useEffect(() => { 17 | // console.log('activeMenus', activeMenus) 18 | // console.log('megaMenuState', megaMenuState) 19 | // console.log('subMenuState', subMenuState) 20 | // console.log('subSubMenuState', subSubMenuState) 21 | // }, [activeMenus, subMenuState, subSubMenuState]) 22 | 23 | const resetMenus = () => { 24 | // close all menus and empty activeMenus array 25 | setActiveMenus([]) 26 | setMegaMenuState('closed') 27 | setSubMenuState('closed') 28 | setSubSubMenuState('closed') 29 | } 30 | 31 | const updateActiveMenus = (state, menuId) => { 32 | if (state === 'open') { 33 | // add menuId from activeMenus 34 | setActiveMenus([...activeMenus, menuId]) 35 | } else if (state === 'closed') { 36 | // remove menuId from activeMenus 37 | setActiveMenus(activeMenus.filter((item) => item !== menuId)) 38 | } 39 | } 40 | 41 | const toggleMegaMenu = (e) => { 42 | if (e && e.preventDefault) { 43 | e.preventDefault() 44 | } 45 | 46 | const nextState = stateMachine(megaMenuState) 47 | 48 | setMegaMenuState(nextState) 49 | 50 | updateActiveMenus(nextState, 'nav-main') 51 | 52 | if (megaMenuState === 'open') { 53 | resetMenus() 54 | } 55 | } 56 | 57 | const toggleSubMenu = (e, menuId) => { 58 | if (e && e.preventDefault) { 59 | e.preventDefault() 60 | } 61 | 62 | const nextState = stateMachine(subMenuState) 63 | 64 | setSubMenuState(stateMachine(subMenuState)) 65 | /* 66 | I haven't come up with single solution (yet) that takes care of 67 | opening and closing menus for both small and large screens, so for 68 | now I fork the logic based on viewport size. 69 | */ 70 | if (!isMobile) { 71 | if (activeMenus.includes(menuId)) { 72 | // menu is already open, remove it from activeMenus to close it 73 | setActiveMenus([]) 74 | } else { 75 | // menu is not yet active, add it to activeMenus to open it 76 | setActiveMenus([menuId]) 77 | } 78 | } else { 79 | // remove menuId from activeMenus 80 | updateActiveMenus(nextState, menuId) 81 | } 82 | } 83 | 84 | const toggleSubSubMenu = (e, menuId) => { 85 | if (e && e.preventDefault) { 86 | e.preventDefault() 87 | } 88 | 89 | const nextState = stateMachine(subSubMenuState) 90 | 91 | setSubSubMenuState(stateMachine(subSubMenuState)) 92 | 93 | updateActiveMenus(nextState, menuId) 94 | } 95 | 96 | useEffect(() => { 97 | const updateIsMobile = () => { 98 | if (window.innerWidth >= 1024) { 99 | setIsMobile(false) 100 | } else { 101 | setIsMobile(true) 102 | } 103 | } 104 | 105 | updateIsMobile() 106 | window.addEventListener('resize', updateIsMobile) 107 | 108 | return () => { 109 | window.removeEventListener('resize', updateIsMobile) 110 | } 111 | }, []) 112 | 113 | return ( 114 | 133 | {children} 134 | 135 | ) 136 | } 137 | 138 | export const useMenu = () => useContext(MenuContext) 139 | 140 | MenuProvider.propTypes = { 141 | children: PropTypes.node.isRequired 142 | } 143 | -------------------------------------------------------------------------------- /src/context/MenuContext.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { renderHook, act } from '@testing-library/react' 3 | import { MenuProvider, useMenu } from './MenuContext' 4 | 5 | describe('MenuContext', () => { 6 | let result 7 | 8 | beforeEach(() => { 9 | const wrapper = ({ children }) => {children} 10 | result = renderHook(() => useMenu(), { wrapper }).result 11 | }) 12 | 13 | test('resetMenus closes all menus and empties activeMenus array', () => { 14 | act(() => { 15 | result.current.setActiveMenus([ 16 | 'nav-main', 17 | 'rmm-mega-list-id-store', 18 | 'rmm-nav-list-id-store-outdoors' 19 | ]) 20 | result.current.setMegaMenuState('open') 21 | result.current.setSubMenuState('open') 22 | result.current.setSubSubMenuState('open') 23 | result.current.resetMenus() 24 | }) 25 | 26 | expect(result.current.activeMenus).toEqual([]) 27 | expect(result.current.megaMenuState).toBe('closed') 28 | expect(result.current.subMenuState).toBe('closed') 29 | expect(result.current.subSubMenuState).toBe('closed') 30 | }) 31 | 32 | test('updateActiveMenus adds and removes menu IDs from activeMenus array', () => { 33 | act(() => { 34 | result.current.updateActiveMenus('open', 'menu1') 35 | }) 36 | 37 | expect(result.current.activeMenus).toEqual(['menu1']) 38 | 39 | act(() => { 40 | result.current.updateActiveMenus('closed', 'menu1') 41 | }) 42 | 43 | expect(result.current.activeMenus).toEqual([]) 44 | }) 45 | 46 | test('toggleMegaMenu toggles mega menu state and updates activeMenus array', () => { 47 | act(() => { 48 | result.current.toggleMegaMenu({ preventDefault: jest.fn() }) 49 | }) 50 | 51 | expect(result.current.megaMenuState).toBe('open') 52 | expect(result.current.activeMenus).toEqual(['nav-main']) 53 | 54 | act(() => { 55 | result.current.toggleMegaMenu({ preventDefault: jest.fn() }) 56 | }) 57 | 58 | expect(result.current.megaMenuState).toBe('closed') 59 | expect(result.current.activeMenus).toEqual([]) 60 | }) 61 | 62 | test('toggleSubMenu toggles sub-menu state and updates activeMenus array based on viewport size', () => { 63 | global.innerWidth = 1200 // Simulate desktop viewport 64 | act(() => { 65 | result.current.toggleSubMenu({ preventDefault: jest.fn() }, 'menu1') 66 | }) 67 | 68 | expect(result.current.subMenuState).toBe('open') 69 | expect(result.current.activeMenus).toEqual(['menu1']) 70 | 71 | act(() => { 72 | result.current.toggleSubMenu({ preventDefault: jest.fn() }, 'menu1') 73 | }) 74 | 75 | expect(result.current.subMenuState).toBe('closed') 76 | expect(result.current.activeMenus).toEqual([]) 77 | 78 | global.innerWidth = 500 // Simulate mobile viewport 79 | act(() => { 80 | result.current.toggleSubMenu({ preventDefault: jest.fn() }, 'menu1') 81 | }) 82 | 83 | expect(result.current.subMenuState).toBe('open') 84 | expect(result.current.activeMenus).toEqual(['menu1']) 85 | }) 86 | 87 | test('toggleSubSubMenu toggles sub-sub-menu state', () => { 88 | act(() => { 89 | result.current.toggleSubSubMenu({ preventDefault: jest.fn() }, 'menu1') 90 | }) 91 | 92 | expect(result.current.subSubMenuState).toBe('open') 93 | 94 | act(() => { 95 | result.current.toggleSubSubMenu({ preventDefault: jest.fn() }, 'menu1') 96 | }) 97 | 98 | expect(result.current.subSubMenuState).toBe('closed') 99 | }) 100 | }) 101 | -------------------------------------------------------------------------------- /src/helpers/a11y.js: -------------------------------------------------------------------------------- 1 | export const click = (e) => { 2 | const code = e.charCode || e.keyCode 3 | if (code === 32 || code === 13) { 4 | return true 5 | } 6 | } 7 | 8 | export const escape = (e, resetMenus) => { 9 | if (e.keyCode === 27) { 10 | resetMenus() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/helpers/a11y.test.js: -------------------------------------------------------------------------------- 1 | import { click, escape } from './a11y' 2 | 3 | describe('a11y utility functions', () => { 4 | describe('click function', () => { 5 | it('should return true for space key (charCode 32)', () => { 6 | const event = { charCode: 32 } 7 | expect(click(event)).toBe(true) 8 | }) 9 | 10 | it('should return true for enter key (keyCode 13)', () => { 11 | const event = { keyCode: 13 } 12 | expect(click(event)).toBe(true) 13 | }) 14 | 15 | it('should return undefined for other keys', () => { 16 | const event = { keyCode: 65 } // 'A' key 17 | expect(click(event)).toBeUndefined() 18 | }) 19 | }) 20 | 21 | describe('escape function', () => { 22 | it('should call resetMenus for escape key (keyCode 27)', () => { 23 | const resetMenusMock = jest.fn() 24 | const event = { keyCode: 27 } 25 | escape(event, resetMenusMock) 26 | expect(resetMenusMock).toHaveBeenCalled() 27 | }) 28 | 29 | it('should not call resetMenus for other keys', () => { 30 | const resetMenusMock = jest.fn() 31 | const event = { keyCode: 65 } // 'A' key 32 | escape(event, resetMenusMock) 33 | expect(resetMenusMock).not.toHaveBeenCalled() 34 | }) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /src/helpers/animationStyles.js: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/react' 2 | import { respondTo } from './responsive' 3 | 4 | export const getAnimationStyles = (activeState) => { 5 | if (activeState === 'open') { 6 | return css` 7 | animation-duration: 0.75s; 8 | animation-fill-mode: both; 9 | animation-name: slideOpen; 10 | animation-iteration-count: 1; 11 | @media (prefers-reduced-motion: reduce) { 12 | animation: none; 13 | } 14 | 15 | @keyframes slideOpen { 16 | from { 17 | transform: translate3d(-100%, 0, 0); 18 | } 19 | to { 20 | transform: translate3d(100%, 0, 0); 21 | } 22 | } 23 | 24 | @media (prefers-reduced-motion: reduce) { 25 | transform: translate3d(100%, 0, 0); 26 | } 27 | 28 | ${respondTo('large')} { 29 | animation: none; 30 | display: flex; 31 | opacity: 1; 32 | @media (prefers-reduced-motion: reduce) { 33 | transform: none; 34 | } 35 | } 36 | ` 37 | } else if (activeState === 'closed') { 38 | return css` 39 | animation-duration: 0.75s; 40 | animation-fill-mode: both; 41 | animation-name: slideClosed; 42 | animation-iteration-count: 1; 43 | @media (prefers-reduced-motion: reduce) { 44 | animation: none; 45 | } 46 | 47 | @keyframes slideClosed { 48 | from { 49 | transform: translate3d(100%, 0, 0); 50 | } 51 | to { 52 | transform: translate3d(-100%, 0, 0); 53 | } 54 | } 55 | 56 | @media (prefers-reduced-motion: reduce) { 57 | transform: translate3d(-100%, 0, 0); 58 | } 59 | 60 | ${respondTo('large')} { 61 | animation: none; 62 | display: flex; 63 | opacity: 1; 64 | @media (prefers-reduced-motion: reduce) { 65 | transform: none; 66 | } 67 | } 68 | ` 69 | } 70 | return null 71 | } 72 | -------------------------------------------------------------------------------- /src/helpers/animationStyles.test.js: -------------------------------------------------------------------------------- 1 | import { getAnimationStyles } from './animationStyles' 2 | import { css } from '@emotion/react' 3 | import { respondTo } from './responsive' 4 | 5 | describe('getAnimationStyles', () => { 6 | test('returns correct styles for open state', () => { 7 | const styles = getAnimationStyles('open') 8 | expect(styles).toEqual(css` 9 | animation-duration: 0.75s; 10 | animation-fill-mode: both; 11 | animation-name: slideOpen; 12 | animation-iteration-count: 1; 13 | @media (prefers-reduced-motion: reduce) { 14 | animation: none; 15 | } 16 | 17 | @keyframes slideOpen { 18 | from { 19 | transform: translate3d(-100%, 0, 0); 20 | } 21 | to { 22 | transform: translate3d(100%, 0, 0); 23 | } 24 | } 25 | 26 | @media (prefers-reduced-motion: reduce) { 27 | transform: translate3d(100%, 0, 0); 28 | } 29 | 30 | ${respondTo('large')} { 31 | animation: none; 32 | display: flex; 33 | opacity: 1; 34 | @media (prefers-reduced-motion: reduce) { 35 | transform: none; 36 | } 37 | } 38 | `) 39 | }) 40 | 41 | test('returns correct styles for closed state', () => { 42 | const styles = getAnimationStyles('closed') 43 | expect(styles).toEqual(css` 44 | animation-duration: 0.75s; 45 | animation-fill-mode: both; 46 | animation-name: slideClosed; 47 | animation-iteration-count: 1; 48 | @media (prefers-reduced-motion: reduce) { 49 | animation: none; 50 | } 51 | 52 | @keyframes slideClosed { 53 | from { 54 | transform: translate3d(100%, 0, 0); 55 | } 56 | to { 57 | transform: translate3d(-100%, 0, 0); 58 | } 59 | } 60 | 61 | @media (prefers-reduced-motion: reduce) { 62 | transform: translate3d(-100%, 0, 0); 63 | } 64 | 65 | ${respondTo('large')} { 66 | animation: none; 67 | display: flex; 68 | opacity: 1; 69 | @media (prefers-reduced-motion: reduce) { 70 | transform: none; 71 | } 72 | } 73 | `) 74 | }) 75 | 76 | test('returns undefined for unknown state', () => { 77 | const styles = getAnimationStyles('unknown') 78 | expect(styles).toBeNull() 79 | }) 80 | }) 81 | -------------------------------------------------------------------------------- /src/helpers/menu.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | // Context 4 | import { useMenu } from '../context/MenuContext' 5 | 6 | // Components 7 | import MegaList from '../components/MegaList' 8 | import MainNavItem from '../components/MainNavItem' 9 | import MainNavItemLink from '../components/MainNavItemLink' 10 | import NavItem from '../components/NavItem' 11 | import NavItemLink from '../components/NavItemLink' 12 | import NavList from '../components/NavList' 13 | import NavItemDescription from '../components/NavItemDescription' 14 | 15 | import { 16 | MENU_ITEM_TYPE_LINK, 17 | MENU_ITEM_TYPE_MEGA, 18 | MENU_ITEM_TYPE_SUB 19 | } from '../config/menuItemTypes' 20 | 21 | export const handleUrl = (e, url, toggleMegaMenu) => { 22 | if (!url.includes('http')) { 23 | toggleMegaMenu(e) 24 | } 25 | window.location.href = url 26 | } 27 | 28 | export const formatIdString = (str) => { 29 | return str 30 | .toLowerCase() 31 | .replace(/[^a-z0-9\s-]/g, '') 32 | .trim() 33 | .replace(/\s+/g, '-') 34 | } 35 | 36 | export const renderMainMenuItem = (item) => { 37 | const { toggleMegaMenu } = useMenu() 38 | 39 | return ( 40 | 46 | handleUrl(e, item.url, toggleMegaMenu)} 52 | className="rmm__main-nav-item-link" 53 | > 54 | {item.label} 55 | 56 | 57 | ) 58 | } 59 | 60 | export const renderLinkMenuItem = (item) => { 61 | const { toggleMegaMenu } = useMenu() 62 | 63 | return ( 64 | 70 | handleUrl(e, item.url, toggleMegaMenu)} 75 | className="rmm__nav-item-link" 76 | > 77 | {item.label} 78 | 79 | {item.description && ( 80 | 81 | {item.description} 82 | 83 | )} 84 | 85 | ) 86 | } 87 | 88 | export const renderMegaMenuItem = ( 89 | item, 90 | a11yClick, 91 | renderLinkMenuItem, 92 | renderSubMenuItem 93 | ) => { 94 | const { activeMenus, toggleSubMenu } = useMenu() 95 | 96 | return ( 97 | 104 | 113 | toggleSubMenu(e, `rmm-mega-list-id-${formatIdString(item.id)}`) 114 | } 115 | onKeyDown={(e) => 116 | a11yClick(e) && 117 | toggleSubMenu(e, `rmm-mega-list-id-${formatIdString(item.id)}`) 118 | } 119 | ariaHaspopup="true" 120 | ariaControls={`rmm-mega-list-id-${formatIdString(item.id)}`} 121 | className="rmm__main-nav-item-link rmm__main-nav-item-link--forward" 122 | > 123 | {item.label} 124 | 125 | 134 | 138 | 142 | toggleSubMenu(e, `rmm-mega-list-id-${formatIdString(item.id)}`) 143 | } 144 | onKeyDown={(e) => 145 | a11yClick(e) && 146 | toggleSubMenu(e, `rmm-mega-list-id-${formatIdString(item.id)}`) 147 | } 148 | ariaHaspopup="true" 149 | ariaControls={`rmm-mega-list-id-${formatIdString(item.id)}`} 150 | className="rmm__nav-item-link rmm__nav-item-link--back" 151 | > 152 | {item.label} 153 | 154 | 155 | {item.items.map((item) => { 156 | switch (item.type) { 157 | case MENU_ITEM_TYPE_MEGA: 158 | return renderMegaMenuItem( 159 | item, 160 | a11yClick, 161 | renderLinkMenuItem, 162 | renderSubMenuItem 163 | ) 164 | case MENU_ITEM_TYPE_SUB: 165 | return renderSubMenuItem(item, a11yClick, renderLinkMenuItem) 166 | default: 167 | return renderLinkMenuItem(item) 168 | } 169 | })} 170 | 171 | 172 | ) 173 | } 174 | 175 | export const renderSubMenuItem = (item, a11yClick, renderLinkMenuItem) => { 176 | const { activeMenus, toggleSubMenu, toggleSubSubMenu } = useMenu() 177 | 178 | return ( 179 | 184 | 189 | toggleSubSubMenu(e, `rmm-nav-list-id-${formatIdString(item.id)}`) 190 | } 191 | onKeyDown={(e) => 192 | a11yClick(e) && 193 | toggleSubSubMenu(e, `rmm-nav-list-id-${formatIdString(item.id)}`) 194 | } 195 | ariaHaspopup="true" 196 | ariaControls={`rmm-nav-list-id-${formatIdString(item.id)}`} 197 | className="rmm__nav-item-link rmm__nav-item-link--forward" 198 | > 199 | {item.label} 200 | 201 | {item.description && ( 202 | 203 | {item.description} 204 | 205 | )} 206 | 220 | 225 | 230 | toggleSubMenu(e, `rmm-nav-list-id-${formatIdString(item.id)}`) 231 | } 232 | onKeyDown={(e) => 233 | a11yClick(e) && 234 | toggleSubMenu(e, `rmm-nav-list-id-${formatIdString(item.id)}`) 235 | } 236 | ariaHaspopup="true" 237 | ariaControls={`rmm-nav-list-id-${formatIdString(item.id)}`} 238 | className="rmm__nav-item-link rmm__nav-item-link--back" 239 | > 240 | {item.label} 241 | 242 | 243 | {item.items.map((item) => { 244 | switch (item.type) { 245 | case MENU_ITEM_TYPE_LINK: 246 | return renderLinkMenuItem(item) 247 | default: 248 | return null 249 | } 250 | })} 251 | 252 | 253 | ) 254 | } 255 | 256 | export const stateMachine = (state) => { 257 | const defaultState = 'closed' 258 | 259 | switch (state) { 260 | case 'closed': 261 | return 'open' 262 | case 'open': 263 | return 'closed' 264 | default: 265 | return defaultState 266 | } 267 | } 268 | 269 | export const config = { 270 | topbar: { 271 | id: 'topbar', 272 | logo: { 273 | src: 'https://via.placeholder.com/150x50', 274 | alt: 'Placeholder Logo', 275 | rel: 'home' 276 | }, 277 | title: 'React Mega Menu' 278 | }, 279 | menu: { 280 | items: [ 281 | { 282 | id: 'home', 283 | label: 'Home', 284 | type: 'main', 285 | url: '/' 286 | }, 287 | { 288 | id: 'about', 289 | label: 'About', 290 | type: 'main', 291 | url: '/about/' 292 | }, 293 | { 294 | id: 'store', 295 | label: 'Store', 296 | type: 'mega', 297 | url: '/store/', 298 | items: [ 299 | { 300 | id: 'store-deals', 301 | label: 'Deals', 302 | type: 'link', 303 | url: '/store/deals/', 304 | description: 305 | "Three lined small description that accompanies link in the React Mega Menu project. This maybe too much text? Who's to say, really. We'll leave it to fate to decide." 306 | }, 307 | { 308 | id: 'store-kitchen', 309 | label: 'Kitchen', 310 | type: 'link', 311 | url: '/store/kitchen/', 312 | description: 313 | "Three lined small description that accompanies link in the React Mega Menu project. This maybe too much text? Who's to say, really. We'll leave it to fate to decide." 314 | }, 315 | { 316 | id: 'store-outdoors', 317 | label: 'Outdoors', 318 | type: 'sub', 319 | url: '/store/outdoors/', 320 | description: 321 | "Three lined small description that accompanies link in the React Mega Menu project. This maybe too much text? Who's to say, really. We'll leave it to fate to decide.", 322 | items: [ 323 | { 324 | id: 'store-outdoors-tools', 325 | label: 'Tools', 326 | type: 'link', 327 | url: '/store/outdoors/tools/', 328 | description: 'Single line description that accompanies link' 329 | }, 330 | { 331 | id: 'store-outdoors-plants', 332 | label: 'Plants', 333 | type: 'link', 334 | url: '/store/outdoors/plants/', 335 | description: 'Single line description that accompanies link' 336 | }, 337 | { 338 | id: 'store-outdoors-patio', 339 | label: 'Patio', 340 | type: 'link', 341 | url: '/store/outdoors/patio/', 342 | description: 'Single line description that accompanies link' 343 | }, 344 | { 345 | id: 'store-outdoors-decking', 346 | label: 'Decking', 347 | type: 'link', 348 | url: '/store/outdoors/decking/', 349 | description: 'Single line description that accompanies link' 350 | } 351 | ] 352 | }, 353 | { 354 | id: 'store-bedroom', 355 | label: 'Bedroom', 356 | type: 'sub', 357 | url: '/store/bedroom/', 358 | description: 359 | "Three lined small description that accompanies link in the React Mega Menu project. This maybe too much text? Who's to say, really. We'll leave it to fate to decide.", 360 | items: [ 361 | { 362 | id: 'store-bedroom-beds', 363 | label: 'Beds', 364 | type: 'link', 365 | url: '/store/bedroom/beds/', 366 | description: 'Single line description that accompanies link' 367 | }, 368 | { 369 | id: 'store-bedroom-dressers', 370 | label: 'Dressers', 371 | type: 'link', 372 | url: '/store/bedroom/dressers/', 373 | description: 374 | 'Double lined small description that accompanies link in the React Mega Menu project' 375 | }, 376 | { 377 | id: 'store-bedroom-nightstands', 378 | label: 'Nightstands', 379 | type: 'link', 380 | url: '/store/bedroom/nightstands/', 381 | description: 382 | 'Double lined small description that accompanies link in the React Mega Menu project' 383 | }, 384 | { 385 | id: 'store-bedroom-benches', 386 | label: 'Benches', 387 | type: 'link', 388 | url: '/store/bedroom/benches/', 389 | description: 390 | 'Double lined small description that accompanies link in the React Mega Menu project' 391 | } 392 | ] 393 | } 394 | ] 395 | }, 396 | { 397 | id: 'blog', 398 | label: 'Blog', 399 | type: 'mega', 400 | url: '/blog/', 401 | items: [ 402 | { 403 | id: 'blog-latest-post-title', 404 | label: 'Latest Post Title', 405 | type: 'link', 406 | url: '/blog/posts/latest-post-title/', 407 | description: 408 | 'Double lined small description that accompanies link in the React Mega Menu project' 409 | }, 410 | { 411 | id: 'blog-categories', 412 | label: 'Categories', 413 | type: 'sub', 414 | url: '/blog/categories/', 415 | items: [ 416 | { 417 | id: 'blog-news', 418 | label: 'News', 419 | type: 'link', 420 | url: '/blog/news/' 421 | }, 422 | { 423 | id: 'blog-recipes', 424 | label: 'Recipes', 425 | type: 'link', 426 | url: '/blog/recipes/' 427 | }, 428 | { 429 | id: 'blog-health', 430 | label: 'Health', 431 | type: 'link', 432 | url: '/blog/health/' 433 | }, 434 | { 435 | id: 'blog-diet', 436 | label: 'Diet', 437 | type: 'link', 438 | url: '/blog/diet/' 439 | } 440 | ] 441 | } 442 | ] 443 | }, 444 | { 445 | id: 'help', 446 | label: 'Help', 447 | type: 'mega', 448 | url: '/help/', 449 | items: [ 450 | { 451 | id: 'help-react-mega-menu', 452 | label: 'React Mega Menu', 453 | type: 'link', 454 | url: 'https://github.com/jasonrundell/react-mega-menu', 455 | description: 456 | 'A React project which aims to be an accessible, responsive, boilerplate top navigation menu with a "Mega Menu"!' 457 | }, 458 | { 459 | id: 'help-faq', 460 | label: 'FAQ', 461 | type: 'link', 462 | url: '/help/faq/', 463 | description: 'Single line description that accompanies link' 464 | }, 465 | { 466 | id: 'help-knowledge-base', 467 | label: 'Knowledge Base', 468 | type: 'link', 469 | url: '/help/knowledge-base/', 470 | description: 471 | 'Double lined small description that accompanies link in the React Mega Menu project' 472 | } 473 | ] 474 | }, 475 | { 476 | id: 'settings', 477 | label: 'Settings', 478 | type: 'mega', 479 | url: '/settings/', 480 | items: [ 481 | { 482 | id: 'settings-profile', 483 | label: 'Profile', 484 | type: 'link', 485 | url: '/settings/profile/', 486 | description: 'Single line description that accompanies link' 487 | }, 488 | { 489 | id: 'settings-billing', 490 | label: 'Billing', 491 | type: 'link', 492 | url: '/settings/billing/', 493 | description: 'Single line description that accompanies link' 494 | }, 495 | { 496 | id: 'settings-theme', 497 | label: 'Theme', 498 | type: 'sub', 499 | url: '#', 500 | description: 'Change the React Mega Menu theme', 501 | items: [ 502 | { 503 | id: 'settings-theme-light', 504 | label: 'Light', 505 | type: 'link', 506 | url: '/?theme=light' 507 | }, 508 | { 509 | id: 'settings-theme-dark', 510 | label: 'Dark', 511 | type: 'link', 512 | url: '/?theme=dark' 513 | }, 514 | { 515 | id: 'settings-theme-monokai', 516 | label: 'Monokai', 517 | type: 'link', 518 | url: '/?theme=monokai' 519 | }, 520 | { 521 | id: 'settings-theme-retro', 522 | label: 'Retro', 523 | type: 'link', 524 | url: '/?theme=retro' 525 | }, 526 | { 527 | id: 'settings-theme-synthwave', 528 | label: 'Synthwave', 529 | type: 'link', 530 | url: '/?theme=synthwave' 531 | } 532 | ] 533 | }, 534 | { 535 | id: 'settings-logout', 536 | label: 'Logout', 537 | type: 'link', 538 | url: '/settings/logout/', 539 | description: 'Single line description that accompanies link' 540 | } 541 | ] 542 | }, 543 | { 544 | id: 'contact', 545 | label: 'Contact', 546 | type: 'main', 547 | url: '#contact' 548 | } 549 | ] 550 | } 551 | } 552 | -------------------------------------------------------------------------------- /src/helpers/menu.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render, fireEvent } from '@testing-library/react' 3 | import '@testing-library/jest-dom' 4 | import { 5 | renderMainMenuItem, 6 | renderLinkMenuItem, 7 | renderMegaMenuItem, 8 | renderSubMenuItem, 9 | stateMachine, 10 | handleUrl 11 | } from './menu' 12 | import { useMenu } from '../context/MenuContext' 13 | import MegaList from '../components/MegaList' 14 | import NavItemDescription from '../components/NavItemDescription' 15 | 16 | // Mock the useMenu hook 17 | jest.mock('../context/MenuContext', () => ({ 18 | useMenu: jest.fn() 19 | })) 20 | 21 | describe('Menu Functions', () => { 22 | const toggleMegaMenuMock = jest.fn() 23 | const toggleSubMenuMock = jest.fn() 24 | const a11yClickMock = jest.fn(() => true) 25 | 26 | beforeEach(() => { 27 | useMenu.mockReturnValue({ 28 | toggleMegaMenu: toggleMegaMenuMock, 29 | activeMenus: [], 30 | toggleSubMenu: toggleSubMenuMock, 31 | toggleSubSubMenu: jest.fn() 32 | }) 33 | }) 34 | 35 | test('renders MainNavItem correctly', () => { 36 | const item = { 37 | id: 'home', 38 | type: 'link', 39 | url: '/home', 40 | label: 'Home' 41 | } 42 | const { getByRole } = render(renderMainMenuItem(item)) 43 | const mainNavItem = getByRole('none') 44 | expect(mainNavItem).toBeInTheDocument() 45 | expect(mainNavItem).toHaveClass('rmm__main-nav-item') 46 | }) 47 | 48 | test('renders NavItem correctly', () => { 49 | const item = { 50 | id: 'home', 51 | type: 'link', 52 | url: '/home', 53 | label: 'Home', 54 | items: [{ id: 'about', type: 'link', url: '/about', label: 'About' }] 55 | } 56 | const { container } = render( 57 | renderMegaMenuItem( 58 | item, 59 | a11yClickMock, 60 | renderLinkMenuItem, 61 | renderSubMenuItem 62 | ) 63 | ) 64 | 65 | const navItem = container.querySelector('#rmm-nav-item-home') 66 | const navItemLink = container.querySelector('#rmm-main-nav-item-link-home') 67 | 68 | // Simulate click event 69 | fireEvent.click(navItemLink) // covers line 104 70 | 71 | // Assertions for the first item 72 | expect(navItem).toBeInTheDocument() 73 | expect(navItem).toHaveClass('rmm__nav-item rmm__nav-item--heading') 74 | expect(navItem).toHaveAttribute('id', 'rmm-nav-item-home') 75 | 76 | // Assertions for the nested item 77 | expect(navItemLink).toBeInTheDocument() 78 | expect(navItemLink).toHaveClass('rmm__main-nav-item-link') 79 | expect(navItemLink).toHaveAttribute('id', 'rmm-main-nav-item-link-home') 80 | 81 | // Verify that toggleSubMenu is called with the correct arguments 82 | expect(toggleSubMenuMock).toHaveBeenCalledWith( 83 | expect.any(Object), 84 | 'rmm-mega-list-id-home' 85 | ) 86 | }) 87 | 88 | test('renders NavItemLink correctly', () => { 89 | const item = { 90 | id: 'about', 91 | type: 'link', 92 | url: '/about', 93 | label: 'About' 94 | } 95 | const { getByRole } = render(renderLinkMenuItem(item)) 96 | const navItemLink = getByRole('menuitem') 97 | expect(navItemLink).toBeInTheDocument() 98 | expect(navItemLink).toHaveAttribute('href', '/about') 99 | }) 100 | 101 | test('renders MegaList correctly', () => { 102 | const { container } = render( 103 | 104 |
105 |
106 | ) 107 | const megaList = container.querySelector('#mega-list') 108 | expect(megaList).toBeInTheDocument() 109 | }) 110 | 111 | test('renders NavItemDescription correctly', () => { 112 | const { getByText } = render( 113 | Description 114 | ) 115 | const description = getByText('Description') 116 | expect(description).toBeInTheDocument() 117 | }) 118 | 119 | test('renderMainMenuItem renders correctly', () => { 120 | const item = { id: 'home', label: 'Home', url: '/', type: 'main' } 121 | const { getByRole } = render(renderMainMenuItem(item)) 122 | expect(getByRole('menuitem')).toHaveTextContent('Home') 123 | }) 124 | 125 | test('renderLinkMenuItem renders correctly', () => { 126 | const item = { 127 | id: 'deals', 128 | label: 'Deals', 129 | url: '/deals', 130 | type: 'link', 131 | description: 'Deals description' 132 | } 133 | const { getByRole, getByText } = render(renderLinkMenuItem(item)) 134 | expect(getByRole('menuitem')).toHaveTextContent('Deals') 135 | expect(getByText('Deals description')).toBeInTheDocument() 136 | }) 137 | 138 | test('renderMegaMenuItem renders correctly', () => { 139 | const item = { 140 | id: 'store', 141 | label: 'Store', 142 | url: '/store', 143 | type: 'mega', 144 | items: [ 145 | { id: 'subitem', label: 'SubItem', url: '/subitem', type: 'link' } 146 | ] 147 | } 148 | const { container } = render( 149 | renderMegaMenuItem(item, jest.fn(), renderLinkMenuItem, renderSubMenuItem) 150 | ) 151 | const menuItem = container.querySelector('#rmm-main-nav-item-link-store') 152 | expect(menuItem).toHaveTextContent('Store') 153 | }) 154 | 155 | test('calls toggleSubMenu onClick event for renderMegaMenuItem', () => { 156 | const item = { 157 | id: 'home', 158 | type: 'link', 159 | url: '/home', 160 | label: 'Home', 161 | items: [{ id: 'about', type: 'link', url: '/about', label: 'About' }] 162 | } 163 | 164 | const { container } = render( 165 | renderMegaMenuItem( 166 | item, 167 | a11yClickMock, 168 | renderLinkMenuItem, 169 | renderSubMenuItem 170 | ) 171 | ) 172 | 173 | const navItemLink = container.querySelector('#rmm-nav-item-link-home') 174 | 175 | fireEvent.click(navItemLink) // covers line 130 176 | 177 | // Verify that toggleSubMenu is called with the correct arguments 178 | expect(toggleSubMenuMock).toHaveBeenCalledWith( 179 | expect.any(Object), 180 | 'rmm-mega-list-id-home' 181 | ) 182 | }) 183 | 184 | test('calls toggleSubMenu on keydown event for renderMegaMenuItem', () => { 185 | const item = { 186 | id: 'home', 187 | type: 'link', 188 | url: '/home', 189 | label: 'Home', 190 | items: [{ id: 'about', type: 'link', url: '/about', label: 'About' }] 191 | } 192 | 193 | const { container } = render( 194 | renderMegaMenuItem( 195 | item, 196 | a11yClickMock, 197 | renderLinkMenuItem, 198 | renderSubMenuItem 199 | ) 200 | ) 201 | 202 | const navItemLink = container.querySelector('#rmm-nav-item-link-home') 203 | 204 | // Simulate keydown event 205 | fireEvent.keyDown(navItemLink, { 206 | key: 'Enter', 207 | code: 'Enter', 208 | keyCode: 13, 209 | charCode: 13 210 | }) 211 | 212 | // Verify that toggleSubMenu is called with the correct arguments 213 | expect(toggleSubMenuMock).toHaveBeenCalledWith( 214 | expect.any(Object), 215 | 'rmm-mega-list-id-home' 216 | ) 217 | }) 218 | 219 | test('renderSubMenuItem renders correctly', () => { 220 | const item = { 221 | id: 'outdoors', 222 | label: 'Outdoors', 223 | url: '/outdoors', 224 | type: 'sub', 225 | items: [{ id: 'tools', label: 'Tools', url: '/tools', type: 'link' }] 226 | } 227 | const { container } = render( 228 | renderSubMenuItem(item, jest.fn(), renderLinkMenuItem) 229 | ) 230 | 231 | const navItemLink = container.querySelector( 232 | '#rmm-nav-item-link-sub-outdoors' 233 | ) 234 | 235 | fireEvent.click(navItemLink) // covers line 221 236 | 237 | const menuItem = container.querySelector('#rmm-nav-item-link-outdoors') 238 | expect(menuItem).toHaveTextContent('Outdoors') 239 | }) 240 | }) 241 | 242 | describe('stateMachine', () => { 243 | test('should toggle state from closed to open', () => { 244 | const result = stateMachine('closed') 245 | expect(result).toBe('open') 246 | }) 247 | 248 | test('should toggle state from open to closed', () => { 249 | const result = stateMachine('open') 250 | expect(result).toBe('closed') 251 | }) 252 | 253 | test('should return default state for invalid state', () => { 254 | const result = stateMachine('invalid') 255 | expect(result).toBe('closed') 256 | }) 257 | 258 | test('should return default state for undefined state', () => { 259 | const result = stateMachine(undefined) 260 | expect(result).toBe('closed') 261 | }) 262 | 263 | test('should return default state for null state', () => { 264 | const result = stateMachine(null) 265 | expect(result).toBe('closed') 266 | }) 267 | }) 268 | 269 | describe('handleUrl function', () => { 270 | let originalLocation 271 | 272 | beforeAll(() => { 273 | // Save the original window.location 274 | originalLocation = window.location 275 | delete window.location 276 | window.location = { href: '' } 277 | }) 278 | 279 | afterAll(() => { 280 | // Restore the original window.location 281 | window.location = originalLocation 282 | }) 283 | 284 | test('calls toggleMegaMenu when URL does not include http', () => { 285 | const toggleMegaMenuMock = jest.fn() 286 | const eventMock = {} 287 | 288 | handleUrl(eventMock, '/internal-link', toggleMegaMenuMock) 289 | 290 | expect(toggleMegaMenuMock).toHaveBeenCalledWith(eventMock) 291 | expect(window.location.href).toBe('/internal-link') 292 | }) 293 | 294 | test('does not call toggleMegaMenu when URL includes http', () => { 295 | const toggleMegaMenuMock = jest.fn() 296 | const eventMock = {} 297 | 298 | handleUrl(eventMock, 'http://external-link.com', toggleMegaMenuMock) 299 | 300 | expect(toggleMegaMenuMock).not.toHaveBeenCalled() 301 | expect(window.location.href).toBe('http://external-link.com') 302 | }) 303 | }) 304 | -------------------------------------------------------------------------------- /src/helpers/responsive.js: -------------------------------------------------------------------------------- 1 | import { breakpoints as BreakPoints } from '../config/breakpoints' 2 | 3 | export const respondTo = (breakpoint) => { 4 | const breakpoints = { 5 | large: `@media (min-width: ${BreakPoints.large['min-width']})` 6 | } 7 | return breakpoints[breakpoint] || null 8 | } 9 | 10 | export const viewportLarge = 1024 11 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { MenuProvider } from './context/MenuContext' 3 | import { Menu } from './Menu' 4 | 5 | const MenuWithProvider = (props) => ( 6 | 7 | 8 | 9 | ) 10 | 11 | export { MenuWithProvider as Menu } 12 | export default MenuWithProvider 13 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from '@testing-library/react' 3 | import '@testing-library/jest-dom' 4 | import { Menu } from './index' // Adjust the import path as needed 5 | 6 | // Mock the Menu component 7 | jest.mock('./Menu', () => ({ 8 | Menu: jest.fn(() =>
Menu Component
) 9 | })) 10 | 11 | describe('MenuWithProvider', () => { 12 | it('should render Menu component with the provider', () => { 13 | // Arrange: Render the MenuWithProvider component 14 | const { getByTestId } = render() 15 | 16 | // Assert: Check if the Menu component is rendered 17 | const menuComponent = getByTestId('menu-component') 18 | expect(menuComponent).toBeInTheDocument() 19 | }) 20 | 21 | it('should pass props to the Menu component', () => { 22 | // Arrange: Render the MenuWithProvider component with a prop 23 | const testProp = 'testValue' 24 | render() 25 | 26 | // Assert: Check if the props are passed correctly 27 | expect(require('./Menu').Menu).toHaveBeenCalledWith( 28 | expect.objectContaining({ someProp: testProp }), 29 | {} 30 | ) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES5", 4 | "module": "ESNext", 5 | "lib": ["DOM", "ESNext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "react-jsx", 16 | "inlineSources": true, 17 | "declaration": true, 18 | "declarationMap": true, 19 | "emitDeclarationOnly": true, 20 | "outDir": "./dist", 21 | "sourceMap": true 22 | }, 23 | "include": ["src"] 24 | } 25 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import path from 'path' 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | resolve: { 8 | alias: { 9 | '@': path.resolve(__dirname, 'src') 10 | } 11 | }, 12 | build: { 13 | lib: { 14 | entry: 'src/index.jsx', 15 | name: 'ReactMegaMenu', 16 | formats: ['es', 'cjs'], 17 | fileName: (format) => `index.${format}.js` 18 | }, 19 | sourcemap: true, 20 | rollupOptions: { 21 | external: ['react', 'react-dom', '@emotion/react', '@emotion/styled'], 22 | output: { 23 | globals: { 24 | react: 'React', 25 | 'react-dom': 'ReactDOM', 26 | '@emotion/react': 'emotionReact', 27 | '@emotion/styled': 'emotionStyled' 28 | }, 29 | exports: 'named' 30 | } 31 | } 32 | } 33 | }) 34 | --------------------------------------------------------------------------------