├── .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 | [](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 |
454 | {headEnabled ? 'Disable styling to view' : 'Re-enable styling'}
455 |
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 | handleThemeChange(theme)}>
483 | {theme.charAt(0).toUpperCase() + theme.slice(1)}
484 |
485 |
486 | ))}
487 |
488 | handleThemeChange('')}>None
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 |
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 |
308 |
309 |
310 | Get started by editing{' '}
311 |
312 | src/app/page.js
313 |
314 | .
315 |
316 | Save and see your changes instantly.
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 |
124 |
129 | {config.menu.items.map((item) => {
130 | if (item.type === MENU_ITEM_TYPE_MEGA) {
131 | return renderMegaMenuItem(
132 | item,
133 | a11yClick,
134 | renderLinkMenuItem,
135 | renderSubMenuItem,
136 | toggleMegaMenu
137 | )
138 | } else {
139 | return renderMainMenuItem(item, toggleMegaMenu)
140 | }
141 | })}
142 |
143 |
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 |
10 |
11 | Home
12 | About
13 |
14 |
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 |
24 |
25 | Home
26 | About
27 |
28 |
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 |
42 |
43 | Home
44 | About
45 |
46 |
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 |
60 |
61 | Home
62 | About
63 |
64 |
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 |
--------------------------------------------------------------------------------