├── example ├── public │ ├── .nojekyll │ ├── gdbk.jpeg │ ├── favicon.ico │ ├── octocat.png │ ├── GitHub-Mark-32px.png │ ├── GitHub-Mark-64px.png │ ├── GitHub-Mark-Light-32px.png │ ├── GitHub-Mark-Light-64px.png │ └── manifest.json ├── src │ ├── pages │ │ ├── 404.js │ │ ├── docs.js │ │ ├── index.js │ │ ├── style-guide.js │ │ ├── _app.js │ │ └── _document.js │ ├── components │ │ ├── LibName.js │ │ ├── Docs.js │ │ ├── StyleGuide.js │ │ ├── NotFound.js │ │ ├── ExternalLink.js │ │ ├── RightSection.js │ │ ├── ThemeSwitch.js │ │ ├── PromoSpot.js │ │ ├── HeaderBanner.js │ │ ├── PageView.js │ │ ├── CascadingContents.js │ │ ├── TableContentsList.js │ │ ├── Promo.js │ │ ├── Table.js │ │ ├── HashHeading.js │ │ ├── Icons.js │ │ ├── Logo.js │ │ ├── StyleExamples.js │ │ ├── Footer.js │ │ ├── App.js │ │ ├── TableContents.js │ │ └── Header.js │ ├── styles │ │ ├── _logo.scss │ │ ├── _theme-switch.scss │ │ ├── _hash-heading.scss │ │ ├── _prism.scss │ │ ├── _menu.scss │ │ ├── _var.scss │ │ ├── _mixins.scss │ │ └── _table-contents.scss │ ├── store.js │ └── utils │ │ └── index.js ├── README.md ├── next.config.js ├── package.json └── gh-pages.sh ├── .browserslistrc ├── .prettierignore ├── setup-jest.js ├── types ├── tsconfig.json └── style-utils.d.ts ├── src ├── utils │ ├── index.js │ ├── withHovering.js │ ├── submenuCtx.js │ ├── constants.js │ └── utils.js ├── styles │ ├── _mixins.scss │ ├── _var.scss │ ├── theme-dark.scss │ ├── transitions │ │ ├── slide.scss │ │ └── zoom.scss │ ├── index.scss │ └── core.scss ├── positionUtils │ ├── index.js │ ├── placeArrowHorizontal.js │ ├── placeArrowVertical.js │ ├── getNormalizedClientRect.js │ ├── placeLeftorRight.js │ ├── placeToporBottom.js │ ├── positionMenu.js │ └── getPositionHelpers.js ├── __tests__ │ ├── entry.js │ ├── utils.test.js │ ├── SSR.test.js │ ├── MenuGroup.test.js │ ├── styling.test.js │ ├── style-utils.test.js │ └── useMenuState.test.js ├── hooks │ ├── useMouseOver.js │ ├── useMenuStateAndFocus.js │ ├── index.js │ ├── useClick.js │ ├── useCombinedRef.js │ ├── useItemEffect.js │ ├── useIsomorphicLayoutEffect.js │ ├── useHover.js │ ├── useBEM.js │ ├── useMenuState.js │ └── useItemState.js ├── components │ ├── MenuDivider.js │ ├── MenuHeader.js │ ├── MenuButton.js │ ├── MenuRadioGroup.js │ ├── MenuContainer.js │ ├── MenuGroup.js │ ├── FocusableItem.js │ └── Menu.js ├── index.js └── style-utils │ └── index.js ├── .prettierrc.yaml ├── style-utils └── package.json ├── jest.config.js ├── .gitignore ├── dist ├── esm │ ├── hooks │ │ ├── useIsomorphicLayoutEffect.mjs │ │ ├── useMouseOver.mjs │ │ ├── useCombinedRef.mjs │ │ ├── useClick.mjs │ │ ├── useMenuStateAndFocus.mjs │ │ ├── useItemEffect.mjs │ │ ├── useHover.mjs │ │ ├── useBEM.mjs │ │ ├── useMenuState.mjs │ │ └── useItemState.mjs │ ├── positionUtils │ │ ├── placeArrowVertical.mjs │ │ ├── placeArrowHorizontal.mjs │ │ ├── getNormalizedClientRect.mjs │ │ ├── placeLeftorRight.mjs │ │ ├── placeToporBottom.mjs │ │ ├── getPositionHelpers.mjs │ │ └── positionMenu.mjs │ ├── utils │ │ ├── submenuCtx.mjs │ │ ├── withHovering.mjs │ │ ├── constants.mjs │ │ └── utils.mjs │ ├── components │ │ ├── MenuDivider.mjs │ │ ├── MenuHeader.mjs │ │ ├── MenuButton.mjs │ │ ├── MenuRadioGroup.mjs │ │ ├── MenuContainer.mjs │ │ ├── MenuGroup.mjs │ │ ├── FocusableItem.mjs │ │ └── Menu.mjs │ └── index.mjs ├── cjs │ ├── hooks │ │ ├── useIsomorphicLayoutEffect.cjs │ │ ├── useMouseOver.cjs │ │ ├── useCombinedRef.cjs │ │ ├── useClick.cjs │ │ ├── useMenuStateAndFocus.cjs │ │ ├── useItemEffect.cjs │ │ ├── useHover.cjs │ │ ├── useBEM.cjs │ │ ├── useMenuState.cjs │ │ └── useItemState.cjs │ ├── positionUtils │ │ ├── placeArrowVertical.cjs │ │ ├── placeArrowHorizontal.cjs │ │ ├── getNormalizedClientRect.cjs │ │ ├── placeLeftorRight.cjs │ │ ├── placeToporBottom.cjs │ │ ├── getPositionHelpers.cjs │ │ └── positionMenu.cjs │ ├── utils │ │ ├── submenuCtx.cjs │ │ ├── withHovering.cjs │ │ ├── utils.cjs │ │ └── constants.cjs │ ├── components │ │ ├── MenuDivider.cjs │ │ ├── MenuHeader.cjs │ │ ├── MenuButton.cjs │ │ ├── MenuRadioGroup.cjs │ │ ├── MenuContainer.cjs │ │ ├── MenuGroup.cjs │ │ ├── FocusableItem.cjs │ │ └── Menu.cjs │ └── index.cjs ├── theme-dark.css ├── transitions │ ├── zoom.css │ └── slide.css ├── core.css └── style-utils │ ├── index.mjs │ └── index.cjs ├── .github ├── ISSUE_TEMPLATE │ ├── ---feature-request.md │ ├── ---bug-report.md │ └── ---questions-and-help.md └── dependabot.yml ├── docs ├── migration │ ├── index.md │ └── v4.md └── FAQs.md ├── babel.config.js ├── LICENSE ├── rollup.config.mjs ├── eslint.config.mjs ├── README.md └── package.json /example/public/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | last 2 versions 2 | > 0.2% 3 | not dead 4 | not op_mini all -------------------------------------------------------------------------------- /example/src/pages/404.js: -------------------------------------------------------------------------------- 1 | export { default } from '../components/NotFound'; 2 | -------------------------------------------------------------------------------- /example/src/pages/docs.js: -------------------------------------------------------------------------------- 1 | export { default } from '../components/Docs'; 2 | -------------------------------------------------------------------------------- /example/src/pages/index.js: -------------------------------------------------------------------------------- 1 | export { default } from '../components/Usage'; 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build/ 2 | coverage/ 3 | dist/ 4 | example/out/ 5 | .next/ 6 | _next/ -------------------------------------------------------------------------------- /example/src/pages/style-guide.js: -------------------------------------------------------------------------------- 1 | export { default } from '../components/StyleGuide'; 2 | -------------------------------------------------------------------------------- /example/public/gdbk.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szhsin/react-menu/HEAD/example/public/gdbk.jpeg -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szhsin/react-menu/HEAD/example/public/favicon.ico -------------------------------------------------------------------------------- /example/public/octocat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szhsin/react-menu/HEAD/example/public/octocat.png -------------------------------------------------------------------------------- /setup-jest.js: -------------------------------------------------------------------------------- 1 | const { toHaveNoViolations } = require('jest-axe'); 2 | expect.extend(toHaveNoViolations); 3 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | React-Menu examples and documentation website, built with [Next.js](https://nextjs.org/). 2 | -------------------------------------------------------------------------------- /types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "noEmit": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /example/public/GitHub-Mark-32px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szhsin/react-menu/HEAD/example/public/GitHub-Mark-32px.png -------------------------------------------------------------------------------- /example/public/GitHub-Mark-64px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szhsin/react-menu/HEAD/example/public/GitHub-Mark-64px.png -------------------------------------------------------------------------------- /example/src/components/LibName.js: -------------------------------------------------------------------------------- 1 | export function LibName() { 2 | return React-Menu; 3 | } 4 | -------------------------------------------------------------------------------- /example/public/GitHub-Mark-Light-32px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szhsin/react-menu/HEAD/example/public/GitHub-Mark-Light-32px.png -------------------------------------------------------------------------------- /example/public/GitHub-Mark-Light-64px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/szhsin/react-menu/HEAD/example/public/GitHub-Mark-Light-64px.png -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | export * from './constants'; 2 | export * from './utils'; 3 | export * from './submenuCtx'; 4 | export * from './withHovering'; 5 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | trailingComma: none 2 | singleQuote: true 3 | printWidth: 100 4 | overrides: 5 | - files: '*.md' 6 | options: 7 | printWidth: 80 8 | -------------------------------------------------------------------------------- /src/styles/_mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin reset-list { 2 | margin: 0; 3 | padding: 0; 4 | list-style: none; 5 | } 6 | 7 | @mixin remove-focus { 8 | &:focus { 9 | outline: none; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/positionUtils/index.js: -------------------------------------------------------------------------------- 1 | export { getNormalizedClientRect } from './getNormalizedClientRect'; 2 | export { getPositionHelpers } from './getPositionHelpers'; 3 | export { positionMenu } from './positionMenu'; 4 | -------------------------------------------------------------------------------- /style-utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "sideEffects": false, 4 | "main": "../dist/style-utils/index.cjs", 5 | "module": "../dist/style-utils/index.mjs", 6 | "types": "../types/style-utils.d.ts" 7 | } 8 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | testEnvironment: 'jsdom', 4 | testMatch: ['**/*.test.js'], 5 | setupFilesAfterEnv: ['@testing-library/jest-dom', 'jest-dom-extended/jest', './setup-jest.js'] 6 | }; 7 | -------------------------------------------------------------------------------- /example/src/components/Docs.js: -------------------------------------------------------------------------------- 1 | import { PageView } from './PageView'; 2 | import documentation from '../data/documentation'; 3 | 4 | const Docs = () => ; 5 | 6 | export default Docs; 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules/ 3 | 4 | # builds 5 | build/ 6 | .next/ 7 | _next/ 8 | example/out/ 9 | 10 | # tests 11 | coverage/ 12 | 13 | # logs 14 | logs 15 | *.log 16 | 17 | # misc 18 | .DS_Store 19 | .npm 20 | .env 21 | .eslintcache -------------------------------------------------------------------------------- /example/next.config.js: -------------------------------------------------------------------------------- 1 | const path = require('node:path'); 2 | 3 | module.exports = { 4 | reactStrictMode: true, 5 | output: 'export', 6 | basePath: '/react-menu', 7 | turbopack: { 8 | root: path.join(__dirname, '..') 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /example/src/components/StyleGuide.js: -------------------------------------------------------------------------------- 1 | import { PageView } from './PageView'; 2 | import styleGuide from '../data/styleGuide'; 3 | 4 | const StyleGuide = () => ; 5 | 6 | export default StyleGuide; 7 | -------------------------------------------------------------------------------- /example/src/components/NotFound.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const NotFound = React.memo(function NotFound() { 4 | return ( 5 |
6 |

404 - Page Not Found

7 |
8 | ); 9 | }); 10 | 11 | export default NotFound; 12 | -------------------------------------------------------------------------------- /example/src/components/ExternalLink.js: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import { ExternalLinkIcon } from './Icons'; 3 | 4 | export const ExternalLink = memo(({ href, children }) => ( 5 | 6 | {children} 7 | 8 | 9 | )); 10 | -------------------------------------------------------------------------------- /dist/esm/hooks/useIsomorphicLayoutEffect.mjs: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useEffect } from 'react'; 2 | 3 | const useIsomorphicLayoutEffect = typeof window !== 'undefined' && typeof window.document !== 'undefined' && typeof window.document.createElement !== 'undefined' ? useLayoutEffect : useEffect; 4 | 5 | export { useIsomorphicLayoutEffect as useLayoutEffect }; 6 | -------------------------------------------------------------------------------- /src/__tests__/entry.js: -------------------------------------------------------------------------------- 1 | export * from '../'; 2 | export * from '../style-utils'; 3 | 4 | // Test npm distribution before publishing 5 | 6 | // cjs 7 | // export * from '../../dist'; 8 | // export * from '../../dist/style-utils/index.cjs'; 9 | 10 | // es 11 | // export * from '../../dist/es'; 12 | // export * from '../../dist/style-utils/index.js'; 13 | -------------------------------------------------------------------------------- /dist/cjs/hooks/useIsomorphicLayoutEffect.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var react = require('react'); 4 | 5 | const useIsomorphicLayoutEffect = typeof window !== 'undefined' && typeof window.document !== 'undefined' && typeof window.document.createElement !== 'undefined' ? react.useLayoutEffect : react.useEffect; 6 | 7 | exports.useLayoutEffect = useIsomorphicLayoutEffect; 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/---feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F4A1 Feature Request" 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: unconfirmed 6 | assignees: '' 7 | --- 8 | 9 | 13 | -------------------------------------------------------------------------------- /src/positionUtils/placeArrowHorizontal.js: -------------------------------------------------------------------------------- 1 | export const placeArrowHorizontal = ({ arrowRef, menuX, anchorRect, containerRect, menuRect }) => { 2 | let x = anchorRect.left - containerRect.left - menuX + anchorRect.width / 2; 3 | const offset = arrowRef.current.offsetWidth * 1.25; 4 | x = Math.max(offset, x); 5 | x = Math.min(x, menuRect.width - offset); 6 | return x; 7 | }; 8 | -------------------------------------------------------------------------------- /src/positionUtils/placeArrowVertical.js: -------------------------------------------------------------------------------- 1 | export const placeArrowVertical = ({ arrowRef, menuY, anchorRect, containerRect, menuRect }) => { 2 | let y = anchorRect.top - containerRect.top - menuY + anchorRect.height / 2; 3 | const offset = arrowRef.current.offsetHeight * 1.25; 4 | y = Math.max(offset, y); 5 | y = Math.min(y, menuRect.height - offset); 6 | return y; 7 | }; 8 | -------------------------------------------------------------------------------- /docs/migration/index.md: -------------------------------------------------------------------------------- 1 | ## Migration guides 2 | 3 | - [Migrating to v4](v4.md) 4 | - [Migrating to v3](v3.md) 5 | - [Migrating to v2](v2.md) 6 | 7 | ## Looking for doc/example websites of older versions? 8 | 9 | - [v3 docs](https://szhsin.github.io/react-menu-v3/) 10 | - [v2 docs](https://szhsin.github.io/react-menu-v2/) 11 | - [v1 docs](https://szhsin.github.io/react-menu-v1/) 12 | -------------------------------------------------------------------------------- /example/src/components/RightSection.js: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import { useDomInfo } from '../store'; 3 | import { Promo } from './Promo'; 4 | 5 | export const RightSection = memo(function RightSection() { 6 | return ( 7 |
8 | 9 |
10 | ); 11 | }); 12 | -------------------------------------------------------------------------------- /example/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "react-menu", 3 | "name": "@szhsin/react-menu", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#ffffff", 14 | "background_color": "#000000" 15 | } 16 | -------------------------------------------------------------------------------- /example/src/styles/_logo.scss: -------------------------------------------------------------------------------- 1 | .app-logo { 2 | display: flex; 3 | align-items: center; 4 | font-weight: 700; 5 | cursor: pointer; 6 | .version { 7 | margin-left: 0.5rem; 8 | font-size: 0.85rem; 9 | font-weight: normal; 10 | color: #888; 11 | } 12 | .drop-down { 13 | font-size: 1.75rem; 14 | } 15 | } 16 | 17 | .version-menu { 18 | font-weight: normal; 19 | } 20 | -------------------------------------------------------------------------------- /dist/esm/hooks/useMouseOver.mjs: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | const useMouseOver = isHovering => { 4 | const [mouseOver, setMouseOver] = useState(false); 5 | useEffect(() => { 6 | !isHovering && setMouseOver(false); 7 | }, [isHovering]); 8 | return [mouseOver, () => !mouseOver && setMouseOver(true), () => setMouseOver(false)]; 9 | }; 10 | 11 | export { useMouseOver }; 12 | -------------------------------------------------------------------------------- /src/hooks/useMouseOver.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | const useMouseOver = (isHovering) => { 4 | const [mouseOver, setMouseOver] = useState(false); 5 | 6 | useEffect(() => { 7 | !isHovering && setMouseOver(false); 8 | }, [isHovering]); 9 | 10 | return [mouseOver, () => !mouseOver && setMouseOver(true), () => setMouseOver(false)]; 11 | }; 12 | 13 | export { useMouseOver }; 14 | -------------------------------------------------------------------------------- /dist/cjs/hooks/useMouseOver.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var react = require('react'); 4 | 5 | const useMouseOver = isHovering => { 6 | const [mouseOver, setMouseOver] = react.useState(false); 7 | react.useEffect(() => { 8 | !isHovering && setMouseOver(false); 9 | }, [isHovering]); 10 | return [mouseOver, () => !mouseOver && setMouseOver(true), () => setMouseOver(false)]; 11 | }; 12 | 13 | exports.useMouseOver = useMouseOver; 14 | -------------------------------------------------------------------------------- /dist/esm/positionUtils/placeArrowVertical.mjs: -------------------------------------------------------------------------------- 1 | const placeArrowVertical = ({ 2 | arrowRef, 3 | menuY, 4 | anchorRect, 5 | containerRect, 6 | menuRect 7 | }) => { 8 | let y = anchorRect.top - containerRect.top - menuY + anchorRect.height / 2; 9 | const offset = arrowRef.current.offsetHeight * 1.25; 10 | y = Math.max(offset, y); 11 | y = Math.min(y, menuRect.height - offset); 12 | return y; 13 | }; 14 | 15 | export { placeArrowVertical }; 16 | -------------------------------------------------------------------------------- /dist/esm/positionUtils/placeArrowHorizontal.mjs: -------------------------------------------------------------------------------- 1 | const placeArrowHorizontal = ({ 2 | arrowRef, 3 | menuX, 4 | anchorRect, 5 | containerRect, 6 | menuRect 7 | }) => { 8 | let x = anchorRect.left - containerRect.left - menuX + anchorRect.width / 2; 9 | const offset = arrowRef.current.offsetWidth * 1.25; 10 | x = Math.max(offset, x); 11 | x = Math.min(x, menuRect.width - offset); 12 | return x; 13 | }; 14 | 15 | export { placeArrowHorizontal }; 16 | -------------------------------------------------------------------------------- /dist/esm/hooks/useCombinedRef.mjs: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | 3 | function setRef(ref, instance) { 4 | typeof ref === 'function' ? ref(instance) : ref.current = instance; 5 | } 6 | const useCombinedRef = (refA, refB) => useMemo(() => { 7 | if (!refA) return refB; 8 | if (!refB) return refA; 9 | return instance => { 10 | setRef(refA, instance); 11 | setRef(refB, instance); 12 | }; 13 | }, [refA, refB]); 14 | 15 | export { useCombinedRef }; 16 | -------------------------------------------------------------------------------- /dist/cjs/positionUtils/placeArrowVertical.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const placeArrowVertical = ({ 4 | arrowRef, 5 | menuY, 6 | anchorRect, 7 | containerRect, 8 | menuRect 9 | }) => { 10 | let y = anchorRect.top - containerRect.top - menuY + anchorRect.height / 2; 11 | const offset = arrowRef.current.offsetHeight * 1.25; 12 | y = Math.max(offset, y); 13 | y = Math.min(y, menuRect.height - offset); 14 | return y; 15 | }; 16 | 17 | exports.placeArrowVertical = placeArrowVertical; 18 | -------------------------------------------------------------------------------- /dist/esm/positionUtils/getNormalizedClientRect.mjs: -------------------------------------------------------------------------------- 1 | const getNativeDimension = (transformed, untransformed) => Math.round(transformed) === untransformed ? transformed : untransformed; 2 | const getNormalizedClientRect = element => { 3 | const rect = element.getBoundingClientRect(); 4 | rect.width = getNativeDimension(rect.width, element.offsetWidth); 5 | rect.height = getNativeDimension(rect.height, element.offsetHeight); 6 | return rect; 7 | }; 8 | 9 | export { getNormalizedClientRect }; 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/---bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F41B Bug Report" 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: unconfirmed 6 | assignees: '' 7 | --- 8 | 9 | React/React-dom version: 10 | React-Menu version: 11 | 12 | ## Describe the bug 13 | 14 | ## To Reproduce 15 | 16 | 1. 17 | 2. 18 | 3. 19 | 20 | ## Expected behavior 21 | 22 | ## Code snippets/CodeSandbox examples/Screenshots 23 | 24 | 25 | -------------------------------------------------------------------------------- /dist/cjs/hooks/useCombinedRef.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var react = require('react'); 4 | 5 | function setRef(ref, instance) { 6 | typeof ref === 'function' ? ref(instance) : ref.current = instance; 7 | } 8 | const useCombinedRef = (refA, refB) => react.useMemo(() => { 9 | if (!refA) return refB; 10 | if (!refB) return refA; 11 | return instance => { 12 | setRef(refA, instance); 13 | setRef(refB, instance); 14 | }; 15 | }, [refA, refB]); 16 | 17 | exports.useCombinedRef = useCombinedRef; 18 | -------------------------------------------------------------------------------- /dist/cjs/positionUtils/placeArrowHorizontal.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const placeArrowHorizontal = ({ 4 | arrowRef, 5 | menuX, 6 | anchorRect, 7 | containerRect, 8 | menuRect 9 | }) => { 10 | let x = anchorRect.left - containerRect.left - menuX + anchorRect.width / 2; 11 | const offset = arrowRef.current.offsetWidth * 1.25; 12 | x = Math.max(offset, x); 13 | x = Math.min(x, menuRect.width - offset); 14 | return x; 15 | }; 16 | 17 | exports.placeArrowHorizontal = placeArrowHorizontal; 18 | -------------------------------------------------------------------------------- /src/positionUtils/getNormalizedClientRect.js: -------------------------------------------------------------------------------- 1 | const getNativeDimension = (transformed, untransformed) => 2 | Math.round(transformed) === untransformed ? transformed : untransformed; 3 | 4 | const getNormalizedClientRect = (element) => { 5 | const rect = element.getBoundingClientRect(); 6 | rect.width = getNativeDimension(rect.width, element.offsetWidth); 7 | rect.height = getNativeDimension(rect.height, element.offsetHeight); 8 | return rect; 9 | }; 10 | 11 | export { getNormalizedClientRect }; 12 | -------------------------------------------------------------------------------- /example/src/components/ThemeSwitch.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useTheme, themeState } from '../store'; 3 | import { bem } from '../utils'; 4 | 5 | export const ThemeSwitch = React.memo(function ThemeSwitch() { 6 | const { isDark, theme } = useTheme(); 7 | 8 | return ( 9 | themeState.set(e.target.checked ? 'dark' : 'light')} 13 | checked={isDark} 14 | /> 15 | ); 16 | }); 17 | -------------------------------------------------------------------------------- /src/hooks/useMenuStateAndFocus.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useMenuState } from './useMenuState'; 3 | 4 | export const useMenuStateAndFocus = (options) => { 5 | const [menuProps, toggleMenu] = useMenuState(options); 6 | const [menuItemFocus, setMenuItemFocus] = useState(); 7 | 8 | const openMenu = (position, alwaysUpdate) => { 9 | setMenuItemFocus({ position, alwaysUpdate }); 10 | toggleMenu(true); 11 | }; 12 | 13 | return [{ menuItemFocus, ...menuProps }, toggleMenu, openMenu]; 14 | }; 15 | -------------------------------------------------------------------------------- /dist/cjs/positionUtils/getNormalizedClientRect.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const getNativeDimension = (transformed, untransformed) => Math.round(transformed) === untransformed ? transformed : untransformed; 4 | const getNormalizedClientRect = element => { 5 | const rect = element.getBoundingClientRect(); 6 | rect.width = getNativeDimension(rect.width, element.offsetWidth); 7 | rect.height = getNativeDimension(rect.height, element.offsetHeight); 8 | return rect; 9 | }; 10 | 11 | exports.getNormalizedClientRect = getNormalizedClientRect; 12 | -------------------------------------------------------------------------------- /src/components/MenuDivider.js: -------------------------------------------------------------------------------- 1 | import { memo, forwardRef } from 'react'; 2 | import { useBEM } from '../hooks'; 3 | import { menuClass, menuDividerClass } from '../utils'; 4 | 5 | export const MenuDivider = memo( 6 | forwardRef(function MenuDivider({ className, ...restProps }, externalRef) { 7 | return ( 8 |
  • 14 | ); 15 | }) 16 | ); 17 | -------------------------------------------------------------------------------- /src/components/MenuHeader.js: -------------------------------------------------------------------------------- 1 | import { memo, forwardRef } from 'react'; 2 | import { useBEM } from '../hooks'; 3 | import { menuClass, menuHeaderClass, roleNone } from '../utils'; 4 | 5 | export const MenuHeader = memo( 6 | forwardRef(function MenuHeader({ className, ...restProps }, externalRef) { 7 | return ( 8 |
  • 14 | ); 15 | }) 16 | ); 17 | -------------------------------------------------------------------------------- /example/src/styles/_theme-switch.scss: -------------------------------------------------------------------------------- 1 | @use 'var'; 2 | @use 'mixins'; 3 | 4 | .theme-switch { 5 | display: flex; 6 | align-items: center; 7 | font-family: 'Material Icons'; 8 | cursor: pointer; 9 | color: var.$navbar-dark; 10 | border: none; 11 | @include mixins.remove-focus; 12 | 13 | &::before { 14 | content: 'nights_stay'; 15 | } 16 | &:checked::before { 17 | content: 'light_mode'; 18 | } 19 | &:hover { 20 | opacity: 0.75; 21 | } 22 | 23 | &--theme-dark { 24 | color: var.$navbar-light; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/styles/_var.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:math'; 2 | 3 | $color: #212529; 4 | $color-dark: #cad1d8; 5 | $color-disabled: #aaa; 6 | $color-disabled-dark: #666; 7 | 8 | $header-color: #888; 9 | $divider-color: rgba(0, 0, 0, 0.12); 10 | $divider-color-dark: #3a3a3a; 11 | $border-color: rgba(0, 0, 0, 0.1); 12 | $border-color-dark: #333; 13 | 14 | $background-color: #fff; 15 | $background-color-dark: #22262c; 16 | $background-color-hover: #ebebeb; 17 | $background-color-hover-dark: #31363c; 18 | 19 | $arrow-size: 0.75rem; 20 | $arrow-pos: -(math.div($arrow-size, 2)); 21 | -------------------------------------------------------------------------------- /src/hooks/index.js: -------------------------------------------------------------------------------- 1 | export { useBEM } from './useBEM'; 2 | export { useClick } from './useClick'; 3 | export { useCombinedRef } from './useCombinedRef'; 4 | export { useHover } from './useHover'; 5 | export { useLayoutEffect } from './useIsomorphicLayoutEffect'; 6 | export { useItems } from './useItems'; 7 | export { useItemEffect } from './useItemEffect'; 8 | export { useItemState } from './useItemState'; 9 | export { useMenuState } from './useMenuState'; 10 | export { useMenuStateAndFocus } from './useMenuStateAndFocus'; 11 | export { useMouseOver } from './useMouseOver'; 12 | -------------------------------------------------------------------------------- /dist/esm/hooks/useClick.mjs: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | const useClick = (state, onToggle) => { 4 | if (process.env.NODE_ENV !== 'production' && typeof onToggle !== 'function') { 5 | throw new Error('[React-Menu] useClick/useHover requires a function in the second parameter.'); 6 | } 7 | const [skipOpen] = useState({}); 8 | return { 9 | onMouseDown: () => { 10 | skipOpen.v = state && state !== 'closed'; 11 | }, 12 | onClick: e => skipOpen.v ? skipOpen.v = false : onToggle(true, e) 13 | }; 14 | }; 15 | 16 | export { useClick }; 17 | -------------------------------------------------------------------------------- /example/src/styles/_hash-heading.scss: -------------------------------------------------------------------------------- 1 | @use 'mixins'; 2 | 3 | #content .hash-heading { 4 | display: flex; 5 | align-items: flex-end; 6 | margin-top: -4.5rem; 7 | width: max-content; 8 | 9 | &__heading { 10 | padding: 4.5rem 0.5rem 0 0; 11 | position: relative; 12 | z-index: -1; 13 | } 14 | 15 | &__link { 16 | opacity: 0; 17 | @include mixins.transition(opacity, 0.15s); 18 | line-height: 1.2; 19 | margin-bottom: 0.25rem; 20 | &--hover, 21 | &:focus { 22 | opacity: 1; 23 | text-decoration: none; 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/hooks/useClick.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | const useClick = (state, onToggle) => { 4 | if (process.env.NODE_ENV !== 'production' && typeof onToggle !== 'function') { 5 | throw new Error('[React-Menu] useClick/useHover requires a function in the second parameter.'); 6 | } 7 | 8 | const [skipOpen] = useState({}); 9 | 10 | return { 11 | onMouseDown: () => { 12 | skipOpen.v = state && state !== 'closed'; 13 | }, 14 | onClick: (e) => (skipOpen.v ? (skipOpen.v = false) : onToggle(true, e)) 15 | }; 16 | }; 17 | 18 | export { useClick }; 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/---questions-and-help.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F914 Questions and Help" 3 | about: Ask questions or request support 4 | title: '' 5 | labels: question 6 | assignees: '' 7 | --- 8 | 9 | 13 | 14 | React/React-dom version: 15 | React-Menu version: 16 | 17 | 21 | -------------------------------------------------------------------------------- /dist/cjs/hooks/useClick.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var react = require('react'); 4 | 5 | const useClick = (state, onToggle) => { 6 | if (process.env.NODE_ENV !== 'production' && typeof onToggle !== 'function') { 7 | throw new Error('[React-Menu] useClick/useHover requires a function in the second parameter.'); 8 | } 9 | const [skipOpen] = react.useState({}); 10 | return { 11 | onMouseDown: () => { 12 | skipOpen.v = state && state !== 'closed'; 13 | }, 14 | onClick: e => skipOpen.v ? skipOpen.v = false : onToggle(true, e) 15 | }; 16 | }; 17 | 18 | exports.useClick = useClick; 19 | -------------------------------------------------------------------------------- /dist/esm/hooks/useMenuStateAndFocus.mjs: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useMenuState } from './useMenuState.mjs'; 3 | 4 | const useMenuStateAndFocus = options => { 5 | const [menuProps, toggleMenu] = useMenuState(options); 6 | const [menuItemFocus, setMenuItemFocus] = useState(); 7 | const openMenu = (position, alwaysUpdate) => { 8 | setMenuItemFocus({ 9 | position, 10 | alwaysUpdate 11 | }); 12 | toggleMenu(true); 13 | }; 14 | return [{ 15 | menuItemFocus, 16 | ...menuProps 17 | }, toggleMenu, openMenu]; 18 | }; 19 | 20 | export { useMenuStateAndFocus }; 21 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@szhsin/react-menu-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "homepage": ".", 6 | "dependencies": { 7 | "@szhsin/react-menu": "file:..", 8 | "next": "^16.0.10", 9 | "prism-react-renderer": "^2.4.1", 10 | "react": "file:../node_modules/react", 11 | "react-dom": "file:../node_modules/react-dom", 12 | "reactish-state": "^2.0.0" 13 | }, 14 | "devDependencies": { 15 | "eslint-config-next": "^16.0.10" 16 | }, 17 | "scripts": { 18 | "dev": "next dev", 19 | "build": "next build", 20 | "start": "next start" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/hooks/useCombinedRef.js: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | 3 | // Adapted from material-ui 4 | // https://github.com/mui-org/material-ui/blob/f996027d00e7e4bff3fc040786c1706f9c6c3f82/packages/material-ui-utils/src/useForkRef.ts 5 | 6 | function setRef(ref, instance) { 7 | typeof ref === 'function' ? ref(instance) : (ref.current = instance); 8 | } 9 | 10 | export const useCombinedRef = (refA, refB) => 11 | useMemo(() => { 12 | if (!refA) return refB; 13 | if (!refB) return refA; 14 | 15 | return (instance) => { 16 | setRef(refA, instance); 17 | setRef(refB, instance); 18 | }; 19 | }, [refA, refB]); 20 | -------------------------------------------------------------------------------- /example/src/components/PromoSpot.js: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import { bem } from '../utils'; 3 | 4 | const blockName = 'promo-spot'; 5 | 6 | export const PromoSpot = memo(function PromoSpot({ label, title, desc, link }) { 7 | const modifier = { ...(label && { [label.toLowerCase()]: true }) }; 8 | return ( 9 | 15 | {title} 16 |
    {desc}
    17 |
    18 | ); 19 | }); 20 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { useClick, useHover, useMenuState } from './hooks'; 2 | export { MenuButton } from './components/MenuButton'; 3 | export { Menu } from './components/Menu'; 4 | export { ControlledMenu } from './components/ControlledMenu'; 5 | export { SubMenu } from './components/SubMenu'; 6 | export { MenuItem } from './components/MenuItem'; 7 | export { FocusableItem } from './components/FocusableItem'; 8 | export { MenuDivider } from './components/MenuDivider'; 9 | export { MenuHeader } from './components/MenuHeader'; 10 | export { MenuGroup } from './components/MenuGroup'; 11 | export { MenuRadioGroup } from './components/MenuRadioGroup'; 12 | -------------------------------------------------------------------------------- /src/hooks/useItemEffect.js: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect } from './useIsomorphicLayoutEffect'; 2 | 3 | export const useItemEffect = (isDisabled, itemRef, updateItems) => { 4 | useLayoutEffect(() => { 5 | if (process.env.NODE_ENV !== 'production' && !updateItems) { 6 | throw new Error( 7 | `[React-Menu] This menu item or submenu should be rendered under a menu: ${itemRef.current.outerHTML}` 8 | ); 9 | } 10 | if (isDisabled) return; 11 | const item = itemRef.current; 12 | updateItems(item, true); 13 | return () => { 14 | updateItems(item); 15 | }; 16 | }, [isDisabled, itemRef, updateItems]); 17 | }; 18 | -------------------------------------------------------------------------------- /example/gh-pages.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | check_str=$(pwd | grep "/example") 6 | if [ -z "$check_str" ]; then 7 | echo "Not in /example" 8 | exit 1 9 | fi 10 | 11 | rm -Rf out/ 12 | npm run build 13 | 14 | tmpdir="$HOME/gh-pages" 15 | rm -Rf "$tmpdir" 16 | mkdir "$tmpdir" 17 | mv out "$tmpdir" 18 | cd .. 19 | 20 | git checkout gh-pages 21 | check_str=$(git branch | grep "*" | grep "gh-pages") 22 | if [ -z "$check_str" ]; then 23 | echo "Not on branch gh-pages" 24 | exit 1 25 | fi 26 | 27 | rm -Rf _next 28 | cp -Rf "$tmpdir/out/" . 29 | git add . 30 | git commit -m "Updates" 31 | rm -Rf "$tmpdir" 32 | echo "Ready to push gh-pages" 33 | -------------------------------------------------------------------------------- /dist/cjs/hooks/useMenuStateAndFocus.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var react = require('react'); 4 | var useMenuState = require('./useMenuState.cjs'); 5 | 6 | const useMenuStateAndFocus = options => { 7 | const [menuProps, toggleMenu] = useMenuState.useMenuState(options); 8 | const [menuItemFocus, setMenuItemFocus] = react.useState(); 9 | const openMenu = (position, alwaysUpdate) => { 10 | setMenuItemFocus({ 11 | position, 12 | alwaysUpdate 13 | }); 14 | toggleMenu(true); 15 | }; 16 | return [{ 17 | menuItemFocus, 18 | ...menuProps 19 | }, toggleMenu, openMenu]; 20 | }; 21 | 22 | exports.useMenuStateAndFocus = useMenuStateAndFocus; 23 | -------------------------------------------------------------------------------- /dist/esm/utils/submenuCtx.mjs: -------------------------------------------------------------------------------- 1 | const createSubmenuCtx = () => { 2 | let timer, 3 | count = 0; 4 | return { 5 | toggle: isOpen => { 6 | isOpen ? count++ : count--; 7 | count = Math.max(count, 0); 8 | }, 9 | on: (closeDelay, pending, settled) => { 10 | if (count) { 11 | if (!timer) timer = setTimeout(() => { 12 | timer = 0; 13 | pending(); 14 | }, closeDelay); 15 | } else { 16 | settled?.(); 17 | } 18 | }, 19 | off: () => { 20 | if (timer) { 21 | clearTimeout(timer); 22 | timer = 0; 23 | } 24 | } 25 | }; 26 | }; 27 | 28 | export { createSubmenuCtx }; 29 | -------------------------------------------------------------------------------- /src/utils/withHovering.js: -------------------------------------------------------------------------------- 1 | import { memo, forwardRef, useContext, useRef } from 'react'; 2 | import { HoverItemContext } from './constants'; 3 | 4 | export const withHovering = (name, WrappedComponent) => { 5 | const Component = memo(WrappedComponent); 6 | const WithHovering = forwardRef((props, ref) => { 7 | const itemRef = useRef(null); 8 | return ( 9 | 15 | ); 16 | }); 17 | 18 | WithHovering.displayName = `WithHovering(${name})`; 19 | 20 | return WithHovering; 21 | }; 22 | -------------------------------------------------------------------------------- /dist/esm/components/MenuDivider.mjs: -------------------------------------------------------------------------------- 1 | import { memo, forwardRef } from 'react'; 2 | import { jsx } from 'react/jsx-runtime'; 3 | import { useBEM } from '../hooks/useBEM.mjs'; 4 | import { menuClass, menuDividerClass } from '../utils/constants.mjs'; 5 | 6 | const MenuDivider = /*#__PURE__*/memo(/*#__PURE__*/forwardRef(function MenuDivider({ 7 | className, 8 | ...restProps 9 | }, externalRef) { 10 | return /*#__PURE__*/jsx("li", { 11 | role: "separator", 12 | ...restProps, 13 | ref: externalRef, 14 | className: useBEM({ 15 | block: menuClass, 16 | element: menuDividerClass, 17 | className 18 | }) 19 | }); 20 | })); 21 | 22 | export { MenuDivider }; 23 | -------------------------------------------------------------------------------- /dist/esm/components/MenuHeader.mjs: -------------------------------------------------------------------------------- 1 | import { memo, forwardRef } from 'react'; 2 | import { jsx } from 'react/jsx-runtime'; 3 | import { useBEM } from '../hooks/useBEM.mjs'; 4 | import { roleNone, menuClass, menuHeaderClass } from '../utils/constants.mjs'; 5 | 6 | const MenuHeader = /*#__PURE__*/memo(/*#__PURE__*/forwardRef(function MenuHeader({ 7 | className, 8 | ...restProps 9 | }, externalRef) { 10 | return /*#__PURE__*/jsx("li", { 11 | role: roleNone, 12 | ...restProps, 13 | ref: externalRef, 14 | className: useBEM({ 15 | block: menuClass, 16 | element: menuHeaderClass, 17 | className 18 | }) 19 | }); 20 | })); 21 | 22 | export { MenuHeader }; 23 | -------------------------------------------------------------------------------- /src/utils/submenuCtx.js: -------------------------------------------------------------------------------- 1 | const createSubmenuCtx = () => { 2 | let timer, 3 | count = 0; 4 | return { 5 | toggle: (isOpen) => { 6 | isOpen ? count++ : count--; 7 | count = Math.max(count, 0); 8 | }, 9 | on: (closeDelay, pending, settled) => { 10 | if (count) { 11 | if (!timer) 12 | timer = setTimeout(() => { 13 | timer = 0; 14 | pending(); 15 | }, closeDelay); 16 | } else { 17 | settled?.(); 18 | } 19 | }, 20 | off: () => { 21 | if (timer) { 22 | clearTimeout(timer); 23 | timer = 0; 24 | } 25 | } 26 | }; 27 | }; 28 | 29 | export { createSubmenuCtx }; 30 | -------------------------------------------------------------------------------- /dist/esm/hooks/useItemEffect.mjs: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect as useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect.mjs'; 2 | 3 | const useItemEffect = (isDisabled, itemRef, updateItems) => { 4 | useIsomorphicLayoutEffect(() => { 5 | if (process.env.NODE_ENV !== 'production' && !updateItems) { 6 | throw new Error(`[React-Menu] This menu item or submenu should be rendered under a menu: ${itemRef.current.outerHTML}`); 7 | } 8 | if (isDisabled) return; 9 | const item = itemRef.current; 10 | updateItems(item, true); 11 | return () => { 12 | updateItems(item); 13 | }; 14 | }, [isDisabled, itemRef, updateItems]); 15 | }; 16 | 17 | export { useItemEffect }; 18 | -------------------------------------------------------------------------------- /example/src/components/HeaderBanner.js: -------------------------------------------------------------------------------- 1 | import { useDomInfo } from '../store'; 2 | import { version } from '../utils'; 3 | 4 | export function HeaderBanner({ onClose }) { 5 | const isFullSize = useDomInfo().vWidth > 700; 6 | 7 | return ( 8 |
    9 | {isFullSize && This website is for React-Menu v{version}} 10 | 11 | {isFullSize ? 'You can find the latest version here.' : 'Visit the latest version'} 12 | 13 | 14 | close 15 | 16 |
    17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /example/src/pages/_app.js: -------------------------------------------------------------------------------- 1 | import '@szhsin/react-menu/dist/index.css'; 2 | import '@szhsin/react-menu/dist/theme-dark.css'; 3 | import '@szhsin/react-menu/dist/transitions/zoom.css'; 4 | import '../styles/index.scss'; 5 | import Head from 'next/head'; 6 | import App from '../components/App'; 7 | 8 | function MyApp({ Component, pageProps }) { 9 | return ( 10 | <> 11 | 12 | React menu library - szhsin/react-menu 13 | 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | } 21 | 22 | export default MyApp; 23 | -------------------------------------------------------------------------------- /example/src/components/PageView.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { TableContents } from './TableContents'; 3 | import { CascadingContents } from './CascadingContents'; 4 | import { RightSection } from './RightSection'; 5 | 6 | export const PageView = React.memo(function PageView({ id, data, showRightSection }) { 7 | return ( 8 | <> 9 | {data} 10 | 11 |
    12 |
    13 | {data.map((c) => ( 14 | 15 | ))} 16 |
    17 |
    18 | 19 | {showRightSection && } 20 | 21 | ); 22 | }); 23 | -------------------------------------------------------------------------------- /example/src/styles/_prism.scss: -------------------------------------------------------------------------------- 1 | @use 'var'; 2 | @use 'mixins'; 3 | 4 | .prism-code { 5 | @include mixins.scrollbar(10px, height); 6 | background-color: #f2f2f3 !important; 7 | display: block; 8 | overflow-x: auto; 9 | font-size: 90%; 10 | color: inherit; 11 | border-radius: var.$border-radius-medium; 12 | padding: 1rem 1.25rem; 13 | } 14 | 15 | .szh-app--theme-dark .prism-code { 16 | @include mixins.scrollbar-dark; 17 | background-color: var.$block-dark !important; 18 | } 19 | 20 | .szh-app--theme-light .prism-code .token { 21 | &.comment { 22 | color: #028000 !important; 23 | font-style: normal !important; 24 | } 25 | &.attr-name { 26 | color: #235dbe !important; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /dist/cjs/utils/submenuCtx.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const createSubmenuCtx = () => { 4 | let timer, 5 | count = 0; 6 | return { 7 | toggle: isOpen => { 8 | isOpen ? count++ : count--; 9 | count = Math.max(count, 0); 10 | }, 11 | on: (closeDelay, pending, settled) => { 12 | if (count) { 13 | if (!timer) timer = setTimeout(() => { 14 | timer = 0; 15 | pending(); 16 | }, closeDelay); 17 | } else { 18 | settled?.(); 19 | } 20 | }, 21 | off: () => { 22 | if (timer) { 23 | clearTimeout(timer); 24 | timer = 0; 25 | } 26 | } 27 | }; 28 | }; 29 | 30 | exports.createSubmenuCtx = createSubmenuCtx; 31 | -------------------------------------------------------------------------------- /dist/cjs/hooks/useItemEffect.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var useIsomorphicLayoutEffect = require('./useIsomorphicLayoutEffect.cjs'); 4 | 5 | const useItemEffect = (isDisabled, itemRef, updateItems) => { 6 | useIsomorphicLayoutEffect.useLayoutEffect(() => { 7 | if (process.env.NODE_ENV !== 'production' && !updateItems) { 8 | throw new Error(`[React-Menu] This menu item or submenu should be rendered under a menu: ${itemRef.current.outerHTML}`); 9 | } 10 | if (isDisabled) return; 11 | const item = itemRef.current; 12 | updateItems(item, true); 13 | return () => { 14 | updateItems(item); 15 | }; 16 | }, [isDisabled, itemRef, updateItems]); 17 | }; 18 | 19 | exports.useItemEffect = useItemEffect; 20 | -------------------------------------------------------------------------------- /src/hooks/useIsomorphicLayoutEffect.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useLayoutEffect } from 'react'; 2 | 3 | // Get around a warning when using useLayoutEffect on the server. 4 | // https://github.com/reduxjs/react-redux/blob/b48d087d76f666e1c6c5a9713bbec112a1631841/src/utils/useIsomorphicLayoutEffect.js#L12 5 | // https://gist.github.com/gaearon/e7d97cdf38a2907924ea12e4ebdf3c85 6 | // https://github.com/facebook/react/issues/14927#issuecomment-549457471 7 | 8 | const useIsomorphicLayoutEffect = 9 | typeof window !== 'undefined' && 10 | typeof window.document !== 'undefined' && 11 | typeof window.document.createElement !== 'undefined' 12 | ? useLayoutEffect 13 | : useEffect; 14 | 15 | export { useIsomorphicLayoutEffect as useLayoutEffect }; 16 | -------------------------------------------------------------------------------- /dist/cjs/components/MenuDivider.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var react = require('react'); 4 | var jsxRuntime = require('react/jsx-runtime'); 5 | var useBEM = require('../hooks/useBEM.cjs'); 6 | var constants = require('../utils/constants.cjs'); 7 | 8 | const MenuDivider = /*#__PURE__*/react.memo(/*#__PURE__*/react.forwardRef(function MenuDivider({ 9 | className, 10 | ...restProps 11 | }, externalRef) { 12 | return /*#__PURE__*/jsxRuntime.jsx("li", { 13 | role: "separator", 14 | ...restProps, 15 | ref: externalRef, 16 | className: useBEM.useBEM({ 17 | block: constants.menuClass, 18 | element: constants.menuDividerClass, 19 | className 20 | }) 21 | }); 22 | })); 23 | 24 | exports.MenuDivider = MenuDivider; 25 | -------------------------------------------------------------------------------- /dist/cjs/components/MenuHeader.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var react = require('react'); 4 | var jsxRuntime = require('react/jsx-runtime'); 5 | var useBEM = require('../hooks/useBEM.cjs'); 6 | var constants = require('../utils/constants.cjs'); 7 | 8 | const MenuHeader = /*#__PURE__*/react.memo(/*#__PURE__*/react.forwardRef(function MenuHeader({ 9 | className, 10 | ...restProps 11 | }, externalRef) { 12 | return /*#__PURE__*/jsxRuntime.jsx("li", { 13 | role: constants.roleNone, 14 | ...restProps, 15 | ref: externalRef, 16 | className: useBEM.useBEM({ 17 | block: constants.menuClass, 18 | element: constants.menuHeaderClass, 19 | className 20 | }) 21 | }); 22 | })); 23 | 24 | exports.MenuHeader = MenuHeader; 25 | -------------------------------------------------------------------------------- /dist/theme-dark.css: -------------------------------------------------------------------------------- 1 | .szh-menu-container--theme-dark .szh-menu { 2 | color: #cad1d8; 3 | background-color: #22262c; 4 | border: 1px solid #333; 5 | box-shadow: 0 2px 9px 3px rgba(0, 0, 0, 0.25); 6 | } 7 | .szh-menu-container--theme-dark .szh-menu__arrow { 8 | background-color: #22262c; 9 | border-left-color: #333; 10 | border-top-color: #333; 11 | } 12 | .szh-menu-container--theme-dark .szh-menu__item--hover { 13 | background-color: #31363c; 14 | } 15 | .szh-menu-container--theme-dark .szh-menu__item--focusable { 16 | background-color: inherit; 17 | } 18 | .szh-menu-container--theme-dark .szh-menu__item--disabled { 19 | color: #666; 20 | } 21 | .szh-menu-container--theme-dark .szh-menu__divider { 22 | background-color: #3a3a3a; 23 | } 24 | -------------------------------------------------------------------------------- /dist/esm/utils/withHovering.mjs: -------------------------------------------------------------------------------- 1 | import { forwardRef, useRef, memo, useContext } from 'react'; 2 | import { HoverItemContext } from './constants.mjs'; 3 | import { jsx } from 'react/jsx-runtime'; 4 | 5 | const withHovering = (name, WrappedComponent) => { 6 | const Component = /*#__PURE__*/memo(WrappedComponent); 7 | const WithHovering = /*#__PURE__*/forwardRef((props, ref) => { 8 | const itemRef = useRef(null); 9 | return /*#__PURE__*/jsx(Component, { 10 | ...props, 11 | itemRef: itemRef, 12 | externalRef: ref, 13 | isHovering: useContext(HoverItemContext) === itemRef.current 14 | }); 15 | }); 16 | WithHovering.displayName = `WithHovering(${name})`; 17 | return WithHovering; 18 | }; 19 | 20 | export { withHovering }; 21 | -------------------------------------------------------------------------------- /src/hooks/useHover.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useClick } from './useClick'; 3 | 4 | const useHover = (isOpen, onToggle, { openDelay = 100, closeDelay = 300 } = {}) => { 5 | const [config] = useState({}); 6 | 7 | const clearTimer = () => clearTimeout(config.t); 8 | const delayAction = (toOpen) => (e) => { 9 | clearTimer(); 10 | config.t = setTimeout(() => onToggle(toOpen, e), toOpen ? openDelay : closeDelay); 11 | }; 12 | const props = { 13 | onMouseEnter: delayAction(true), 14 | onMouseLeave: delayAction(false) 15 | }; 16 | 17 | return { 18 | anchorProps: { ...props, ...useClick(isOpen, onToggle) }, 19 | hoverProps: { ...props, onMouseEnter: clearTimer } 20 | }; 21 | }; 22 | 23 | export { useHover }; 24 | -------------------------------------------------------------------------------- /dist/cjs/utils/withHovering.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var react = require('react'); 4 | var constants = require('./constants.cjs'); 5 | var jsxRuntime = require('react/jsx-runtime'); 6 | 7 | const withHovering = (name, WrappedComponent) => { 8 | const Component = /*#__PURE__*/react.memo(WrappedComponent); 9 | const WithHovering = /*#__PURE__*/react.forwardRef((props, ref) => { 10 | const itemRef = react.useRef(null); 11 | return /*#__PURE__*/jsxRuntime.jsx(Component, { 12 | ...props, 13 | itemRef: itemRef, 14 | externalRef: ref, 15 | isHovering: react.useContext(constants.HoverItemContext) === itemRef.current 16 | }); 17 | }); 18 | WithHovering.displayName = `WithHovering(${name})`; 19 | return WithHovering; 20 | }; 21 | 22 | exports.withHovering = withHovering; 23 | -------------------------------------------------------------------------------- /dist/esm/index.mjs: -------------------------------------------------------------------------------- 1 | 2 | 'use client'; 3 | export { MenuButton } from './components/MenuButton.mjs'; 4 | export { Menu } from './components/Menu.mjs'; 5 | export { ControlledMenu } from './components/ControlledMenu.mjs'; 6 | export { SubMenu } from './components/SubMenu.mjs'; 7 | export { MenuItem } from './components/MenuItem.mjs'; 8 | export { FocusableItem } from './components/FocusableItem.mjs'; 9 | export { MenuDivider } from './components/MenuDivider.mjs'; 10 | export { MenuHeader } from './components/MenuHeader.mjs'; 11 | export { MenuGroup } from './components/MenuGroup.mjs'; 12 | export { MenuRadioGroup } from './components/MenuRadioGroup.mjs'; 13 | export { useClick } from './hooks/useClick.mjs'; 14 | export { useHover } from './hooks/useHover.mjs'; 15 | export { useMenuState } from './hooks/useMenuState.mjs'; 16 | -------------------------------------------------------------------------------- /src/styles/theme-dark.scss: -------------------------------------------------------------------------------- 1 | @use 'var'; 2 | 3 | .szh-menu-container--theme-dark .szh-menu { 4 | color: var.$color-dark; 5 | background-color: var.$background-color-dark; 6 | border: 1px solid var.$border-color-dark; 7 | box-shadow: 0 2px 9px 3px rgba(0, 0, 0, 0.25); 8 | 9 | &__arrow { 10 | background-color: var.$background-color-dark; 11 | border-left-color: var.$border-color-dark; 12 | border-top-color: var.$border-color-dark; 13 | } 14 | 15 | &__item { 16 | &--hover { 17 | background-color: var.$background-color-hover-dark; 18 | } 19 | &--focusable { 20 | background-color: inherit; 21 | } 22 | &--disabled { 23 | color: var.$color-disabled-dark; 24 | } 25 | } 26 | 27 | &__divider { 28 | background-color: var.$divider-color-dark; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: 'npm' 9 | directory: '/' 10 | versioning-strategy: increase 11 | schedule: 12 | interval: 'daily' 13 | groups: 14 | all-dependencies: 15 | patterns: ['*'] 16 | - package-ecosystem: 'npm' 17 | directory: '/example' 18 | versioning-strategy: increase 19 | schedule: 20 | interval: 'weekly' 21 | groups: 22 | all-dependencies: 23 | patterns: ['*'] 24 | -------------------------------------------------------------------------------- /src/components/MenuButton.js: -------------------------------------------------------------------------------- 1 | import { forwardRef, useMemo } from 'react'; 2 | import { useBEM } from '../hooks'; 3 | import { defineName, menuButtonClass } from '../utils'; 4 | 5 | export const MenuButton = defineName( 6 | 'MenuButton', 7 | forwardRef(function MenuButton({ className, isOpen, disabled, children, ...restProps }, ref) { 8 | const modifiers = useMemo(() => ({ open: isOpen }), [isOpen]); 9 | 10 | return ( 11 | 23 | ); 24 | }) 25 | ); 26 | -------------------------------------------------------------------------------- /dist/esm/hooks/useHover.mjs: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useClick } from './useClick.mjs'; 3 | 4 | const useHover = (isOpen, onToggle, { 5 | openDelay = 100, 6 | closeDelay = 300 7 | } = {}) => { 8 | const [config] = useState({}); 9 | const clearTimer = () => clearTimeout(config.t); 10 | const delayAction = toOpen => e => { 11 | clearTimer(); 12 | config.t = setTimeout(() => onToggle(toOpen, e), toOpen ? openDelay : closeDelay); 13 | }; 14 | const props = { 15 | onMouseEnter: delayAction(true), 16 | onMouseLeave: delayAction(false) 17 | }; 18 | return { 19 | anchorProps: { 20 | ...props, 21 | ...useClick(isOpen, onToggle) 22 | }, 23 | hoverProps: { 24 | ...props, 25 | onMouseEnter: clearTimer 26 | } 27 | }; 28 | }; 29 | 30 | export { useHover }; 31 | -------------------------------------------------------------------------------- /dist/esm/hooks/useBEM.mjs: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | 3 | const useBEM = ({ 4 | block, 5 | element, 6 | modifiers, 7 | className 8 | }) => useMemo(() => { 9 | const blockElement = element ? `${block}__${element}` : block; 10 | let classString = blockElement; 11 | modifiers && Object.keys(modifiers).forEach(name => { 12 | const value = modifiers[name]; 13 | if (value) classString += ` ${blockElement}--${value === true ? name : `${name}-${value}`}`; 14 | }); 15 | let expandedClassName = typeof className === 'function' ? className(modifiers) : className; 16 | if (typeof expandedClassName === 'string') { 17 | expandedClassName = expandedClassName.trim(); 18 | if (expandedClassName) classString += ` ${expandedClassName}`; 19 | } 20 | return classString; 21 | }, [block, element, modifiers, className]); 22 | 23 | export { useBEM }; 24 | -------------------------------------------------------------------------------- /dist/cjs/hooks/useHover.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var react = require('react'); 4 | var useClick = require('./useClick.cjs'); 5 | 6 | const useHover = (isOpen, onToggle, { 7 | openDelay = 100, 8 | closeDelay = 300 9 | } = {}) => { 10 | const [config] = react.useState({}); 11 | const clearTimer = () => clearTimeout(config.t); 12 | const delayAction = toOpen => e => { 13 | clearTimer(); 14 | config.t = setTimeout(() => onToggle(toOpen, e), toOpen ? openDelay : closeDelay); 15 | }; 16 | const props = { 17 | onMouseEnter: delayAction(true), 18 | onMouseLeave: delayAction(false) 19 | }; 20 | return { 21 | anchorProps: { 22 | ...props, 23 | ...useClick.useClick(isOpen, onToggle) 24 | }, 25 | hoverProps: { 26 | ...props, 27 | onMouseEnter: clearTimer 28 | } 29 | }; 30 | }; 31 | 32 | exports.useHover = useHover; 33 | -------------------------------------------------------------------------------- /example/src/components/CascadingContents.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { HashHeading } from './HashHeading'; 3 | import { Table } from './Table'; 4 | 5 | export const CascadingContents = React.memo(function CascadingContents({ 6 | id, 7 | title, 8 | contents, 9 | list, 10 | level = 1, 11 | sectioning = true 12 | }) { 13 | return React.createElement( 14 | sectioning ? 'section' : React.Fragment, 15 | sectioning ? { 'aria-labelledby': id } : undefined, 16 | , 17 | contents && 18 | contents.map((c, i) => 19 | c.contentType === 'table' ? ( 20 | 21 | ) : ( 22 | {c} 23 | ) 24 | ), 25 | list && list.map((c) => ) 26 | ); 27 | }); 28 | -------------------------------------------------------------------------------- /dist/cjs/hooks/useBEM.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var react = require('react'); 4 | 5 | const useBEM = ({ 6 | block, 7 | element, 8 | modifiers, 9 | className 10 | }) => react.useMemo(() => { 11 | const blockElement = element ? `${block}__${element}` : block; 12 | let classString = blockElement; 13 | modifiers && Object.keys(modifiers).forEach(name => { 14 | const value = modifiers[name]; 15 | if (value) classString += ` ${blockElement}--${value === true ? name : `${name}-${value}`}`; 16 | }); 17 | let expandedClassName = typeof className === 'function' ? className(modifiers) : className; 18 | if (typeof expandedClassName === 'string') { 19 | expandedClassName = expandedClassName.trim(); 20 | if (expandedClassName) classString += ` ${expandedClassName}`; 21 | } 22 | return classString; 23 | }, [block, element, modifiers, className]); 24 | 25 | exports.useBEM = useBEM; 26 | -------------------------------------------------------------------------------- /example/src/components/TableContentsList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Link from 'next/link'; 4 | import { Promo } from './Promo'; 5 | 6 | export const TableContentsList = React.memo(function TableContentsList({ 7 | list, 8 | level = 1, 9 | maxHeight 10 | }) { 11 | const listElt = list.map((item) => { 12 | let nested = null; 13 | if (item.list) { 14 | nested = ; 15 | } 16 | 17 | return ( 18 |
  • 19 | {item.title} 20 | {nested} 21 |
  • 22 | ); 23 | }); 24 | 25 | return ( 26 |
      27 | {listElt} 28 | {level === 1 && } 29 |
    30 | ); 31 | }); 32 | 33 | TableContentsList.propTypes = { 34 | list: PropTypes.array.isRequired, 35 | level: PropTypes.number 36 | }; 37 | -------------------------------------------------------------------------------- /src/components/MenuRadioGroup.js: -------------------------------------------------------------------------------- 1 | import { forwardRef, useMemo } from 'react'; 2 | import { useBEM } from '../hooks'; 3 | import { menuClass, radioGroupClass, RadioGroupContext, roleNone } from '../utils'; 4 | 5 | export const MenuRadioGroup = forwardRef(function MenuRadioGroup( 6 | { 'aria-label': ariaLabel, className, name, value, onRadioChange, ...restProps }, 7 | externalRef 8 | ) { 9 | const contextValue = useMemo( 10 | () => ({ name, value, onRadioChange }), 11 | [name, value, onRadioChange] 12 | ); 13 | 14 | return ( 15 | 16 |
  • 17 |
      24 | 25 | 26 | ); 27 | }); 28 | -------------------------------------------------------------------------------- /example/src/components/Promo.js: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import { PromoSpot } from './PromoSpot'; 3 | 4 | export const Promo = memo(function Promo() { 5 | return ( 6 |
      7 | 12 | 17 | 22 | 27 |
      28 | ); 29 | }); 30 | -------------------------------------------------------------------------------- /dist/esm/components/MenuButton.mjs: -------------------------------------------------------------------------------- 1 | import { forwardRef, useMemo } from 'react'; 2 | import { jsx } from 'react/jsx-runtime'; 3 | import { defineName } from '../utils/utils.mjs'; 4 | import { useBEM } from '../hooks/useBEM.mjs'; 5 | import { menuButtonClass } from '../utils/constants.mjs'; 6 | 7 | const MenuButton = /*#__PURE__*/defineName('MenuButton', /*#__PURE__*/forwardRef(function MenuButton({ 8 | className, 9 | isOpen, 10 | disabled, 11 | children, 12 | ...restProps 13 | }, ref) { 14 | const modifiers = useMemo(() => ({ 15 | open: isOpen 16 | }), [isOpen]); 17 | return /*#__PURE__*/jsx("button", { 18 | "aria-haspopup": true, 19 | "aria-expanded": isOpen, 20 | "aria-disabled": disabled || undefined, 21 | type: "button", 22 | disabled: disabled, 23 | ...restProps, 24 | ref: ref, 25 | className: useBEM({ 26 | block: menuButtonClass, 27 | modifiers, 28 | className 29 | }), 30 | children: children 31 | }); 32 | })); 33 | 34 | export { MenuButton }; 35 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | targets: 'defaults', 3 | assumptions: { 4 | constantReexports: true, 5 | ignoreFunctionLength: true, 6 | ignoreToPrimitiveHint: true, 7 | iterableIsArray: true, 8 | noDocumentAll: true, 9 | noIncompleteNsImportDetection: true, 10 | noNewArrows: true, 11 | objectRestNoSymbols: true, 12 | pureGetters: true, 13 | setComputedProperties: true, 14 | setSpreadProperties: true, 15 | skipForOfIteratorClosing: true 16 | }, 17 | shouldPrintComment: (val) => /[@#]__PURE__/.test(val), 18 | plugins: ['pure-annotations'], 19 | presets: [ 20 | [ 21 | '@babel/preset-env', 22 | { 23 | bugfixes: true, 24 | include: [ 25 | '@babel/plugin-transform-nullish-coalescing-operator', 26 | '@babel/plugin-transform-optional-catch-binding' 27 | ], 28 | exclude: ['@babel/plugin-transform-typeof-symbol'] 29 | } 30 | ], 31 | ['@babel/preset-react', { runtime: 'automatic' }] 32 | ] 33 | }; 34 | -------------------------------------------------------------------------------- /src/hooks/useBEM.js: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | 3 | // Generate className following BEM methodology: http://getbem.com/naming/ 4 | // Modifier value can be one of the following types: boolean, string, undefined 5 | export const useBEM = ({ block, element, modifiers, className }) => 6 | useMemo(() => { 7 | const blockElement = element ? `${block}__${element}` : block; 8 | 9 | let classString = blockElement; 10 | modifiers && 11 | Object.keys(modifiers).forEach((name) => { 12 | const value = modifiers[name]; 13 | if (value) classString += ` ${blockElement}--${value === true ? name : `${name}-${value}`}`; 14 | }); 15 | 16 | let expandedClassName = typeof className === 'function' ? className(modifiers) : className; 17 | if (typeof expandedClassName === 'string') { 18 | expandedClassName = expandedClassName.trim(); 19 | if (expandedClassName) classString += ` ${expandedClassName}`; 20 | } 21 | 22 | return classString; 23 | }, [block, element, modifiers, className]); 24 | -------------------------------------------------------------------------------- /example/src/components/Table.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const Table = React.memo(function Table({ heading, sorting, head, rows }) { 4 | if (sorting) { 5 | rows.sort(({ [sorting.key]: r1 }, { [sorting.key]: r2 }) => { 6 | if (r1 < r2) { 7 | return -1; 8 | } 9 | if (r1 > r2) { 10 | return 1; 11 | } 12 | return 0; 13 | }); 14 | } 15 | 16 | return ( 17 | <> 18 | {heading} 19 |
  • 20 | 21 | 22 | {head.map(({ key, value }) => ( 23 | 26 | ))} 27 | 28 | 29 | 30 | {rows.map((row, i) => ( 31 | 32 | {head.map((th, i) => ( 33 | 34 | ))} 35 | 36 | ))} 37 | 38 |
    24 | {value} 25 |
    {row[th.key]}
    39 | 40 | ); 41 | }); 42 | -------------------------------------------------------------------------------- /example/src/pages/_document.js: -------------------------------------------------------------------------------- 1 | import Document, { Html, Head, Main, NextScript } from 'next/document'; 2 | import { basePath } from '../../next.config'; 3 | import { bem } from '../utils'; 4 | 5 | class MyDocument extends Document { 6 | render() { 7 | return ( 8 | 9 | 10 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
    21 | 22 | 23 | 24 | ); 25 | } 26 | } 27 | 28 | export default MyDocument; 29 | -------------------------------------------------------------------------------- /example/src/store.js: -------------------------------------------------------------------------------- 1 | import { state, useSnapshot } from 'reactish-state'; 2 | import { bem } from './utils'; 3 | 4 | export const showBannerState = state(false); 5 | export const isTocOpenState = state(false); 6 | export const toastState = state(null); 7 | 8 | export const domInfoState = state({}); 9 | export const useDomInfo = () => useSnapshot(domInfoState); 10 | 11 | export const themeState = state('dark'); 12 | export const useTheme = () => { 13 | const theme = useSnapshot(themeState); 14 | return { 15 | isDark: theme === 'dark', 16 | theme 17 | }; 18 | }; 19 | themeState.subscribe(() => { 20 | const theme = themeState.get(); 21 | document.body.className = bem('szh-app', null, { theme }); 22 | try { 23 | localStorage.setItem('theme', theme); 24 | } catch { 25 | // continue regardless of error 26 | } 27 | }); 28 | export const hydrate = () => { 29 | try { 30 | const theme = localStorage.getItem('theme'); 31 | if (theme === 'light') themeState.set(theme); 32 | } catch { 33 | // continue regardless of error 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /example/src/styles/_menu.scss: -------------------------------------------------------------------------------- 1 | @use 'var'; 2 | @use 'mixins'; 3 | 4 | .szh-menu__item { 5 | i { 6 | font-size: 1.25rem; 7 | margin-right: 0.5rem; 8 | } 9 | 10 | img { 11 | height: 3rem; 12 | margin-left: -0.5rem; 13 | margin-right: 0.8rem; 14 | } 15 | 16 | .md-48 { 17 | font-size: 3rem; 18 | } 19 | 20 | &:active:not(.szh-menu__item--focusable):not(.szh-menu__item--disabled) { 21 | color: #fff !important; 22 | background-color: var.$primary-light; 23 | } 24 | } 25 | 26 | .szh-menu { 27 | white-space: nowrap; 28 | } 29 | 30 | .szh-menu, 31 | .szh-menu__group { 32 | @include mixins.scrollbar; 33 | @media (max-width: var.$bp-sm) { 34 | &::-webkit-scrollbar { 35 | width: 3px; 36 | } 37 | } 38 | .szh-app--theme-dark & { 39 | @include mixins.scrollbar-dark; 40 | } 41 | } 42 | 43 | .szh-menu__group { 44 | margin: 0.5rem 0; 45 | @include mixins.border(border-top); 46 | @include mixins.border(border-bottom); 47 | .szh-app--theme-dark & { 48 | border-color: var.$border-dark; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /dist/cjs/components/MenuButton.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var react = require('react'); 4 | var jsxRuntime = require('react/jsx-runtime'); 5 | var utils = require('../utils/utils.cjs'); 6 | var useBEM = require('../hooks/useBEM.cjs'); 7 | var constants = require('../utils/constants.cjs'); 8 | 9 | const MenuButton = /*#__PURE__*/utils.defineName('MenuButton', /*#__PURE__*/react.forwardRef(function MenuButton({ 10 | className, 11 | isOpen, 12 | disabled, 13 | children, 14 | ...restProps 15 | }, ref) { 16 | const modifiers = react.useMemo(() => ({ 17 | open: isOpen 18 | }), [isOpen]); 19 | return /*#__PURE__*/jsxRuntime.jsx("button", { 20 | "aria-haspopup": true, 21 | "aria-expanded": isOpen, 22 | "aria-disabled": disabled || undefined, 23 | type: "button", 24 | disabled: disabled, 25 | ...restProps, 26 | ref: ref, 27 | className: useBEM.useBEM({ 28 | block: constants.menuButtonClass, 29 | modifiers, 30 | className 31 | }), 32 | children: children 33 | }); 34 | })); 35 | 36 | exports.MenuButton = MenuButton; 37 | -------------------------------------------------------------------------------- /example/src/components/HashHeading.js: -------------------------------------------------------------------------------- 1 | import { createElement, memo, useState, useRef } from 'react'; 2 | import Link from 'next/link'; 3 | import { bem, useLayoutEffect } from '../utils'; 4 | 5 | const blockName = 'hash-heading'; 6 | 7 | export const HashHeading = memo(function HashHeading({ id, title, heading = 'h1' }) { 8 | const ref = useRef(null); 9 | const [hover, setHover] = useState(false); 10 | const [fontSize, setFontSize] = useState(); 11 | 12 | useLayoutEffect(() => { 13 | setFontSize(getComputedStyle(ref.current).getPropertyValue('font-size')); 14 | }, []); 15 | 16 | return ( 17 |
    setHover(true)} 20 | onMouseLeave={() => setHover(false)} 21 | > 22 | {createElement( 23 | heading, 24 | { 25 | id, 26 | ref, 27 | className: bem(blockName, 'heading') 28 | }, 29 | title 30 | )} 31 | 32 | 33 | # 34 | 35 |
    36 | ); 37 | }); 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Zheng Song 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/styles/transitions/slide.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:list'; 2 | @use 'sass:map'; 3 | 4 | $menu: 'szh-menu'; 5 | $duration: 0.15s; 6 | $directions: ( 7 | 'left': 'X' 1, 8 | 'right': 'X' -1, 9 | 'top': 'Y' 1, 10 | 'bottom': 'Y' -1 11 | ); 12 | 13 | @mixin animation($name, $dir) { 14 | .#{$menu}--state-opening.#{$menu}--dir-#{$dir} { 15 | animation: #{$menu}-show-#{$name}-#{$dir} $duration ease-out; 16 | } 17 | 18 | .#{$menu}--state-closing.#{$menu}--dir-#{$dir} { 19 | animation: #{$menu}-hide-#{$name}-#{$dir} $duration ease-in forwards; 20 | } 21 | } 22 | 23 | @mixin slide-start($axis, $sign) { 24 | opacity: 0; 25 | transform: translate#{$axis }($sign * 0.75rem); 26 | } 27 | 28 | @each $dir, $value in $directions { 29 | $axis: list.nth($value, 1); 30 | $sign: list.nth($value, 2); 31 | 32 | @keyframes #{$menu}-show-slide-#{$dir} { 33 | from { 34 | @include slide-start($axis, $sign); 35 | } 36 | } 37 | 38 | @keyframes #{$menu}-hide-slide-#{$dir} { 39 | to { 40 | @include slide-start($axis, $sign); 41 | } 42 | } 43 | } 44 | 45 | @each $dir in map.keys($directions) { 46 | @include animation('slide', $dir); 47 | } 48 | -------------------------------------------------------------------------------- /src/hooks/useMenuState.js: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { useTransitionState } from 'react-transition-state'; 3 | import { MenuStateMap, getTransition } from '../utils'; 4 | 5 | export const useMenuState = ({ 6 | initialOpen, 7 | initialMounted, 8 | unmountOnClose, 9 | transition, 10 | transitionTimeout = 500, 11 | onMenuChange 12 | } = {}) => { 13 | const enter = getTransition(transition, 'open'); 14 | const exit = getTransition(transition, 'close'); 15 | const [{ status }, toggleMenu, endTransition] = useTransitionState({ 16 | initialEntered: initialOpen, 17 | mountOnEnter: !initialMounted, 18 | unmountOnExit: unmountOnClose, 19 | timeout: transitionTimeout, 20 | enter, 21 | exit, 22 | onStateChange: useCallback( 23 | ({ current: { isEnter, isResolved } }) => { 24 | if (!onMenuChange || (isEnter && enter && isResolved) || (!isEnter && exit && isResolved)) { 25 | return; 26 | } 27 | onMenuChange({ open: isEnter }); 28 | }, 29 | [onMenuChange, enter, exit] 30 | ) 31 | }); 32 | 33 | return [{ state: MenuStateMap[status], endTransition }, toggleMenu]; 34 | }; 35 | -------------------------------------------------------------------------------- /example/src/styles/_var.scss: -------------------------------------------------------------------------------- 1 | $border-radius-small: 0.125rem; 2 | $border-radius-medium: 0.25rem; 3 | $border-radius-large: 0.375rem; 4 | 5 | $focus-light: rgba(17, 113, 240, 0.5); 6 | $focus-dark: #69a6f8; 7 | 8 | $code-light: #ebebeb; 9 | $code-dark: #343a40; 10 | 11 | $btn-light: #fafbfc; 12 | $btn-light-lv2: #f3f4f5; 13 | $btn-light-lv3: #edeff1; 14 | $btn-dark: #22262c; 15 | $btn-dark-lv2: #2b3036; 16 | 17 | $primary-light: #007bff; 18 | $primary-light-lv2: #2767cf; 19 | $primary-light-lv3: #1d4d9b; 20 | $primary-dark: #69a6f8; 21 | $primary-dark-lv2: #3688f6; 22 | 23 | $heading-light: #505050; 24 | $heading-dark: #b0b0b0; 25 | 26 | $navbar-light: #f8f9fa; 27 | $navbar-dark: #1f2021; 28 | 29 | $border-light: #ddd; 30 | $border-dark: #333; 31 | 32 | $border-hover-light: #b1b1b1; 33 | $border-hover-dark: #858585; 34 | 35 | $secondary-light: #808080; 36 | $secondary-dark: #aaa; 37 | 38 | $background-dark: #18191a; 39 | $block-dark: #1d1e1f; 40 | $text-dark: #cad1d8; 41 | 42 | $alert-light: #fef7d6; 43 | $alert-dark: #605a3a; 44 | 45 | $highlight-color: rgba(255, 229, 100, 0.3); 46 | $mask-color: rgba(0, 0, 0, 0.5); 47 | 48 | $bp-lg: 1150px; 49 | $bp-md: 950px; 50 | $bp-sm: 600px; 51 | -------------------------------------------------------------------------------- /dist/esm/components/MenuRadioGroup.mjs: -------------------------------------------------------------------------------- 1 | import { forwardRef, useMemo } from 'react'; 2 | import { jsx } from 'react/jsx-runtime'; 3 | import { useBEM } from '../hooks/useBEM.mjs'; 4 | import { RadioGroupContext, roleNone, menuClass, radioGroupClass } from '../utils/constants.mjs'; 5 | 6 | const MenuRadioGroup = /*#__PURE__*/forwardRef(function MenuRadioGroup({ 7 | 'aria-label': ariaLabel, 8 | className, 9 | name, 10 | value, 11 | onRadioChange, 12 | ...restProps 13 | }, externalRef) { 14 | const contextValue = useMemo(() => ({ 15 | name, 16 | value, 17 | onRadioChange 18 | }), [name, value, onRadioChange]); 19 | return /*#__PURE__*/jsx(RadioGroupContext.Provider, { 20 | value: contextValue, 21 | children: /*#__PURE__*/jsx("li", { 22 | role: roleNone, 23 | children: /*#__PURE__*/jsx("ul", { 24 | role: "group", 25 | "aria-label": ariaLabel || name || 'Radio group', 26 | ...restProps, 27 | ref: externalRef, 28 | className: useBEM({ 29 | block: menuClass, 30 | element: radioGroupClass, 31 | className 32 | }) 33 | }) 34 | }) 35 | }); 36 | }); 37 | 38 | export { MenuRadioGroup }; 39 | -------------------------------------------------------------------------------- /src/__tests__/utils.test.js: -------------------------------------------------------------------------------- 1 | import { parsePadding, getTransition } from '../utils'; 2 | 3 | test('parsePadding', () => { 4 | const defaultPadding = { top: 0, right: 0, bottom: 0, left: 0 }; 5 | expect(parsePadding()).toEqual(defaultPadding); 6 | expect(parsePadding('')).toEqual(defaultPadding); 7 | expect(parsePadding('abcd')).toEqual(defaultPadding); 8 | expect(parsePadding('10')).toEqual({ top: 10, right: 10, bottom: 10, left: 10 }); 9 | expect(parsePadding('10 20')).toEqual({ top: 10, right: 20, bottom: 10, left: 20 }); 10 | expect(parsePadding('10 20 30')).toEqual({ top: 10, right: 20, bottom: 30, left: 20 }); 11 | expect(parsePadding('10 20 30 40')).toEqual({ top: 10, right: 20, bottom: 30, left: 40 }); 12 | expect(parsePadding(' 10px 20 30 40px 50 ')).toEqual({ 13 | top: 10, 14 | right: 20, 15 | bottom: 30, 16 | left: 40 17 | }); 18 | }); 19 | 20 | test('getTransition', () => { 21 | expect(getTransition(true, 'open')).toBe(true); 22 | expect(getTransition(undefined, 'open')).toBe(false); 23 | expect(getTransition({}, 'open')).toBe(false); 24 | expect(getTransition({ open: true }, 'open')).toBe(true); 25 | expect(getTransition({ open: false }, 'open')).toBe(false); 26 | }); 27 | -------------------------------------------------------------------------------- /dist/cjs/components/MenuRadioGroup.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var react = require('react'); 4 | var jsxRuntime = require('react/jsx-runtime'); 5 | var useBEM = require('../hooks/useBEM.cjs'); 6 | var constants = require('../utils/constants.cjs'); 7 | 8 | const MenuRadioGroup = /*#__PURE__*/react.forwardRef(function MenuRadioGroup({ 9 | 'aria-label': ariaLabel, 10 | className, 11 | name, 12 | value, 13 | onRadioChange, 14 | ...restProps 15 | }, externalRef) { 16 | const contextValue = react.useMemo(() => ({ 17 | name, 18 | value, 19 | onRadioChange 20 | }), [name, value, onRadioChange]); 21 | return /*#__PURE__*/jsxRuntime.jsx(constants.RadioGroupContext.Provider, { 22 | value: contextValue, 23 | children: /*#__PURE__*/jsxRuntime.jsx("li", { 24 | role: constants.roleNone, 25 | children: /*#__PURE__*/jsxRuntime.jsx("ul", { 26 | role: "group", 27 | "aria-label": ariaLabel || name || 'Radio group', 28 | ...restProps, 29 | ref: externalRef, 30 | className: useBEM.useBEM({ 31 | block: constants.menuClass, 32 | element: constants.radioGroupClass, 33 | className 34 | }) 35 | }) 36 | }) 37 | }); 38 | }); 39 | 40 | exports.MenuRadioGroup = MenuRadioGroup; 41 | -------------------------------------------------------------------------------- /docs/FAQs.md: -------------------------------------------------------------------------------- 1 | # FAQs 2 | 3 | ## How do I server-side render a menu and its items? 4 | 5 | By default a menu and its items don't get mounted into DOM until the menu has been opened for the first time. This means if you render a menu on the server side, there won't be any HTML string output. You can set the `initialMounted` prop to render a menu and all of its descendants before being opened. 6 | 7 | ```jsx 8 | 9 | ``` 10 | 11 | Or, when using `ControlledMenu` along with `useMenuState` 12 | 13 | ```js 14 | useMenuState({ initialMounted: true }); 15 | ``` 16 | 17 | ## How do I add icons/images to a submenu label? 18 | 19 | The `label` prop of a submenu accepts not only a plain string but any valid JSX element as well. 20 | 21 | ```jsx 22 | 25 | edit 26 | Edit 27 | 28 | 29 | } 30 | /> 31 | ``` 32 | 33 | ## How do I set additional attributes or event handlers to a submenu label? 34 | 35 | You can use the `itemProps` to pass additional props to a submenu label. 36 | 37 | ```jsx 38 | console.log('Clicked!') 43 | }} 44 | /> 45 | ``` 46 | -------------------------------------------------------------------------------- /dist/esm/hooks/useMenuState.mjs: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { useTransitionState } from 'react-transition-state'; 3 | import { getTransition } from '../utils/utils.mjs'; 4 | import { MenuStateMap } from '../utils/constants.mjs'; 5 | 6 | const useMenuState = ({ 7 | initialOpen, 8 | initialMounted, 9 | unmountOnClose, 10 | transition, 11 | transitionTimeout = 500, 12 | onMenuChange 13 | } = {}) => { 14 | const enter = getTransition(transition, 'open'); 15 | const exit = getTransition(transition, 'close'); 16 | const [{ 17 | status 18 | }, toggleMenu, endTransition] = useTransitionState({ 19 | initialEntered: initialOpen, 20 | mountOnEnter: !initialMounted, 21 | unmountOnExit: unmountOnClose, 22 | timeout: transitionTimeout, 23 | enter, 24 | exit, 25 | onStateChange: useCallback(({ 26 | current: { 27 | isEnter, 28 | isResolved 29 | } 30 | }) => { 31 | if (!onMenuChange || isEnter && enter && isResolved || !isEnter && exit && isResolved) { 32 | return; 33 | } 34 | onMenuChange({ 35 | open: isEnter 36 | }); 37 | }, [onMenuChange, enter, exit]) 38 | }); 39 | return [{ 40 | state: MenuStateMap[status], 41 | endTransition 42 | }, toggleMenu]; 43 | }; 44 | 45 | export { useMenuState }; 46 | -------------------------------------------------------------------------------- /src/components/MenuContainer.js: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { useBEM } from '../hooks'; 3 | import { 4 | menuContainerClass, 5 | mergeProps, 6 | safeCall, 7 | getTransition, 8 | CloseReason, 9 | Keys 10 | } from '../utils'; 11 | 12 | export const MenuContainer = ({ 13 | className, 14 | containerRef, 15 | containerProps, 16 | children, 17 | isOpen, 18 | theming, 19 | transition, 20 | onClose 21 | }) => { 22 | const itemTransition = getTransition(transition, 'item'); 23 | 24 | const onKeyDown = ({ key }) => { 25 | switch (key) { 26 | case Keys.ESC: 27 | safeCall(onClose, { key, reason: CloseReason.CANCEL }); 28 | break; 29 | } 30 | }; 31 | 32 | const onBlur = (e) => { 33 | if (isOpen && !e.currentTarget.contains(e.relatedTarget)) { 34 | safeCall(onClose, { reason: CloseReason.BLUR }); 35 | } 36 | }; 37 | 38 | return ( 39 |
    ({ theme: theming, itemTransition }), [theming, itemTransition]), 44 | className 45 | })} 46 | style={{ position: 'absolute', ...containerProps?.style }} 47 | ref={containerRef} 48 | > 49 | {children} 50 |
    51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /src/components/MenuGroup.js: -------------------------------------------------------------------------------- 1 | import { forwardRef, useContext, useRef, useState } from 'react'; 2 | import { useBEM, useLayoutEffect, useCombinedRef } from '../hooks'; 3 | import { menuClass, menuGroupClass, MenuListContext } from '../utils'; 4 | import { getNormalizedClientRect } from '../positionUtils'; 5 | 6 | export const MenuGroup = forwardRef(function MenuGroup( 7 | { className, style, takeOverflow, ...restProps }, 8 | externalRef 9 | ) { 10 | const ref = useRef(null); 11 | const [overflowStyle, setOverflowStyle] = useState(); 12 | const { overflow, overflowAmt } = useContext(MenuListContext); 13 | 14 | useLayoutEffect(() => { 15 | let maxHeight; 16 | if (takeOverflow && overflowAmt >= 0) { 17 | maxHeight = getNormalizedClientRect(ref.current).height - overflowAmt; 18 | if (maxHeight < 0) maxHeight = 0; 19 | } 20 | setOverflowStyle(maxHeight >= 0 ? { maxHeight, overflow } : undefined); 21 | }, [takeOverflow, overflow, overflowAmt]); 22 | 23 | useLayoutEffect(() => { 24 | if (overflowStyle) ref.current.scrollTop = 0; 25 | }, [overflowStyle]); 26 | 27 | return ( 28 |
    34 | ); 35 | }); 36 | -------------------------------------------------------------------------------- /src/styles/transitions/zoom.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:list'; 2 | @use 'sass:string'; 3 | 4 | $menu: 'szh-menu'; 5 | $duration: 0.125s; 6 | $scale: 0.95; 7 | 8 | $origins: ( 9 | ('left', 'start', 'right top'), 10 | ('left', 'center', 'right center'), 11 | ('left', 'end', 'right bottom'), 12 | ('right', 'start', 'left top'), 13 | ('right', 'center', 'left center'), 14 | ('right', 'end', 'left bottom'), 15 | ('top', 'start', 'left bottom'), 16 | ('top', 'center', 'center bottom'), 17 | ('top', 'end', 'right bottom'), 18 | ('bottom', 'start', 'left top'), 19 | ('bottom', 'center', 'center top'), 20 | ('bottom', 'end', 'right top') 21 | ); 22 | 23 | @mixin zoom { 24 | opacity: 0.1; 25 | transform: scale($scale); 26 | } 27 | 28 | @keyframes #{$menu}-show-zoom { 29 | from { 30 | @include zoom; 31 | } 32 | } 33 | 34 | @keyframes #{$menu}-hide-zoom { 35 | to { 36 | @include zoom; 37 | } 38 | } 39 | 40 | .#{$menu}--state-opening { 41 | animation: #{$menu}-show-zoom $duration ease-out; 42 | } 43 | 44 | .#{$menu}--state-closing { 45 | animation: #{$menu}-hide-zoom $duration ease-in forwards; 46 | } 47 | 48 | @each $item in $origins { 49 | $dir: list.nth($item, 1); 50 | $align: list.nth($item, 2); 51 | $origin: list.nth($item, 3); 52 | 53 | .#{$menu}--dir-#{$dir}.#{$menu}--align-#{$align} { 54 | transform-origin: string.unquote($origin); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /dist/cjs/hooks/useMenuState.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var react = require('react'); 4 | var reactTransitionState = require('react-transition-state'); 5 | var utils = require('../utils/utils.cjs'); 6 | var constants = require('../utils/constants.cjs'); 7 | 8 | const useMenuState = ({ 9 | initialOpen, 10 | initialMounted, 11 | unmountOnClose, 12 | transition, 13 | transitionTimeout = 500, 14 | onMenuChange 15 | } = {}) => { 16 | const enter = utils.getTransition(transition, 'open'); 17 | const exit = utils.getTransition(transition, 'close'); 18 | const [{ 19 | status 20 | }, toggleMenu, endTransition] = reactTransitionState.useTransitionState({ 21 | initialEntered: initialOpen, 22 | mountOnEnter: !initialMounted, 23 | unmountOnExit: unmountOnClose, 24 | timeout: transitionTimeout, 25 | enter, 26 | exit, 27 | onStateChange: react.useCallback(({ 28 | current: { 29 | isEnter, 30 | isResolved 31 | } 32 | }) => { 33 | if (!onMenuChange || isEnter && enter && isResolved || !isEnter && exit && isResolved) { 34 | return; 35 | } 36 | onMenuChange({ 37 | open: isEnter 38 | }); 39 | }, [onMenuChange, enter, exit]) 40 | }); 41 | return [{ 42 | state: constants.MenuStateMap[status], 43 | endTransition 44 | }, toggleMenu]; 45 | }; 46 | 47 | exports.useMenuState = useMenuState; 48 | -------------------------------------------------------------------------------- /example/src/utils/index.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useLayoutEffect } from 'react'; 2 | import { useTheme } from '../store'; 3 | 4 | export const version = '4.5.1'; 5 | export const build = '146'; 6 | 7 | export const bem = (block, element, modifiers = {}) => { 8 | let blockElement = element ? `${block}__${element}` : block; 9 | let className = blockElement; 10 | for (const name of Object.keys(modifiers)) { 11 | const value = modifiers[name]; 12 | if (value) { 13 | className += ` ${blockElement}--`; 14 | className += value === true ? name : `${name}-${value}`; 15 | } 16 | } 17 | 18 | return className; 19 | }; 20 | 21 | export const withPresetProps = (MenuComponent) => (props) => ( 22 | 23 | ); 24 | 25 | // Get around a warning when using useLayoutEffect on the server. 26 | // https://github.com/reduxjs/react-redux/blob/b48d087d76f666e1c6c5a9713bbec112a1631841/src/utils/useIsomorphicLayoutEffect.js#L12 27 | // https://gist.github.com/gaearon/e7d97cdf38a2907924ea12e4ebdf3c85 28 | // https://github.com/facebook/react/issues/14927#issuecomment-549457471 29 | const useIsomorphicLayoutEffect = 30 | typeof window !== 'undefined' && 31 | typeof window.document !== 'undefined' && 32 | typeof window.document.createElement !== 'undefined' 33 | ? useLayoutEffect 34 | : useEffect; 35 | 36 | export { useIsomorphicLayoutEffect as useLayoutEffect }; 37 | -------------------------------------------------------------------------------- /dist/cjs/index.cjs: -------------------------------------------------------------------------------- 1 | 2 | 'use client'; 3 | 'use strict'; 4 | 5 | var MenuButton = require('./components/MenuButton.cjs'); 6 | var Menu = require('./components/Menu.cjs'); 7 | var ControlledMenu = require('./components/ControlledMenu.cjs'); 8 | var SubMenu = require('./components/SubMenu.cjs'); 9 | var MenuItem = require('./components/MenuItem.cjs'); 10 | var FocusableItem = require('./components/FocusableItem.cjs'); 11 | var MenuDivider = require('./components/MenuDivider.cjs'); 12 | var MenuHeader = require('./components/MenuHeader.cjs'); 13 | var MenuGroup = require('./components/MenuGroup.cjs'); 14 | var MenuRadioGroup = require('./components/MenuRadioGroup.cjs'); 15 | var useClick = require('./hooks/useClick.cjs'); 16 | var useHover = require('./hooks/useHover.cjs'); 17 | var useMenuState = require('./hooks/useMenuState.cjs'); 18 | 19 | 20 | 21 | exports.MenuButton = MenuButton.MenuButton; 22 | exports.Menu = Menu.Menu; 23 | exports.ControlledMenu = ControlledMenu.ControlledMenu; 24 | exports.SubMenu = SubMenu.SubMenu; 25 | exports.MenuItem = MenuItem.MenuItem; 26 | exports.FocusableItem = FocusableItem.FocusableItem; 27 | exports.MenuDivider = MenuDivider.MenuDivider; 28 | exports.MenuHeader = MenuHeader.MenuHeader; 29 | exports.MenuGroup = MenuGroup.MenuGroup; 30 | exports.MenuRadioGroup = MenuRadioGroup.MenuRadioGroup; 31 | exports.useClick = useClick.useClick; 32 | exports.useHover = useHover.useHover; 33 | exports.useMenuState = useMenuState.useMenuState; 34 | -------------------------------------------------------------------------------- /src/__tests__/SSR.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | import { renderToString } from 'react-dom/server'; 6 | import { MenuItem, Menu, MenuButton } from './entry'; 7 | 8 | const getMenu = (props) => ( 9 |
    10 | Open} initialMounted {...props}> 11 | Item 12 | 13 |
    14 | ); 15 | 16 | describe('Server rendering', () => { 17 | test('portal is not provided', () => { 18 | expect(renderToString(getMenu())).toContain( 19 | '
      { 24 | expect(renderToString(getMenu({ portal: true }))).toContain( 25 | '
        { 30 | expect(renderToString(getMenu({ portal: { target: null } }))).toContain( 31 | '
          { 36 | expect(renderToString(getMenu({ portal: { stablePosition: true } }))).toContain( 37 | '
    ' 38 | ); 39 | }); 40 | 41 | test('initialMounted is false', () => { 42 | expect(renderToString(getMenu({ initialMounted: false }))).toContain(''); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /example/src/components/Icons.js: -------------------------------------------------------------------------------- 1 | export const ExternalLinkIcon = () => ( 2 | 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | 21 | export const CodeSandboxIcon = () => ( 22 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | ); 44 | -------------------------------------------------------------------------------- /example/src/components/Logo.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Menu, MenuItem, MenuDivider, MenuHeader } from '@szhsin/react-menu'; 3 | import { useTheme } from '../store'; 4 | import { version } from '../utils'; 5 | 6 | export const Logo = React.memo(function Logo() { 7 | const menuButton = ( 8 |
    9 | React-Menu 10 |
    v{version}
    11 | arrow_drop_down 12 |
    13 | ); 14 | 15 | return ( 16 | 23 | Version history 24 | v3.5.x 25 | v2.3.x 26 | v1.11.x 27 | 28 | Migration guide 29 | 30 | migrating to v4 31 | 32 | 33 | migrating to v3 34 | 35 | 36 | migrating to v2 37 | 38 | 39 | ); 40 | }); 41 | -------------------------------------------------------------------------------- /src/__tests__/MenuGroup.test.js: -------------------------------------------------------------------------------- 1 | import { createRef } from 'react'; 2 | import { Menu, MenuItem, MenuGroup, MenuButton, MenuDivider } from './entry'; 3 | import { fireEvent } from '@testing-library/react'; 4 | import * as utils from './utils'; 5 | 6 | test('MenuGroup should allow keyboard navigation to go thru its children', () => { 7 | const ref = createRef(); 8 | utils.render( 9 | Menu Group} setDownOverflow> 10 | One 11 | 12 | Two 13 | 14 | Skip 15 | 16 | Three 17 | 18 | 19 | Four 20 | 21 | ); 22 | 23 | expect(ref.current).toBeNull(); 24 | utils.clickMenuButton(); 25 | expect(ref.current).toHaveClass('szh-menu__group'); 26 | const menu = utils.queryMenu(); 27 | 28 | fireEvent.keyDown(menu, { key: 'ArrowDown' }); 29 | expect(utils.queryMenuItem('One')).toHaveFocus(); 30 | fireEvent.keyDown(menu, { key: 'ArrowDown' }); 31 | expect(utils.queryMenuItem('Two')).toHaveFocus(); 32 | fireEvent.keyDown(menu, { key: 'ArrowDown' }); 33 | expect(utils.queryMenuItem('Three')).toHaveFocus(); 34 | fireEvent.keyDown(menu, { key: 'ArrowDown' }); 35 | expect(utils.queryMenuItem('Four')).toHaveFocus(); 36 | fireEvent.keyDown(menu, { key: 'ArrowDown' }); 37 | expect(utils.queryMenuItem('One')).toHaveFocus(); 38 | }); 39 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 4 | import { babel } from '@rollup/plugin-babel'; 5 | import { addDirective } from 'rollup-plugin-add-directive'; 6 | 7 | /** 8 | * @type {import('rollup').RollupOptions} 9 | */ 10 | const sharedConfig = { 11 | external: ['react', 'react-dom', 'react/jsx-runtime', 'react-transition-state'], 12 | treeshake: { 13 | moduleSideEffects: false, 14 | propertyReadSideEffects: false 15 | } 16 | }; 17 | 18 | /** 19 | * @type {import('rollup').RollupOptions[]} 20 | */ 21 | export default [ 22 | { 23 | ...sharedConfig, 24 | plugins: [ 25 | nodeResolve(), 26 | babel({ babelHelpers: 'bundled' }), 27 | addDirective({ pattern: 'index' }) 28 | ], 29 | input: 'src/index.js', 30 | output: [ 31 | { 32 | dir: 'dist/cjs', 33 | format: 'cjs', 34 | interop: 'default', 35 | entryFileNames: '[name].cjs', 36 | preserveModules: true 37 | }, 38 | { 39 | dir: 'dist/esm', 40 | format: 'es', 41 | entryFileNames: '[name].mjs', 42 | preserveModules: true 43 | } 44 | ] 45 | }, 46 | { 47 | ...sharedConfig, 48 | plugins: [nodeResolve(), babel({ babelHelpers: 'bundled' })], 49 | input: 'src/style-utils/index.js', 50 | output: [ 51 | { 52 | file: 'dist/style-utils/index.cjs', 53 | format: 'cjs' 54 | }, 55 | { 56 | file: 'dist/style-utils/index.mjs', 57 | format: 'es' 58 | } 59 | ] 60 | } 61 | ]; 62 | -------------------------------------------------------------------------------- /dist/esm/components/MenuContainer.mjs: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { jsx } from 'react/jsx-runtime'; 3 | import { useBEM } from '../hooks/useBEM.mjs'; 4 | import { mergeProps, getTransition, safeCall } from '../utils/utils.mjs'; 5 | import { menuContainerClass, CloseReason, Keys } from '../utils/constants.mjs'; 6 | 7 | const MenuContainer = ({ 8 | className, 9 | containerRef, 10 | containerProps, 11 | children, 12 | isOpen, 13 | theming, 14 | transition, 15 | onClose 16 | }) => { 17 | const itemTransition = getTransition(transition, 'item'); 18 | const onKeyDown = ({ 19 | key 20 | }) => { 21 | switch (key) { 22 | case Keys.ESC: 23 | safeCall(onClose, { 24 | key, 25 | reason: CloseReason.CANCEL 26 | }); 27 | break; 28 | } 29 | }; 30 | const onBlur = e => { 31 | if (isOpen && !e.currentTarget.contains(e.relatedTarget)) { 32 | safeCall(onClose, { 33 | reason: CloseReason.BLUR 34 | }); 35 | } 36 | }; 37 | return /*#__PURE__*/jsx("div", { 38 | ...mergeProps({ 39 | onKeyDown, 40 | onBlur 41 | }, containerProps), 42 | className: useBEM({ 43 | block: menuContainerClass, 44 | modifiers: useMemo(() => ({ 45 | theme: theming, 46 | itemTransition 47 | }), [theming, itemTransition]), 48 | className 49 | }), 50 | style: { 51 | position: 'absolute', 52 | ...containerProps?.style 53 | }, 54 | ref: containerRef, 55 | children: children 56 | }); 57 | }; 58 | 59 | export { MenuContainer }; 60 | -------------------------------------------------------------------------------- /example/src/components/StyleExamples.js: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import { ExternalLink } from './ExternalLink'; 3 | 4 | const styleExamples = [ 5 | { 6 | name: 'CSS/SASS', 7 | link: 'https://codesandbox.io/s/react-menu-sass-i1wxo' 8 | }, 9 | { 10 | name: 'CSS Module', 11 | link: 'https://codesandbox.io/s/react-menu-css-module-q7zfp' 12 | }, 13 | { 14 | name: 'styled-components', 15 | link: 'https://codesandbox.io/s/react-menu-styled-components-0jrzi' 16 | }, 17 | { 18 | name: '@emotion/styled', 19 | link: 'https://codesandbox.io/s/react-menu-emotion-styled-2l35s' 20 | }, 21 | { 22 | name: '@emotion/react', 23 | link: 'https://codesandbox.io/s/react-menu-emotion-react-ck63i' 24 | }, 25 | { 26 | name: '@emotion/css', 27 | link: 'https://codesandbox.io/s/react-menu-emotion-css-yl4sj' 28 | }, 29 | { 30 | name: 'styled-jsx', 31 | link: 'https://codesandbox.io/s/react-menu-styled-jsx-lcm8z' 32 | }, 33 | { 34 | name: 'Tailwind CSS', 35 | link: 'https://codesandbox.io/s/react-menu-tailwindcss-0r1rvf' 36 | } 37 | ]; 38 | 39 | export const StyleExamples = memo(function StyleExamples() { 40 | return ( 41 | <> 42 |
      43 | {styleExamples.map(({ name, link }) => ( 44 |
    • 45 | {name} 46 |
    • 47 | ))} 48 |
    49 |

    50 | All styles are locally scoped to the components except in the CSS/SASS example. 51 |

    52 | 53 | ); 54 | }); 55 | -------------------------------------------------------------------------------- /dist/transitions/zoom.css: -------------------------------------------------------------------------------- 1 | @keyframes szh-menu-show-zoom { 2 | from { 3 | opacity: 0.1; 4 | transform: scale(0.95); 5 | } 6 | } 7 | @keyframes szh-menu-hide-zoom { 8 | to { 9 | opacity: 0.1; 10 | transform: scale(0.95); 11 | } 12 | } 13 | .szh-menu--state-opening { 14 | animation: szh-menu-show-zoom 0.125s ease-out; 15 | } 16 | 17 | .szh-menu--state-closing { 18 | animation: szh-menu-hide-zoom 0.125s ease-in forwards; 19 | } 20 | 21 | .szh-menu--dir-left.szh-menu--align-start { 22 | transform-origin: right top; 23 | } 24 | 25 | .szh-menu--dir-left.szh-menu--align-center { 26 | transform-origin: right center; 27 | } 28 | 29 | .szh-menu--dir-left.szh-menu--align-end { 30 | transform-origin: right bottom; 31 | } 32 | 33 | .szh-menu--dir-right.szh-menu--align-start { 34 | transform-origin: left top; 35 | } 36 | 37 | .szh-menu--dir-right.szh-menu--align-center { 38 | transform-origin: left center; 39 | } 40 | 41 | .szh-menu--dir-right.szh-menu--align-end { 42 | transform-origin: left bottom; 43 | } 44 | 45 | .szh-menu--dir-top.szh-menu--align-start { 46 | transform-origin: left bottom; 47 | } 48 | 49 | .szh-menu--dir-top.szh-menu--align-center { 50 | transform-origin: center bottom; 51 | } 52 | 53 | .szh-menu--dir-top.szh-menu--align-end { 54 | transform-origin: right bottom; 55 | } 56 | 57 | .szh-menu--dir-bottom.szh-menu--align-start { 58 | transform-origin: left top; 59 | } 60 | 61 | .szh-menu--dir-bottom.szh-menu--align-center { 62 | transform-origin: center top; 63 | } 64 | 65 | .szh-menu--dir-bottom.szh-menu--align-end { 66 | transform-origin: right top; 67 | } 68 | -------------------------------------------------------------------------------- /src/styles/index.scss: -------------------------------------------------------------------------------- 1 | @use 'var'; 2 | @use 'core'; 3 | 4 | .szh-menu { 5 | user-select: none; 6 | color: var.$color; 7 | border: none; 8 | border-radius: 0.25rem; 9 | box-shadow: 10 | 0 3px 7px rgba(0, 0, 0, 0.133), 11 | 0 0.6px 2px rgba(0, 0, 0, 0.1); 12 | min-width: 10rem; 13 | padding: 0.5rem 0; 14 | 15 | &__item { 16 | display: flex; 17 | align-items: center; 18 | position: relative; 19 | padding: 0.375rem 1.5rem; 20 | 21 | .szh-menu-container--itemTransition & { 22 | transition: { 23 | property: background-color, color; 24 | duration: 0.15s; 25 | timing-function: ease-in-out; 26 | } 27 | } 28 | 29 | &--type-radio { 30 | padding-left: 2.2rem; 31 | &::before { 32 | content: '\25cb'; 33 | position: absolute; 34 | left: 0.8rem; 35 | top: 0.55rem; 36 | font-size: 0.8rem; 37 | } 38 | } 39 | 40 | &--type-radio#{&}--checked::before { 41 | content: '\25cf'; 42 | } 43 | 44 | &--type-checkbox { 45 | padding-left: 2.2rem; 46 | &::before { 47 | position: absolute; 48 | left: 0.8rem; 49 | } 50 | } 51 | 52 | &--type-checkbox#{&}--checked::before { 53 | content: '\2714'; 54 | } 55 | } 56 | 57 | &__submenu > .szh-menu__item { 58 | padding-right: 2.5rem; 59 | &::after { 60 | content: '\276f'; 61 | position: absolute; 62 | right: 1rem; 63 | } 64 | } 65 | 66 | &__header { 67 | color: var.$header-color; 68 | font-size: 0.8rem; 69 | padding: 0.2rem 1.5rem; 70 | text-transform: uppercase; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /dist/cjs/components/MenuContainer.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var react = require('react'); 4 | var jsxRuntime = require('react/jsx-runtime'); 5 | var useBEM = require('../hooks/useBEM.cjs'); 6 | var utils = require('../utils/utils.cjs'); 7 | var constants = require('../utils/constants.cjs'); 8 | 9 | const MenuContainer = ({ 10 | className, 11 | containerRef, 12 | containerProps, 13 | children, 14 | isOpen, 15 | theming, 16 | transition, 17 | onClose 18 | }) => { 19 | const itemTransition = utils.getTransition(transition, 'item'); 20 | const onKeyDown = ({ 21 | key 22 | }) => { 23 | switch (key) { 24 | case constants.Keys.ESC: 25 | utils.safeCall(onClose, { 26 | key, 27 | reason: constants.CloseReason.CANCEL 28 | }); 29 | break; 30 | } 31 | }; 32 | const onBlur = e => { 33 | if (isOpen && !e.currentTarget.contains(e.relatedTarget)) { 34 | utils.safeCall(onClose, { 35 | reason: constants.CloseReason.BLUR 36 | }); 37 | } 38 | }; 39 | return /*#__PURE__*/jsxRuntime.jsx("div", { 40 | ...utils.mergeProps({ 41 | onKeyDown, 42 | onBlur 43 | }, containerProps), 44 | className: useBEM.useBEM({ 45 | block: constants.menuContainerClass, 46 | modifiers: react.useMemo(() => ({ 47 | theme: theming, 48 | itemTransition 49 | }), [theming, itemTransition]), 50 | className 51 | }), 52 | style: { 53 | position: 'absolute', 54 | ...containerProps?.style 55 | }, 56 | ref: containerRef, 57 | children: children 58 | }); 59 | }; 60 | 61 | exports.MenuContainer = MenuContainer; 62 | -------------------------------------------------------------------------------- /example/src/styles/_mixins.scss: -------------------------------------------------------------------------------- 1 | @use 'var'; 2 | @use 'sass:color'; 3 | 4 | @mixin reset-list { 5 | margin: 0; 6 | padding: 0; 7 | list-style: none; 8 | } 9 | 10 | @mixin remove-focus { 11 | &:focus { 12 | outline: none; 13 | } 14 | } 15 | 16 | @mixin focus($width: 1px, $color: var.$focus-light) { 17 | &:focus-visible { 18 | border-color: $color; 19 | box-shadow: 0 0 0 $width $color; 20 | } 21 | 22 | @supports not selector(:focus-visible) { 23 | &:focus { 24 | border-color: $color; 25 | box-shadow: 0 0 0 $width $color; 26 | } 27 | } 28 | } 29 | 30 | @mixin transition($property: background-color, $duration: 0.15s, $timing-function: ease-in-out) { 31 | transition-property: $property; 32 | transition-duration: $duration; 33 | transition-timing-function: $timing-function; 34 | } 35 | 36 | @mixin border($border: 'border', $width: 1px, $transition: false) { 37 | #{$border}: $width solid var.$border-light; 38 | @if $transition { 39 | @include transition(border-color); 40 | } 41 | } 42 | 43 | @mixin scrollbar($size: 7px, $dimension: 'width') { 44 | // Chrome, Safari, Edge 45 | &::-webkit-scrollbar { 46 | #{$dimension}: $size; 47 | } 48 | &::-webkit-scrollbar-thumb { 49 | background-color: color.scale(var.$border-light, $lightness: -15%); 50 | } 51 | &::-webkit-scrollbar-track { 52 | background-color: var.$border-light; 53 | } 54 | } 55 | 56 | @mixin scrollbar-dark { 57 | &::-webkit-scrollbar-thumb { 58 | background-color: color.scale(var.$border-dark, $lightness: 25%); 59 | } 60 | &::-webkit-scrollbar-track { 61 | background-color: var.$border-dark; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /dist/core.css: -------------------------------------------------------------------------------- 1 | .szh-menu { 2 | margin: 0; 3 | padding: 0; 4 | list-style: none; 5 | box-sizing: border-box; 6 | width: max-content; 7 | z-index: 100; 8 | border: 1px solid rgba(0, 0, 0, 0.1); 9 | background-color: #fff; 10 | } 11 | .szh-menu:focus { 12 | outline: none; 13 | } 14 | .szh-menu__arrow { 15 | box-sizing: border-box; 16 | width: 0.75rem; 17 | height: 0.75rem; 18 | background-color: #fff; 19 | border: 1px solid transparent; 20 | border-left-color: rgba(0, 0, 0, 0.1); 21 | border-top-color: rgba(0, 0, 0, 0.1); 22 | z-index: -1; 23 | } 24 | .szh-menu__arrow--dir-left { 25 | right: -0.375rem; 26 | transform: translateY(-50%) rotate(135deg); 27 | } 28 | .szh-menu__arrow--dir-right { 29 | left: -0.375rem; 30 | transform: translateY(-50%) rotate(-45deg); 31 | } 32 | .szh-menu__arrow--dir-top { 33 | bottom: -0.375rem; 34 | transform: translateX(-50%) rotate(-135deg); 35 | } 36 | .szh-menu__arrow--dir-bottom { 37 | top: -0.375rem; 38 | transform: translateX(-50%) rotate(45deg); 39 | } 40 | .szh-menu__item { 41 | cursor: pointer; 42 | } 43 | .szh-menu__item:focus { 44 | outline: none; 45 | } 46 | .szh-menu__item--hover { 47 | background-color: #ebebeb; 48 | } 49 | .szh-menu__item--focusable { 50 | cursor: default; 51 | background-color: inherit; 52 | } 53 | .szh-menu__item--disabled { 54 | cursor: default; 55 | color: #aaa; 56 | } 57 | .szh-menu__group { 58 | box-sizing: border-box; 59 | } 60 | .szh-menu__radio-group { 61 | margin: 0; 62 | padding: 0; 63 | list-style: none; 64 | } 65 | .szh-menu__divider { 66 | height: 1px; 67 | margin: 0.5rem 0; 68 | background-color: rgba(0, 0, 0, 0.12); 69 | } 70 | 71 | .szh-menu-button { 72 | box-sizing: border-box; 73 | } 74 | -------------------------------------------------------------------------------- /example/src/components/Footer.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { basePath } from '../../next.config'; 3 | import { useTheme } from '../store'; 4 | import { version, build } from '../utils'; 5 | 6 | export const Footer = React.memo(function Footer() { 7 | const { isDark } = useTheme(); 8 | const [starCount, setStarCount] = useState('-'); 9 | 10 | useEffect(() => { 11 | fetch('https://api.github.com/repos/szhsin/react-menu') 12 | .then((response) => response.json()) 13 | .then(({ stargazers_count }) => setStarCount(stargazers_count.toLocaleString('en-US'))) 14 | .catch((err) => console.error(err)); 15 | }, []); 16 | 17 | return ( 18 | 51 | ); 52 | }); 53 | -------------------------------------------------------------------------------- /dist/esm/components/MenuGroup.mjs: -------------------------------------------------------------------------------- 1 | import { forwardRef, useRef, useState, useContext } from 'react'; 2 | import { jsx } from 'react/jsx-runtime'; 3 | import { useLayoutEffect as useIsomorphicLayoutEffect } from '../hooks/useIsomorphicLayoutEffect.mjs'; 4 | import { getNormalizedClientRect } from '../positionUtils/getNormalizedClientRect.mjs'; 5 | import { useBEM } from '../hooks/useBEM.mjs'; 6 | import { useCombinedRef } from '../hooks/useCombinedRef.mjs'; 7 | import { MenuListContext, menuClass, menuGroupClass } from '../utils/constants.mjs'; 8 | 9 | const MenuGroup = /*#__PURE__*/forwardRef(function MenuGroup({ 10 | className, 11 | style, 12 | takeOverflow, 13 | ...restProps 14 | }, externalRef) { 15 | const ref = useRef(null); 16 | const [overflowStyle, setOverflowStyle] = useState(); 17 | const { 18 | overflow, 19 | overflowAmt 20 | } = useContext(MenuListContext); 21 | useIsomorphicLayoutEffect(() => { 22 | let maxHeight; 23 | if (takeOverflow && overflowAmt >= 0) { 24 | maxHeight = getNormalizedClientRect(ref.current).height - overflowAmt; 25 | if (maxHeight < 0) maxHeight = 0; 26 | } 27 | setOverflowStyle(maxHeight >= 0 ? { 28 | maxHeight, 29 | overflow 30 | } : undefined); 31 | }, [takeOverflow, overflow, overflowAmt]); 32 | useIsomorphicLayoutEffect(() => { 33 | if (overflowStyle) ref.current.scrollTop = 0; 34 | }, [overflowStyle]); 35 | return /*#__PURE__*/jsx("div", { 36 | ...restProps, 37 | ref: useCombinedRef(externalRef, ref), 38 | className: useBEM({ 39 | block: menuClass, 40 | element: menuGroupClass, 41 | className 42 | }), 43 | style: { 44 | ...style, 45 | ...overflowStyle 46 | } 47 | }); 48 | }); 49 | 50 | export { MenuGroup }; 51 | -------------------------------------------------------------------------------- /dist/esm/hooks/useItemState.mjs: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect } from 'react'; 2 | import { useItemEffect } from './useItemEffect.mjs'; 3 | import { useMouseOver } from './useMouseOver.mjs'; 4 | import { SettingsContext, MenuListItemContext, HoverActionTypes } from '../utils/constants.mjs'; 5 | 6 | const useItemState = (itemRef, focusRef, isHovering, isDisabled) => { 7 | const [mouseOver, mouseOverStart, mouseOverEnd] = useMouseOver(isHovering); 8 | const { 9 | submenuCloseDelay 10 | } = useContext(SettingsContext); 11 | const { 12 | isParentOpen, 13 | submenuCtx, 14 | dispatch, 15 | updateItems 16 | } = useContext(MenuListItemContext); 17 | const setHover = () => { 18 | !isHovering && !isDisabled && dispatch(HoverActionTypes.SET, itemRef.current); 19 | }; 20 | const unsetHover = () => { 21 | !isDisabled && dispatch(HoverActionTypes.UNSET, itemRef.current); 22 | }; 23 | const onBlur = e => { 24 | if (isHovering && !e.currentTarget.contains(e.relatedTarget)) unsetHover(); 25 | }; 26 | const onPointerMove = e => { 27 | if (!isDisabled) { 28 | e.stopPropagation(); 29 | mouseOverStart(); 30 | submenuCtx.on(submenuCloseDelay, setHover, setHover); 31 | } 32 | }; 33 | const onPointerLeave = (_, keepHover) => { 34 | mouseOverEnd(); 35 | submenuCtx.off(); 36 | !keepHover && unsetHover(); 37 | }; 38 | useItemEffect(isDisabled, itemRef, updateItems); 39 | useEffect(() => { 40 | if (isHovering && isParentOpen) { 41 | focusRef.current && focusRef.current.focus(); 42 | } 43 | }, [focusRef, isHovering, isParentOpen]); 44 | return { 45 | mouseOver, 46 | setHover, 47 | onBlur, 48 | onPointerMove, 49 | onPointerLeave 50 | }; 51 | }; 52 | 53 | export { useItemState }; 54 | -------------------------------------------------------------------------------- /dist/cjs/hooks/useItemState.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var react = require('react'); 4 | var useItemEffect = require('./useItemEffect.cjs'); 5 | var useMouseOver = require('./useMouseOver.cjs'); 6 | var constants = require('../utils/constants.cjs'); 7 | 8 | const useItemState = (itemRef, focusRef, isHovering, isDisabled) => { 9 | const [mouseOver, mouseOverStart, mouseOverEnd] = useMouseOver.useMouseOver(isHovering); 10 | const { 11 | submenuCloseDelay 12 | } = react.useContext(constants.SettingsContext); 13 | const { 14 | isParentOpen, 15 | submenuCtx, 16 | dispatch, 17 | updateItems 18 | } = react.useContext(constants.MenuListItemContext); 19 | const setHover = () => { 20 | !isHovering && !isDisabled && dispatch(constants.HoverActionTypes.SET, itemRef.current); 21 | }; 22 | const unsetHover = () => { 23 | !isDisabled && dispatch(constants.HoverActionTypes.UNSET, itemRef.current); 24 | }; 25 | const onBlur = e => { 26 | if (isHovering && !e.currentTarget.contains(e.relatedTarget)) unsetHover(); 27 | }; 28 | const onPointerMove = e => { 29 | if (!isDisabled) { 30 | e.stopPropagation(); 31 | mouseOverStart(); 32 | submenuCtx.on(submenuCloseDelay, setHover, setHover); 33 | } 34 | }; 35 | const onPointerLeave = (_, keepHover) => { 36 | mouseOverEnd(); 37 | submenuCtx.off(); 38 | !keepHover && unsetHover(); 39 | }; 40 | useItemEffect.useItemEffect(isDisabled, itemRef, updateItems); 41 | react.useEffect(() => { 42 | if (isHovering && isParentOpen) { 43 | focusRef.current && focusRef.current.focus(); 44 | } 45 | }, [focusRef, isHovering, isParentOpen]); 46 | return { 47 | mouseOver, 48 | setHover, 49 | onBlur, 50 | onPointerMove, 51 | onPointerLeave 52 | }; 53 | }; 54 | 55 | exports.useItemState = useItemState; 56 | -------------------------------------------------------------------------------- /dist/cjs/components/MenuGroup.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var react = require('react'); 4 | var jsxRuntime = require('react/jsx-runtime'); 5 | var useIsomorphicLayoutEffect = require('../hooks/useIsomorphicLayoutEffect.cjs'); 6 | var getNormalizedClientRect = require('../positionUtils/getNormalizedClientRect.cjs'); 7 | var useBEM = require('../hooks/useBEM.cjs'); 8 | var useCombinedRef = require('../hooks/useCombinedRef.cjs'); 9 | var constants = require('../utils/constants.cjs'); 10 | 11 | const MenuGroup = /*#__PURE__*/react.forwardRef(function MenuGroup({ 12 | className, 13 | style, 14 | takeOverflow, 15 | ...restProps 16 | }, externalRef) { 17 | const ref = react.useRef(null); 18 | const [overflowStyle, setOverflowStyle] = react.useState(); 19 | const { 20 | overflow, 21 | overflowAmt 22 | } = react.useContext(constants.MenuListContext); 23 | useIsomorphicLayoutEffect.useLayoutEffect(() => { 24 | let maxHeight; 25 | if (takeOverflow && overflowAmt >= 0) { 26 | maxHeight = getNormalizedClientRect.getNormalizedClientRect(ref.current).height - overflowAmt; 27 | if (maxHeight < 0) maxHeight = 0; 28 | } 29 | setOverflowStyle(maxHeight >= 0 ? { 30 | maxHeight, 31 | overflow 32 | } : undefined); 33 | }, [takeOverflow, overflow, overflowAmt]); 34 | useIsomorphicLayoutEffect.useLayoutEffect(() => { 35 | if (overflowStyle) ref.current.scrollTop = 0; 36 | }, [overflowStyle]); 37 | return /*#__PURE__*/jsxRuntime.jsx("div", { 38 | ...restProps, 39 | ref: useCombinedRef.useCombinedRef(externalRef, ref), 40 | className: useBEM.useBEM({ 41 | block: constants.menuClass, 42 | element: constants.menuGroupClass, 43 | className 44 | }), 45 | style: { 46 | ...style, 47 | ...overflowStyle 48 | } 49 | }); 50 | }); 51 | 52 | exports.MenuGroup = MenuGroup; 53 | -------------------------------------------------------------------------------- /src/styles/core.scss: -------------------------------------------------------------------------------- 1 | @use 'var'; 2 | @use 'mixins'; 3 | 4 | .szh-menu { 5 | @include mixins.reset-list; 6 | box-sizing: border-box; 7 | width: max-content; 8 | z-index: 100; 9 | border: 1px solid var.$border-color; 10 | background-color: var.$background-color; 11 | @include mixins.remove-focus; 12 | 13 | &__arrow { 14 | box-sizing: border-box; 15 | width: var.$arrow-size; 16 | height: var.$arrow-size; 17 | background-color: var.$background-color; 18 | border: 1px solid transparent; 19 | border-left-color: var.$border-color; 20 | border-top-color: var.$border-color; 21 | z-index: -1; 22 | 23 | &--dir-left { 24 | right: var.$arrow-pos; 25 | transform: translateY(-50%) rotate(135deg); 26 | } 27 | 28 | &--dir-right { 29 | left: var.$arrow-pos; 30 | transform: translateY(-50%) rotate(-45deg); 31 | } 32 | 33 | &--dir-top { 34 | bottom: var.$arrow-pos; 35 | transform: translateX(-50%) rotate(-135deg); 36 | } 37 | 38 | &--dir-bottom { 39 | top: var.$arrow-pos; 40 | transform: translateX(-50%) rotate(45deg); 41 | } 42 | } 43 | 44 | &__item { 45 | cursor: pointer; 46 | @include mixins.remove-focus; 47 | 48 | &--hover { 49 | background-color: var.$background-color-hover; 50 | } 51 | 52 | &--focusable { 53 | cursor: default; 54 | background-color: inherit; 55 | } 56 | 57 | &--disabled { 58 | cursor: default; 59 | color: var.$color-disabled; 60 | } 61 | } 62 | 63 | &__group { 64 | box-sizing: border-box; 65 | } 66 | 67 | &__radio-group { 68 | @include mixins.reset-list; 69 | } 70 | 71 | &__divider { 72 | height: 1px; 73 | margin: 0.5rem 0; 74 | background-color: var.$divider-color; 75 | } 76 | } 77 | 78 | .szh-menu-button { 79 | box-sizing: border-box; 80 | } 81 | -------------------------------------------------------------------------------- /src/utils/constants.js: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | export const menuContainerClass = 'szh-menu-container'; 4 | export const menuClass = 'szh-menu'; 5 | export const menuButtonClass = 'szh-menu-button'; 6 | export const menuArrowClass = 'arrow'; 7 | export const menuItemClass = 'item'; 8 | export const menuDividerClass = 'divider'; 9 | export const menuHeaderClass = 'header'; 10 | export const menuGroupClass = 'group'; 11 | export const subMenuClass = 'submenu'; 12 | export const radioGroupClass = 'radio-group'; 13 | 14 | export const HoverItemContext = createContext(); 15 | export const MenuListItemContext = createContext({}); 16 | export const MenuListContext = createContext({}); 17 | export const EventHandlersContext = createContext({}); 18 | export const RadioGroupContext = createContext({}); 19 | export const SettingsContext = createContext({}); 20 | 21 | export const Keys = Object.freeze({ 22 | ENTER: 'Enter', 23 | ESC: 'Escape', 24 | SPACE: ' ', 25 | HOME: 'Home', 26 | END: 'End', 27 | LEFT: 'ArrowLeft', 28 | RIGHT: 'ArrowRight', 29 | UP: 'ArrowUp', 30 | DOWN: 'ArrowDown' 31 | }); 32 | 33 | export const HoverActionTypes = Object.freeze({ 34 | RESET: 0, 35 | SET: 1, 36 | UNSET: 2, 37 | INCREASE: 3, 38 | DECREASE: 4, 39 | FIRST: 5, 40 | LAST: 6, 41 | SET_INDEX: 7 42 | }); 43 | 44 | export const CloseReason = Object.freeze({ 45 | CLICK: 'click', 46 | CANCEL: 'cancel', 47 | BLUR: 'blur', 48 | SCROLL: 'scroll' 49 | }); 50 | 51 | export const FocusPositions = Object.freeze({ 52 | FIRST: 'first', 53 | LAST: 'last' 54 | }); 55 | 56 | export const MenuStateMap = Object.freeze({ 57 | entering: 'opening', 58 | entered: 'open', 59 | exiting: 'closing', 60 | exited: 'closed' 61 | }); 62 | 63 | export const positionAbsolute = 'absolute'; 64 | export const roleNone = 'none'; 65 | export const roleMenuitem = 'menuitem'; 66 | export const noScrollFocus = { preventScroll: true }; 67 | -------------------------------------------------------------------------------- /example/src/components/App.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useSnapshot } from 'reactish-state'; 3 | import { useRouter } from 'next/router'; 4 | import { bem, useLayoutEffect } from '../utils'; 5 | import { hydrate, domInfoState, isTocOpenState, toastState, showBannerState } from '../store'; 6 | import { Header } from './Header'; 7 | import { Footer } from './Footer'; 8 | 9 | const App = ({ children }) => { 10 | const showBanner = useSnapshot(showBannerState); 11 | const toast = useSnapshot(toastState); 12 | 13 | useLayoutEffect(() => { 14 | hydrate(); 15 | }, []); 16 | 17 | useEffect(() => { 18 | const handleResize = () => { 19 | const info = { 20 | // Viewport size 21 | vWidth: document.documentElement.clientWidth, 22 | vHeight: window.innerHeight, 23 | navbarHeight: document.querySelector('#header').offsetHeight 24 | }; 25 | 26 | if (info.vWidth > 950) isTocOpenState.set(false); 27 | domInfoState.set(info); 28 | }; 29 | 30 | handleResize(); 31 | window.addEventListener('resize', handleResize); 32 | 33 | return () => { 34 | window.removeEventListener('resize', handleResize); 35 | }; 36 | }, [/* effect dep */ showBanner]); 37 | 38 | useEffect(() => { 39 | if (!toast) return; 40 | const id = setTimeout(() => toastState.set(null), 2500); 41 | return () => clearTimeout(id); 42 | }, [toast]); 43 | 44 | const router = useRouter(); 45 | useEffect(() => { 46 | isTocOpenState.set(false); 47 | }, [/* effect dep */ router]); 48 | 49 | return ( 50 | <> 51 |
    52 |
    53 | {children} 54 |
    55 |