├── src ├── vite-env.d.ts ├── react-float-menu.ts ├── models │ ├── position.ts │ └── menu-head.model.ts ├── utils │ ├── theme-default.ts │ └── helpers.ts ├── main.tsx ├── icons │ ├── plus.tsx │ ├── close.tsx │ ├── index.tsx │ ├── chevron-right.tsx │ ├── logout.tsx │ ├── file.tsx │ ├── save.tsx │ ├── copy.tsx │ └── edit.tsx ├── components │ ├── context.ts │ ├── menu-list-item │ │ ├── menu-list-item.model.ts │ │ ├── menu-list-item.module.scss │ │ ├── __tests__ │ │ │ └── menu-list-item.test.tsx │ │ └── menu-list-item.tsx │ ├── menu-container │ │ ├── menu-container.model.ts │ │ ├── menu-container.module.scss │ │ └── menu-container.tsx │ ├── menu │ │ ├── menu-model.ts │ │ ├── __tests__ │ │ │ └── menu.test.tsx │ │ ├── menu.module.scss │ │ └── index.tsx │ └── main │ │ ├── main.module.scss │ │ ├── __tests__ │ │ └── main.test.tsx │ │ └── index.tsx ├── effects │ ├── useMenuHidden.ts │ ├── useCloseOnEscape.ts │ ├── useCloseOnClick.ts │ ├── useKeyboardNav.ts │ ├── useMenuToFront.ts │ └── usePosition.ts └── App.tsx ├── .prettierrc ├── jest-setup.d.ts ├── logo.jpg ├── logo.png ├── .github ├── ranger.yml ├── .kodiak.toml └── workflows │ ├── webpack.yml │ ├── test-and-lint.yml │ └── cypress.yml.disabled ├── .husky └── pre-commit ├── cypress ├── videos │ └── interactions.cy.ts.mp4 ├── component │ └── ComponentName.cy.ts ├── fixtures │ └── example.json ├── support │ ├── component-index.html │ ├── e2e.ts │ ├── component.ts │ └── commands.ts ├── tsconfig.json └── e2e │ └── 3-float-menu │ └── interactions.cy.ts ├── jest-setup.js ├── jest-setup.ts ├── .restyled.yaml ├── .stylelintrc.json ├── index.html ├── cypress.config.ts ├── .gitignore ├── postcss.config.js ├── globals.d.ts ├── vitest.config.ts ├── LICENSE.md ├── tsconfig.json ├── CONTRIBUTING.md ├── vite.config.ts ├── eslint.config.js ├── package.json ├── CLAUDE.md ├── README.md └── logo.svg /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false 4 | } 5 | -------------------------------------------------------------------------------- /jest-setup.d.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/extend-expect'; 2 | -------------------------------------------------------------------------------- /logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prabhuignoto/react-float-menu/HEAD/logo.jpg -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prabhuignoto/react-float-menu/HEAD/logo.png -------------------------------------------------------------------------------- /.github/ranger.yml: -------------------------------------------------------------------------------- 1 | merges: 2 | # Delete branch after merging the PR 3 | - action: delete_branch -------------------------------------------------------------------------------- /src/react-float-menu.ts: -------------------------------------------------------------------------------- 1 | import { MenuHead } from "./components/main"; 2 | 3 | export { MenuHead as Menu }; 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | pnpm format 5 | pnpm lint:css-fix 6 | pnpm lint:all 7 | -------------------------------------------------------------------------------- /src/models/position.ts: -------------------------------------------------------------------------------- 1 | export type Position = 2 | | "top left" 3 | | "top right" 4 | | "bottom left" 5 | | "bottom right"; 6 | -------------------------------------------------------------------------------- /cypress/videos/interactions.cy.ts.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prabhuignoto/react-float-menu/HEAD/cypress/videos/interactions.cy.ts.mp4 -------------------------------------------------------------------------------- /jest-setup.js: -------------------------------------------------------------------------------- 1 | // jest setup 2 | import '@testing-library/jest-dom/extend-expect'; 3 | import React from "react"; 4 | global.React = React; 5 | -------------------------------------------------------------------------------- /jest-setup.ts: -------------------------------------------------------------------------------- 1 | // jest setup 2 | import '@testing-library/jest-dom/extend-expect'; 3 | import React from "react"; 4 | 5 | global.React = React; -------------------------------------------------------------------------------- /cypress/component/ComponentName.cy.ts: -------------------------------------------------------------------------------- 1 | describe('ComponentName.cy.ts', () => { 2 | it('playground', () => { 3 | // cy.mount() 4 | }) 5 | }) -------------------------------------------------------------------------------- /.restyled.yaml: -------------------------------------------------------------------------------- 1 | exclude: 2 | - "**/*.patch" 3 | - "**/node_modules/**/*" 4 | - "**/vendor/**/*" 5 | - ".github/workflows/**/*" 6 | - ".gitignore" 7 | - "pnpm-lock.yaml" 8 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /.github/.kodiak.toml: -------------------------------------------------------------------------------- 1 | # .kodiak.toml 2 | 3 | version = 1 4 | 5 | [update] 6 | always = true 7 | require_automerge_label = true 8 | 9 | [merge] 10 | block_on_reviews_requested = true 11 | notify_on_conflict = true 12 | blocking_labels = ["wip"] -------------------------------------------------------------------------------- /src/utils/theme-default.ts: -------------------------------------------------------------------------------- 1 | export const defaultTheme = { 2 | menuBackgroundColor: "#FFFFFF", 3 | menuItemHoverColor: "#318CE7", 4 | menuItemHoverTextColor: "#fff", 5 | menuItemTextColor: "#000", 6 | primary: "#318CE7", 7 | secondary: "#FFFFFF", 8 | }; 9 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | // import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App"; 4 | 5 | const target = document.getElementById("root"); 6 | 7 | if (target) { 8 | const root = ReactDOM.createRoot(target); 9 | root.render(); 10 | } 11 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "overrides": [ 3 | { 4 | "files": ["**/*.scss"], 5 | "customSyntax": "postcss-scss" 6 | } 7 | ], 8 | "rules": { 9 | "property-no-unknown": [ 10 | true, 11 | { 12 | "ignoreProperties": [ 13 | "composes" 14 | ] 15 | } 16 | ] 17 | } 18 | } -------------------------------------------------------------------------------- /cypress/support/component-index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Components App 8 | 9 | 10 |
11 | 12 | -------------------------------------------------------------------------------- /src/icons/plus.tsx: -------------------------------------------------------------------------------- 1 | const SvgComponent = () => ( 2 | 10 | 11 | 12 | 13 | ); 14 | 15 | export default SvgComponent; 16 | -------------------------------------------------------------------------------- /src/icons/close.tsx: -------------------------------------------------------------------------------- 1 | const SvgComponent = () => ( 2 | 11 | 12 | 13 | ); 14 | 15 | export default SvgComponent; 16 | -------------------------------------------------------------------------------- /src/icons/index.tsx: -------------------------------------------------------------------------------- 1 | export { default as ChevronRight } from "./chevron-right"; 2 | export { default as CloseIcon } from "./close"; 3 | export { default as CopyIcon } from "./copy"; 4 | export { default as EditIcon } from "./edit"; 5 | export { default as FileIcon } from "./file"; 6 | export { default as LogoutIcon } from "./logout"; 7 | export { default as PlusIcon } from "./plus"; 8 | export { default as SaveIcon } from "./save"; 9 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite App 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/components/context.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { MenuHeadProps } from "../models/menu-head.model"; 3 | 4 | type ContextModel = Pick< 5 | MenuHeadProps, 6 | | "dimension" 7 | | "items" 8 | | "shape" 9 | | "theme" 10 | | "disableHeader" 11 | | "width" 12 | | "iconSize" 13 | | "RTL" 14 | | "closeOnClickOutside" 15 | >; 16 | 17 | export const MenuContext = React.createContext({}); 18 | -------------------------------------------------------------------------------- /src/icons/chevron-right.tsx: -------------------------------------------------------------------------------- 1 | const ChevronRight = () => ( 2 | 12 | 13 | 14 | ); 15 | 16 | export default ChevronRight; 17 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | 3 | export default defineConfig({ 4 | component: { 5 | devServer: { 6 | bundler: "webpack", 7 | framework: "react", 8 | }, 9 | }, 10 | e2e: { 11 | setupNodeEvents(on, config) { 12 | // implement node event listeners here 13 | }, 14 | }, 15 | projectId: 'tpauqa', 16 | 17 | retries: 2, 18 | waitForAnimations: true, 19 | 20 | }); 21 | -------------------------------------------------------------------------------- /src/icons/logout.tsx: -------------------------------------------------------------------------------- 1 | const SvgComponent = () => ( 2 | 12 | 13 | 14 | ); 15 | 16 | export default SvgComponent; 17 | -------------------------------------------------------------------------------- /src/icons/file.tsx: -------------------------------------------------------------------------------- 1 | const SvgComponent = () => ( 2 | 12 | 13 | 14 | 15 | ); 16 | 17 | export default SvgComponent; 18 | -------------------------------------------------------------------------------- /src/icons/save.tsx: -------------------------------------------------------------------------------- 1 | const SvgComponent = () => ( 2 | 12 | 13 | 14 | 15 | ); 16 | 17 | export default SvgComponent; 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | cache 27 | coverage 28 | vitest_cache 29 | 30 | cypress/e2e/1-getting-started 31 | cypress/e2e/2-advanced-examples 32 | cypress/videos/** 33 | -------------------------------------------------------------------------------- /src/icons/copy.tsx: -------------------------------------------------------------------------------- 1 | const SvgComponent = () => ( 2 | 12 | 13 | 14 | 15 | ); 16 | 17 | export default SvgComponent; 18 | -------------------------------------------------------------------------------- /src/components/menu-list-item/menu-list-item.model.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { MenuItemProps } from "../menu/menu-model"; 3 | 4 | export type MenuItemViewModel = MenuItemProps & { 5 | icon?: ReactNode; 6 | open?: boolean | null; 7 | onSelect?: (path: string, index?: number, id?: string) => void; 8 | index?: number; 9 | onCloseSubMenu?: () => void; 10 | onMouseEnter?: (id?: string) => void; 11 | onMouseLeave?: (id?: string) => void; 12 | onToggleSubMenu?: (id?: string) => void; 13 | }; 14 | -------------------------------------------------------------------------------- /src/icons/edit.tsx: -------------------------------------------------------------------------------- 1 | const SvgComponent = () => ( 2 | 12 | 13 | 14 | 15 | ); 16 | 17 | export default SvgComponent; 18 | -------------------------------------------------------------------------------- /src/effects/useMenuHidden.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | type Dir = "left" | "right" | null; 4 | 5 | function useMenuHidden( 6 | menuLeft: number, 7 | menuWidth: number, 8 | cb: (dir: Dir) => void 9 | ) { 10 | useEffect(() => { 11 | let dir: Dir; 12 | if (menuLeft < 0) { 13 | dir = "left"; 14 | } else if (menuLeft + menuWidth > window.innerWidth) { 15 | dir = "right"; 16 | } else { 17 | dir = null; 18 | } 19 | cb(dir); 20 | }, [menuLeft, menuWidth]); 21 | } 22 | 23 | export { useMenuHidden }; 24 | -------------------------------------------------------------------------------- /src/components/menu-container/menu-container.model.ts: -------------------------------------------------------------------------------- 1 | import { MenuHeadProps } from "../../models/menu-head.model"; 2 | 3 | export type MenuContainerProps = Pick & { 4 | shouldFlipVertical: boolean; 5 | menuPosition: { 6 | left: number; 7 | top?: number; 8 | bottom?: number; 9 | }; 10 | headPosition: { 11 | x: number; 12 | y: number; 13 | }; 14 | open: boolean | null; 15 | onClose: () => void; 16 | onMenuRender: (h: number, w: number) => void; 17 | closeImmediate?: boolean; 18 | onSelect?: (path: string) => void; 19 | }; -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | "postcss-preset-env": { 4 | stage: 2, 5 | features: { 6 | "nesting-rules": true, 7 | "custom-media-queries": true, 8 | }, 9 | }, 10 | autoprefixer: {}, 11 | ...(process.env.NODE_ENV === "production" && { 12 | cssnano: { 13 | preset: [ 14 | "default", 15 | { 16 | discardComments: { 17 | removeAll: true, 18 | }, 19 | normalizeUnicode: false, 20 | }, 21 | ], 22 | }, 23 | }), 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /src/effects/useCloseOnEscape.ts: -------------------------------------------------------------------------------- 1 | import { MutableRefObject, useEffect } from "react"; 2 | 3 | function useCloseOnEscape( 4 | ref: MutableRefObject, 5 | onClose: () => void 6 | ) { 7 | useEffect(() => { 8 | const handleEscape = (e: KeyboardEvent) => { 9 | if (e.key === "Escape") { 10 | onClose?.(); 11 | } 12 | }; 13 | 14 | ref.current?.addEventListener("keyup", handleEscape); 15 | 16 | return () => { 17 | ref.current?.removeEventListener("keyup", handleEscape); 18 | }; 19 | }, [ref]); 20 | } 21 | 22 | export { useCloseOnEscape }; 23 | -------------------------------------------------------------------------------- /globals.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.module.css" { 2 | const classes: { [key: string]: string }; 3 | export default classes; 4 | } 5 | 6 | declare module "*.module.scss" { 7 | const classes: { [key: string]: string }; 8 | export default classes; 9 | } 10 | 11 | declare module "*.module.sass" { 12 | const classes: { [key: string]: string }; 13 | export default classes; 14 | } 15 | 16 | declare module "*.module.less" { 17 | const classes: { [key: string]: string }; 18 | export default classes; 19 | } 20 | 21 | declare module "*.module.styl" { 22 | const classes: { [key: string]: string }; 23 | export default classes; 24 | } 25 | -------------------------------------------------------------------------------- /src/effects/useCloseOnClick.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect } from "react"; 2 | 3 | function useCloseOnClick( 4 | ref: RefObject, 5 | menuOpen: boolean | undefined | null, 6 | onClose: () => void 7 | ) { 8 | useEffect(() => { 9 | const handleClick = (e: MouseEvent) => { 10 | if (ref.current && !ref.current.contains(e.target as Node) && menuOpen) { 11 | onClose(); 12 | } 13 | }; 14 | 15 | document.addEventListener("pointerdown", handleClick); 16 | 17 | return () => { 18 | document.removeEventListener("pointerdown", handleClick); 19 | }; 20 | }, [ref, onClose, menuOpen]); 21 | } 22 | 23 | export { useCloseOnClick }; 24 | -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react-swc"; 2 | import { defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | clearScreen: true, 6 | plugins: [react()], 7 | test: { 8 | cache: { 9 | dir: "./vitest_cache", 10 | }, 11 | coverage: { 12 | enabled: true, 13 | provider: "v8", 14 | reporter: ["text", "json", "html", "lcov", "clover"], 15 | reportsDirectory: "./coverage", 16 | }, 17 | environment: "jsdom", 18 | globals: true, 19 | include: ["src/**/*test.tsx"], 20 | maxThreads: 10, 21 | minThreads: 2, 22 | setupFiles: "./jest-setup.ts", 23 | // silent: true, 24 | threads: true, 25 | update: true, 26 | watch: true, 27 | }, 28 | }); 29 | -------------------------------------------------------------------------------- /src/components/menu/menu-model.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { MenuHeadProps } from "../../models/menu-head.model"; 3 | 4 | export interface MenuItemProps { 5 | name: string; 6 | id?: string; 7 | onSelected?: (id: string, name: string) => void; 8 | items?: MenuItemProps[]; 9 | selected?: boolean; 10 | icon?: ReactNode; 11 | } 12 | 13 | export type MenuProps = Pick & { 14 | menuHeadPosition?: { 15 | x: number; 16 | y: number; 17 | }; 18 | open?: boolean | null; 19 | onClose?: () => void; 20 | closeImmediate?: boolean; 21 | onRender?: (height: number, width: number) => void; 22 | isSubMenu?: boolean; 23 | disableAnimation?: boolean; 24 | onSelect?: (path: string, index?: number) => void; 25 | }; 26 | -------------------------------------------------------------------------------- /.github/workflows/webpack.yml: -------------------------------------------------------------------------------- 1 | name: Build with Vite 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [20.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - uses: pnpm/action-setup@v4 21 | with: 22 | version: 10.x 23 | 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: "pnpm" 29 | 30 | - name: Install dependencies 31 | run: pnpm install --frozen-lockfile 32 | 33 | - name: Type check 34 | run: pnpm typecheck 35 | 36 | - name: Build library 37 | run: pnpm build 38 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": [ 6 | "DOM", 7 | "DOM.Iterable", 8 | "ESNext" 9 | ], 10 | "allowJs": true, 11 | "skipLibCheck": false, 12 | "esModuleInterop": false, 13 | "allowSyntheticDefaultImports": true, 14 | "strict": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "module": "ESNext", 17 | "moduleResolution": "Node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "jsx": "react-jsx", 21 | "noUnusedLocals": false, 22 | "rootDir": ".", 23 | "baseUrl": ".", 24 | "types": [ 25 | "cypress", 26 | "@4tw/cypress-drag-drop" 27 | ] 28 | }, 29 | "include": [ 30 | "../cypress/**/*.ts" 31 | ], 32 | "exclude": [ 33 | "node_modules", 34 | ] 35 | } -------------------------------------------------------------------------------- /src/models/menu-head.model.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { MenuItemProps } from "../components/menu/menu-model"; 3 | import { Position } from "./position"; 4 | 5 | export interface Theme { 6 | menuBackgroundColor?: string; 7 | menuItemHoverColor?: string; 8 | menuItemHoverTextColor?: string; 9 | primary?: string; 10 | secondary?: string; 11 | menuItemTextColor?: string; 12 | } 13 | 14 | export interface MenuHeadProps { 15 | autoFlipMenu?: boolean; 16 | bringMenuToFocus?: boolean; 17 | children?: ReactNode; 18 | closeOnClickOutside?: boolean; 19 | dimension?: number; 20 | disableHeader?: boolean; 21 | items?: MenuItemProps[]; 22 | onSelect?: (path: string) => void; 23 | shape?: "circle" | "square"; 24 | startOffset?: number; 25 | startPosition?: Position; 26 | theme?: Theme; 27 | width?: number; 28 | iconSize?: string; 29 | pin?: boolean; 30 | RTL?: boolean; 31 | } 32 | -------------------------------------------------------------------------------- /.github/workflows/test-and-lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint and Unit Test 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [20.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - uses: pnpm/action-setup@v4 21 | with: 22 | version: 10.x 23 | 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: "pnpm" 29 | 30 | - name: Install dependencies 31 | run: pnpm install --frozen-lockfile 32 | 33 | - name: Type check 34 | run: pnpm typecheck 35 | 36 | - name: Lint 37 | run: pnpm lint:all 38 | 39 | - name: Unit tests 40 | run: pnpm test:coverage 41 | -------------------------------------------------------------------------------- /src/effects/useKeyboardNav.ts: -------------------------------------------------------------------------------- 1 | import { MutableRefObject, useEffect, useRef } from "react"; 2 | 3 | function useKeyboardNav( 4 | ref: MutableRefObject, 5 | items: U[], 6 | onNav: (newIndex: number) => void 7 | ) { 8 | const activeIndex = useRef(0); 9 | 10 | useEffect(() => { 11 | const handleNavigation = (ev: KeyboardEvent) => { 12 | if (ev.key === "ArrowDown" || ev.key === "ArrowUp") { 13 | let nextIndex = activeIndex.current + (ev.key === "ArrowDown" ? 1 : -1); 14 | 15 | if (nextIndex < 0) { 16 | nextIndex = items.length - 1; 17 | } else if (nextIndex > items.length - 1) { 18 | nextIndex = 0; 19 | } 20 | 21 | activeIndex.current = nextIndex; 22 | 23 | onNav(nextIndex); 24 | } 25 | }; 26 | 27 | ref.current?.addEventListener("keyup", handleNavigation); 28 | 29 | return () => { 30 | ref.current?.removeEventListener("keyup", handleNavigation); 31 | }; 32 | }, [ref, items]); 33 | } 34 | 35 | export { useKeyboardNav }; 36 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Prabhu Murthy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": [ 6 | "DOM", 7 | "DOM.Iterable", 8 | "ESNext" 9 | ], 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": false, 13 | "allowSyntheticDefaultImports": true, 14 | "strict": false, 15 | "forceConsistentCasingInFileNames": true, 16 | "module": "ESNext", 17 | "moduleResolution": "bundler", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "jsx": "react-jsx", 21 | "declaration": true, 22 | "declarationDir": "./dist", 23 | "outDir": "./dist", 24 | "noUnusedLocals": false, 25 | "rootDir": ".", 26 | "types": [ 27 | "@types/node", 28 | "react", 29 | "react-dom", 30 | // "@testing-library/jest-dom", 31 | "@types/testing-library__jest-dom" 32 | ] 33 | }, 34 | "include": [ 35 | "src/**/*", 36 | "./globals.d.ts", 37 | "./jest-setup.ts" 38 | ], 39 | "exclude": [ 40 | "node_modules", 41 | "dist", 42 | "vite.config.ts", 43 | "src/**/*.d.ts", 44 | ] 45 | } -------------------------------------------------------------------------------- /src/components/menu-container/menu-container.module.scss: -------------------------------------------------------------------------------- 1 | .menu_container { 2 | display: flex; 3 | align-items: flex-start; 4 | justify-content: center; 5 | position: absolute; 6 | width: var(--rc-fltmenu-width, 250px); 7 | z-index: 99999; 8 | 9 | &.open { 10 | visibility: visible; 11 | } 12 | 13 | &.close { 14 | visibility: hidden; 15 | } 16 | } 17 | 18 | .menu_arrow { 19 | display: flex; 20 | height: 10px; 21 | align-items: center; 22 | justify-content: center; 23 | 24 | &::after { 25 | content: ""; 26 | display: block; 27 | position: absolute; 28 | left: 50%; 29 | transform: translateX(-50%); 30 | z-index: 1; 31 | } 32 | 33 | &.menu_open:not(.flip)::after { 34 | width: 0; 35 | height: 0; 36 | border-left: 10px solid transparent; 37 | border-right: 10px solid transparent; 38 | border-bottom: 10px solid var(--rc-fltmenu-menu-bg-color, #fff); 39 | top: -10px; 40 | } 41 | 42 | &.menu_open.flip::after { 43 | width: 0; 44 | height: 0; 45 | border-left: 10px solid transparent; 46 | border-right: 10px solid transparent; 47 | border-top: 10px solid var(--rc-fltmenu-menu-bg-color, #fff); 48 | bottom: -10px; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/cypress.yml.disabled: -------------------------------------------------------------------------------- 1 | name: Cypress Tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | cypress-run: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: [20.x] 15 | 16 | steps: 17 | - name: Setup Node.js 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | 22 | - name: Install pnpm 23 | uses: pnpm/action-setup@v4 24 | with: 25 | version: 10.x 26 | 27 | - name: Checkout 28 | uses: actions/checkout@v4 29 | 30 | - name: Install dependencies 31 | run: pnpm install --frozen-lockfile 32 | 33 | - name: Chrome 34 | uses: cypress-io/github-action@v6 35 | with: 36 | install: false 37 | start: pnpm dev 38 | wait-on: "http://localhost:5173" 39 | wait-on-timeout: 200 40 | browser: chrome 41 | 42 | - name: Edge 43 | uses: cypress-io/github-action@v6 44 | with: 45 | install: false 46 | start: pnpm dev 47 | wait-on: "http://localhost:5173" 48 | wait-on-timeout: 200 49 | browser: edge 50 | -------------------------------------------------------------------------------- /cypress/support/component.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/component.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | 22 | import { mount } from 'cypress/react' 23 | 24 | // Augment the Cypress namespace to include type definitions for 25 | // your custom command. 26 | // Alternatively, can be defined in cypress/support/component.d.ts 27 | // with a at the top of your spec. 28 | declare global { 29 | namespace Cypress { 30 | interface Chainable { 31 | mount: typeof mount 32 | } 33 | } 34 | } 35 | 36 | Cypress.Commands.add('mount', mount) 37 | 38 | // Example use: 39 | // cy.mount() -------------------------------------------------------------------------------- /src/components/main/main.module.scss: -------------------------------------------------------------------------------- 1 | .menu_head { 2 | align-items: center; 3 | background: var(--rc-fltmenu-primary); 4 | display: flex; 5 | height: var(--dimension); 6 | justify-content: center; 7 | width: var(--dimension); 8 | cursor: pointer; 9 | user-select: none; 10 | z-index: 99999; 11 | 12 | &:not(.is_dragged) { 13 | transition: left 0.1s ease, top 0.1s ease; 14 | } 15 | 16 | &.circle { 17 | border-radius: 50%; 18 | } 19 | 20 | &.square { 21 | border-radius: 4px; 22 | } 23 | 24 | &:focus { 25 | outline: 1px solid var(--rc-fltmenu-primary); 26 | outline-offset: 3px; 27 | outline-width: 2px; 28 | } 29 | } 30 | 31 | @keyframes pressed-animation { 32 | 0% { 33 | transform: scale(1); 34 | } 35 | 36 | 100% { 37 | transform: scale(0.75); 38 | } 39 | } 40 | 41 | @keyframes released-animation { 42 | 0% { 43 | transform: scale(0.75); 44 | } 45 | 46 | 100% { 47 | transform: scale(1); 48 | } 49 | } 50 | 51 | .pressed { 52 | animation: pressed-animation 0.2s ease-in-out; 53 | transform: scale(0.75); 54 | } 55 | 56 | .released { 57 | animation: released-animation 0.2s ease-in-out; 58 | transform: scale(1); 59 | } 60 | 61 | .icon_container { 62 | display: flex; 63 | align-items: center; 64 | justify-content: center; 65 | color: #fff; 66 | 67 | svg { 68 | width: 80%; 69 | height: 80%; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | // import React from "react"; 2 | import { 3 | CopyIcon, 4 | EditIcon, 5 | FileIcon, 6 | LogoutIcon, 7 | PlusIcon, 8 | SaveIcon, 9 | } from "./icons"; 10 | import { Menu } from "./react-float-menu"; 11 | 12 | function App() { 13 | return ( 14 |
15 | , name: "File" }, 20 | { 21 | icon: , 22 | items: [ 23 | { items: [{ name: "Cut 1" }, { name: "Cut 2" }], name: "Cut" }, 24 | { name: "Select All" }, 25 | ], 26 | name: "Edit", 27 | }, 28 | { icon: , name: "Add" }, 29 | { 30 | icon: , 31 | items: [ 32 | { icon: , name: "Copy from clipboard" }, 33 | { icon: , name: "Copy selection" }, 34 | ], 35 | name: "Copy", 36 | }, 37 | { icon: , name: "Save" }, 38 | { icon: , name: "Logout" }, 39 | ]} 40 | shape="square" 41 | startPosition="top left" 42 | width={250} 43 | onSelect={(val) => console.log(val)} 44 | > 45 | 46 | 47 |
48 | ); 49 | } 50 | 51 | export default App; 52 | -------------------------------------------------------------------------------- /src/effects/useMenuToFront.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | type params = { 4 | startOffset: number; 5 | menuHiddenTowards?: "left" | "right" | null; 6 | menuWidth: number; 7 | dimension: number; 8 | menuOpen?: boolean | null; 9 | }; 10 | 11 | type func = (p: params) => { 12 | position: { 13 | headLeft: number; 14 | menuLeft: number; 15 | }; 16 | }; 17 | 18 | const useMenuToFront: func = function ({ 19 | startOffset, 20 | menuHiddenTowards, 21 | menuWidth, 22 | dimension, 23 | menuOpen, 24 | }) { 25 | const [position, setPosition] = useState<{ 26 | menuLeft: number; 27 | headLeft: number; 28 | }>({ headLeft: 0, menuLeft: 0 }); 29 | 30 | useEffect(() => { 31 | if (!menuOpen) { 32 | return; 33 | } 34 | 35 | const headHalfWidth = Math.round(dimension / 2); 36 | 37 | if (menuHiddenTowards === "left") { 38 | setPosition({ 39 | headLeft: Math.round(menuWidth / 2) - headHalfWidth + startOffset, 40 | menuLeft: startOffset, 41 | }); 42 | } else if (menuHiddenTowards === "right") { 43 | setPosition({ 44 | headLeft: Math.round(innerWidth - menuWidth / 2) - headHalfWidth - 10, 45 | menuLeft: innerWidth - menuWidth - startOffset, 46 | }); 47 | } 48 | }, [menuHiddenTowards, menuOpen, menuWidth]); 49 | 50 | return { 51 | position, 52 | }; 53 | }; 54 | 55 | export { useMenuToFront }; 56 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, or religion. 6 | 7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 8 | 9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 10 | 11 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 12 | 13 | This Code of Conduct is adapted from the [Contributor Covenant](http:contributor-covenant.org), version 1.0.0, available at https://www.contributor-covenant.org/version/1/0/0/code-of-conduct.html -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************** 3 | // This example commands.ts shows you how to 4 | // create various custom commands and overwrite 5 | // existing commands. 6 | // 7 | // For more comprehensive examples of custom 8 | // commands please read more here: 9 | // https://on.cypress.io/custom-commands 10 | // *********************************************** 11 | // 12 | // 13 | // -- This is a parent command -- 14 | // Cypress.Commands.add('login', (email, password) => { ... }) 15 | // 16 | // 17 | // -- This is a child command -- 18 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 19 | // 20 | // 21 | // -- This is a dual command -- 22 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 23 | // 24 | // 25 | // -- This will overwrite an existing command -- 26 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 27 | // 28 | // declare global { 29 | // namespace Cypress { 30 | // interface Chainable { 31 | // login(email: string, password: string): Chainable 32 | // drag(subject: string, options?: Partial): Chainable 33 | // dismiss(subject: string, options?: Partial): Chainable 34 | // visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable 35 | // } 36 | // } 37 | // } -------------------------------------------------------------------------------- /src/utils/helpers.ts: -------------------------------------------------------------------------------- 1 | import { RefObject } from "react"; 2 | import { Position } from "../models/position"; 3 | 4 | export const getStartingPosition = (pos: Position, offset: number = 10) => { 5 | switch (pos) { 6 | case "top left": 7 | return `left: ${offset}px;top: ${offset}px;`; 8 | case "top right": 9 | return `right: ${offset}px;top: ${offset}px;`; 10 | case "bottom left": 11 | return `left: ${offset}px;bottom: ${offset}px;`; 12 | case "bottom right": 13 | return `right: ${offset}px;bottom: ${offset}px;`; 14 | default: 15 | return `left: ${offset}px;top: ${offset}px;`; 16 | } 17 | }; 18 | 19 | export const getLeft = (left: number, dimension: number) => { 20 | if (left < 0) { 21 | return 0; 22 | } else if (left + dimension > window.innerWidth) { 23 | return window.innerWidth - dimension; 24 | } else { 25 | return left; 26 | } 27 | }; 28 | 29 | export const getTop = (top: number, dimension: number) => { 30 | if (top < 0) { 31 | return 0; 32 | } else if (top + dimension > window.innerHeight) { 33 | return window.innerHeight - dimension; 34 | } else { 35 | return top; 36 | } 37 | }; 38 | 39 | export type positionParams = { 40 | onPointerDown: () => void; 41 | onPointerUp: () => void; 42 | onDragStart: (p: { left: number; top: number }) => void; 43 | onDragEnd: (p: { left: number; top: number }) => void; 44 | onClosed: () => void; 45 | startPosition: Position; 46 | dimension?: number; 47 | startOffset?: number; 48 | onInit: (p: { left: number; top: number }) => void; 49 | pin?: boolean; 50 | }; 51 | 52 | export type usePositionType = ( 53 | p: positionParams 54 | ) => { 55 | setup: (node: T) => void; 56 | ref: RefObject; 57 | }; 58 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react-swc"; 2 | import { defineConfig } from "vite"; 3 | import dts from "vite-plugin-dts"; 4 | import { resolve } from "path"; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | esbuild: { 9 | logOverride: { "this-is-undefined-in-esm": "silent" }, 10 | }, 11 | plugins: [ 12 | react(), 13 | dts({ 14 | include: ["src", "globals.d.ts"], 15 | exclude: ["src/**/*.test.tsx", "src/**/*.scss"], 16 | rollupTypes: true, 17 | tsconfigPath: "./tsconfig.json", 18 | logLevel: "error", 19 | compilerOptions: { 20 | skipLibCheck: true, 21 | }, 22 | }), 23 | ], 24 | build: { 25 | lib: { 26 | entry: resolve(__dirname, "src/react-float-menu.ts"), 27 | name: "ReactFloatMenu", 28 | }, 29 | rollupOptions: { 30 | external: ["react", "react-dom"], 31 | output: [ 32 | { 33 | format: "es", 34 | entryFileNames: "react-float-menu.esm.js", 35 | chunkFileNames: "[name].[hash].esm.js", 36 | dir: "dist", 37 | }, 38 | { 39 | format: "cjs", 40 | entryFileNames: "react-float-menu.cjs", 41 | chunkFileNames: "[name].[hash].cjs", 42 | dir: "dist", 43 | }, 44 | { 45 | format: "umd", 46 | name: "ReactFloatMenu", 47 | entryFileNames: "react-float-menu.umd.js", 48 | chunkFileNames: "[name].[hash].umd.js", 49 | globals: { 50 | react: "React", 51 | "react-dom": "ReactDOM", 52 | }, 53 | dir: "dist", 54 | }, 55 | ], 56 | }, 57 | sourcemap: true, 58 | minify: "terser", 59 | cssCodeSplit: true, 60 | }, 61 | }); 62 | -------------------------------------------------------------------------------- /src/components/menu/__tests__/menu.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, waitFor } from "@testing-library/react"; 2 | import userEvent from "@testing-library/user-event"; 3 | import { vi } from "vitest"; 4 | import { Menu } from "../index"; 5 | 6 | // should render menu items 7 | describe("Menu", () => { 8 | it("renders menu items", () => { 9 | const { getByText } = render( 10 | 23 | ); 24 | expect(getByText("Item 1")).toBeInTheDocument(); 25 | expect(getByText("Item 2")).toBeInTheDocument(); 26 | expect(getByText("Item 3")).toBeInTheDocument(); 27 | }); 28 | 29 | // should call onClose when the close button is clicked 30 | it("calls onClose when the close button is clicked", async () => { 31 | const onClose = vi.fn(); 32 | const { getByLabelText } = render( 33 | 47 | ); 48 | 49 | const closeButton = getByLabelText("Close"); 50 | 51 | expect(closeButton).toBeInTheDocument(); 52 | 53 | userEvent.pointer({ 54 | keys: "[MouseLeft]", 55 | target: closeButton, 56 | }); 57 | 58 | await waitFor(() => { 59 | expect(onClose).not.toBeCalled(); 60 | }); 61 | }); 62 | 63 | // should not render the header 64 | it("should not render the header", () => { 65 | const { queryByLabelText } = render( 66 | 80 | ); 81 | 82 | expect(queryByLabelText("Close")).not.toBeInTheDocument(); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/components/menu-container/menu-container.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { FunctionComponent, memo, useContext, useMemo } from "react"; 3 | import { MenuContext } from "../context"; 4 | import { Menu } from "../menu"; 5 | import { MenuContainerProps } from "./menu-container.model"; 6 | import styles from "./menu-container.module.scss"; 7 | 8 | const MenuContainer: FunctionComponent = memo( 9 | ({ 10 | closeImmediate, 11 | disableHeader, 12 | headPosition, 13 | menuPosition, 14 | onClose, 15 | onMenuRender, 16 | onSelect, 17 | open, 18 | shouldFlipVertical, 19 | }) => { 20 | const { left, top, bottom } = menuPosition; 21 | 22 | const { items, width, theme } = useContext(MenuContext); 23 | 24 | const menuContainerStyle = useMemo(() => { 25 | return { 26 | "--rc-fltmenu-menu-bg-color": theme?.menuBackgroundColor, 27 | "--rc-fltmenu-width": `${width}px`, 28 | [shouldFlipVertical ? "bottom" : "top"]: `${ 29 | shouldFlipVertical ? bottom : top 30 | }px`, 31 | left: `${left}px`, 32 | }; 33 | }, [ 34 | shouldFlipVertical, 35 | width, 36 | left, 37 | top, 38 | bottom, 39 | theme?.menuBackgroundColor, 40 | ]); 41 | 42 | const arrowClass = useMemo( 43 | () => 44 | classNames( 45 | styles.menu_arrow, 46 | open ? styles.menu_open : styles.menu_close, 47 | shouldFlipVertical ? styles.flip : "" 48 | ), 49 | [open, shouldFlipVertical] 50 | ); 51 | 52 | const menuContainerClass = useMemo( 53 | () => 54 | classNames(styles.menu_container, open ? styles.open : styles.close), 55 | [open] 56 | ); 57 | 58 | return ( 59 |
64 | 65 | 75 |
76 | ); 77 | } 78 | ); 79 | 80 | MenuContainer.displayName = "MenuContainer"; 81 | 82 | export { MenuContainer }; 83 | -------------------------------------------------------------------------------- /src/components/main/__tests__/main.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, waitFor, fireEvent } from "@testing-library/react"; 2 | // import userEvent from "@testing-library/user-event"; 3 | import { MenuHead } from "../index"; 4 | import styles from "../main.module.scss"; 5 | import { describe, it, beforeEach } from "vitest"; 6 | 7 | class MockPointerEvent {} 8 | 9 | describe("MenuHead", () => { 10 | beforeEach(() => { 11 | global.window.PointerEvent = MockPointerEvent as any; 12 | }); 13 | 14 | // should open the menu on click and all the menu items should be visible 15 | it("should open the menu on click and all the menu items should be visible", async () => { 16 | const { container, getByText } = render( 17 | 30 | ); 31 | 32 | const head = container.querySelector("[data-cy='rc-fltmenu-icon']"); 33 | 34 | if (head) { 35 | fireEvent.mouseDown(head.parentElement); 36 | 37 | await waitFor( 38 | () => { 39 | expect(getByText("Item 1")).toBeInTheDocument(); 40 | expect(getByText("Item 2")).toBeInTheDocument(); 41 | expect(getByText("Item 3")).toBeInTheDocument(); 42 | }, 43 | { 44 | timeout: 2000, 45 | } 46 | ); 47 | } 48 | }); 49 | 50 | // should menu head have the attribute --dimension 51 | it("should have the attribute --dimension", () => { 52 | const { container } = render(); 53 | 54 | const ele = container.querySelector("[data-cy='rc-fltmenu-head']"); 55 | 56 | expect(ele).toHaveAttribute("role", "button"); 57 | expect(ele).toHaveStyle("--dimension: 40px"); 58 | }); 59 | 60 | // menu head should have the square shape 61 | it("should have the square shape", () => { 62 | const { container } = render(); 63 | 64 | const ele = container.querySelector("[data-cy='rc-fltmenu-head']"); 65 | 66 | expect(ele).toBeInTheDocument(); 67 | expect(ele).toHaveClass(styles.square); 68 | }); 69 | 70 | // menu should be pinned to the top left of the screen 71 | it("should be pinned to the top left of the screen", () => { 72 | const { container } = render(); 73 | 74 | const ele = container.querySelector("[data-cy='rc-fltmenu-head']"); 75 | 76 | expect(ele).toBeInTheDocument(); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/components/menu/menu.module.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: flex-start; 5 | justify-content: flex-start; 6 | width: 100%; 7 | border-radius: 4px; 8 | background: var(--rc-fltmenu-menu-bg-color, #fff); 9 | box-shadow: 0 0 25px 8px rgb(0 0 0 / 10%); 10 | padding-bottom: 1rem; 11 | position: relative; 12 | } 13 | 14 | .list { 15 | list-style: none; 16 | margin: 0; 17 | padding: 0; 18 | width: 100%; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: flex-start; 22 | } 23 | 24 | @keyframes menu-open-animation { 25 | 0% { 26 | opacity: 0; 27 | height: 0; 28 | visibility: hidden; 29 | overflow: hidden; 30 | } 31 | 32 | 100% { 33 | opacity: 1; 34 | height: var(--menu-height, auto); 35 | visibility: visible; 36 | overflow: hidden; 37 | } 38 | } 39 | 40 | @keyframes menu-close-animation { 41 | 0% { 42 | opacity: 1; 43 | height: var(--menu-height, auto); 44 | visibility: visible; 45 | overflow: hidden; 46 | } 47 | 48 | 100% { 49 | opacity: 0; 50 | height: 0; 51 | visibility: hidden; 52 | overflow: hidden; 53 | } 54 | } 55 | 56 | .menu_open { 57 | &:not(.no_animation) { 58 | visibility: visible; 59 | animation: menu-open-animation 0.5s ease-in-out; 60 | } 61 | 62 | height: var(--menu-height, auto); 63 | } 64 | 65 | .menu_close:not(.no_animation) { 66 | visibility: hidden; 67 | animation: menu-close-animation 0.5s ease-in-out; 68 | height: 0; 69 | } 70 | 71 | .hide { 72 | visibility: hidden; 73 | } 74 | 75 | .close_btn { 76 | display: flex; 77 | align-items: center; 78 | justify-content: center; 79 | right: 0; 80 | width: 28px; 81 | height: 28px; 82 | z-index: 1; 83 | background: #f5f5f5; 84 | border-radius: 4px; 85 | cursor: pointer; 86 | color: var(--rc-fltmenu-primary); 87 | 88 | &.flip { 89 | margin-left: 4px; 90 | } 91 | 92 | &:not(.flip) { 93 | margin-right: 4px; 94 | } 95 | 96 | &:focus { 97 | outline: 2px solid var(--rc-fltmenu-primary); 98 | outline-offset: 1px; 99 | } 100 | 101 | &:hover { 102 | background: var(--rc-fltmenu-primary); 103 | color: #fff; 104 | } 105 | 106 | svg { 107 | width: 80%; 108 | height: 80%; 109 | } 110 | } 111 | 112 | .toolbar { 113 | display: flex; 114 | align-items: center; 115 | margin: 0.5rem 0; 116 | width: 100%; 117 | 118 | &.flip { 119 | justify-content: flex-start; 120 | } 121 | 122 | &:not(.flip) { 123 | justify-content: flex-end; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/components/menu-list-item/menu-list-item.module.scss: -------------------------------------------------------------------------------- 1 | .list_item { 2 | width: 100%; 3 | padding: 0.75rem 0; 4 | cursor: pointer; 5 | align-self: center; 6 | position: relative; 7 | touch-action: none; 8 | user-select: none; 9 | 10 | &.rtl { 11 | direction: rtl; 12 | } 13 | 14 | &.no_icon { 15 | display: flex; 16 | align-items: center; 17 | justify-content: flex-start; 18 | } 19 | 20 | &.icon { 21 | display: grid; 22 | grid-template-columns: calc(var(--rc-fltmenu-icon-size) * 2) auto; 23 | justify-items: flex-start; 24 | align-items: center; 25 | } 26 | 27 | &:hover { 28 | background: var(--rc-fltmenu-menu-item-hover, #f5f5f5); 29 | } 30 | 31 | & > span:not(.list_item_name) { 32 | color: var(--rc-fltmenu-primary); 33 | } 34 | 35 | &:hover > span { 36 | color: var(--rc-fltmenu-menu-item-hover-text, --rc-fltmenu-primary); 37 | } 38 | 39 | &:not(:last-child) { 40 | border-bottom: 1px solid #e5e5e5; 41 | } 42 | 43 | &:focus { 44 | outline: 1px solid var(--rc-fltmenu-primary); 45 | outline-offset: 2px; 46 | outline-width: 2px; 47 | } 48 | } 49 | 50 | .list_item_name { 51 | display: flex; 52 | align-items: center; 53 | color: var(--rc-fltmenu-menu-item-text, #000); 54 | 55 | &.no_icon:not(.rtl) { 56 | padding-left: var(--rc-fltmenu-icon-size, 1rem); 57 | } 58 | 59 | &.no_icon.rtl { 60 | padding-right: var(--rc-fltmenu-icon-size, 1rem); 61 | } 62 | } 63 | 64 | .list_item_icon { 65 | display: flex; 66 | align-items: center; 67 | justify-content: center; 68 | width: var(--rc-fltmenu-icon-size, 1rem); 69 | height: var(--rc-fltmenu-icon-size, 1rem); 70 | justify-self: center; 71 | 72 | svg { 73 | width: 100%; 74 | height: 100%; 75 | } 76 | } 77 | 78 | .child_menu_wrapper { 79 | display: flex; 80 | align-items: flex-start; 81 | justify-content: center; 82 | position: absolute; 83 | top: 0; 84 | 85 | &.menu_flip { 86 | left: -101%; 87 | } 88 | 89 | &:not(.menu_flip) { 90 | left: 101%; 91 | } 92 | } 93 | 94 | .chevron { 95 | display: flex; 96 | align-items: center; 97 | justify-content: center; 98 | position: absolute; 99 | top: 50%; 100 | height: 1rem; 101 | width: 1rem; 102 | 103 | svg { 104 | width: 100%; 105 | height: 100%; 106 | } 107 | } 108 | 109 | .chevron_right { 110 | composes: chevron; 111 | right: 0.5rem; 112 | transform: translateY(-50%); 113 | } 114 | 115 | .chevron_left { 116 | composes: chevron; 117 | left: 0.5rem; 118 | transform: translateY(-50%) rotate(180deg); 119 | } 120 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import globals from "globals"; 3 | import react from "eslint-plugin-react"; 4 | import reactHooks from "eslint-plugin-react-hooks"; 5 | import reactRefresh from "eslint-plugin-react-refresh"; 6 | import typescript from "@typescript-eslint/eslint-plugin"; 7 | import typescriptParser from "@typescript-eslint/parser"; 8 | import sortKeysFix from "eslint-plugin-sort-keys-fix"; 9 | import cypress from "eslint-plugin-cypress"; 10 | import prettier from "eslint-config-prettier"; 11 | 12 | export default [ 13 | { 14 | ignores: [ 15 | "dist", 16 | "coverage", 17 | "node_modules", 18 | "vitest_cache", 19 | ".git", 20 | "cypress", 21 | ], 22 | }, 23 | { 24 | files: ["**/*.{js,jsx,ts,tsx}"], 25 | languageOptions: { 26 | ecmaVersion: 2021, 27 | sourceType: "module", 28 | globals: { 29 | ...globals.browser, 30 | ...globals.node, 31 | ...globals.es2021, 32 | }, 33 | parser: typescriptParser, 34 | parserOptions: { 35 | ecmaFeatures: { 36 | jsx: true, 37 | }, 38 | }, 39 | }, 40 | plugins: { 41 | react, 42 | "react-hooks": reactHooks, 43 | "react-refresh": reactRefresh, 44 | "@typescript-eslint": typescript, 45 | "sort-keys-fix": sortKeysFix, 46 | }, 47 | rules: { 48 | ...js.configs.recommended.rules, 49 | ...typescript.configs.recommended.rules, 50 | ...react.configs.recommended.rules, 51 | ...reactHooks.configs.recommended.rules, 52 | ...prettier.rules, 53 | 54 | "react/jsx-sort-props": [ 55 | "error", 56 | { 57 | callbacksLast: true, 58 | ignoreCase: false, 59 | noSortAlphabetically: false, 60 | shorthandFirst: true, 61 | }, 62 | ], 63 | "react/prop-types": "off", 64 | "react/react-in-jsx-scope": "off", 65 | "react-refresh/only-export-components": "warn", 66 | "sort-keys": ["error", "asc", { caseSensitive: true, natural: true }], 67 | "sort-keys-fix/sort-keys-fix": "warn", 68 | "@typescript-eslint/no-explicit-any": "warn", 69 | }, 70 | settings: { 71 | react: { 72 | version: "detect", 73 | }, 74 | }, 75 | }, 76 | { 77 | files: ["**/*.test.{ts,tsx}"], 78 | languageOptions: { 79 | globals: { 80 | describe: "readonly", 81 | it: "readonly", 82 | expect: "readonly", 83 | beforeEach: "readonly", 84 | afterEach: "readonly", 85 | }, 86 | }, 87 | }, 88 | { 89 | files: ["cypress/**/*.{js,ts}"], 90 | plugins: { 91 | cypress, 92 | }, 93 | rules: { 94 | ...cypress.configs.recommended.rules, 95 | }, 96 | }, 97 | ]; 98 | -------------------------------------------------------------------------------- /src/effects/usePosition.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from "react"; 2 | import { 3 | getLeft, 4 | getStartingPosition, 5 | getTop, 6 | positionParams, 7 | usePositionType, 8 | } from "../utils/helpers"; 9 | 10 | type Settings = positionParams; 11 | 12 | const usePosition: usePositionType = ( 13 | settings: Settings 14 | ) => { 15 | const { 16 | onPointerDown, 17 | onPointerUp, 18 | onDragStart, 19 | onDragEnd, 20 | startPosition, 21 | dimension = 0, 22 | startOffset, 23 | onInit, 24 | pin, 25 | onClosed, 26 | } = settings; 27 | 28 | const ref = useRef(null); 29 | const isClicked = useRef(false); 30 | const isDragged = useRef(false); 31 | const keyPressed = useRef(false); 32 | 33 | const positionRef = useRef<{ left: number; top: number }>({ 34 | left: 0, 35 | top: 0, 36 | }); 37 | 38 | const handlePointerDown = (ev: PointerEvent | KeyboardEvent) => { 39 | isClicked.current = true; 40 | const ele = ev.target as HTMLElement; 41 | ev.stopPropagation(); 42 | 43 | if (ev instanceof PointerEvent) { 44 | keyPressed.current = false; 45 | ele.setPointerCapture(ev.pointerId); 46 | } else if (ev instanceof KeyboardEvent) { 47 | keyPressed.current = true; 48 | 49 | if (ev.key === "Escape") { 50 | onClosed(); 51 | } 52 | 53 | if (ev.key !== "Enter") { 54 | return; 55 | } 56 | } 57 | 58 | onPointerDown?.(); 59 | }; 60 | 61 | const handlePointerUp = (ev: PointerEvent | KeyboardEvent) => { 62 | isClicked.current = false; 63 | 64 | const ele = ev.target as HTMLElement; 65 | 66 | if (ev instanceof PointerEvent) { 67 | ele.releasePointerCapture(ev.pointerId); 68 | } else if (ev instanceof KeyboardEvent && ev.key !== "Enter") { 69 | return; 70 | } 71 | 72 | if (!isDragged.current) { 73 | onPointerUp?.(); 74 | } else { 75 | isDragged.current = false; 76 | onDragEnd?.(positionRef.current); 77 | } 78 | }; 79 | 80 | const onPointerMove = (e: PointerEvent) => { 81 | if (isClicked.current && ref.current && !keyPressed.current) { 82 | const halfWidth = Math.round(dimension / 2); 83 | const x = e.clientX - halfWidth; 84 | const y = e.clientY - halfWidth; 85 | 86 | const position = { 87 | left: getLeft(x, dimension), 88 | top: getTop(y, dimension), 89 | }; 90 | 91 | if (!isDragged.current) { 92 | isDragged.current = true; 93 | onDragStart?.(position); 94 | } 95 | 96 | positionRef.current = position; 97 | ref.current.style.cssText += `top: ${position.top}px;left: ${position.left}px;`; 98 | } 99 | }; 100 | 101 | const setup = useCallback((node: T) => { 102 | if (node) { 103 | ref.current = node; 104 | node.addEventListener("pointerdown", handlePointerDown); 105 | node.addEventListener("keydown", handlePointerDown); 106 | node.addEventListener("pointerup", handlePointerUp); 107 | node.addEventListener("keyup", handlePointerUp); 108 | node.style.touchAction = "none"; 109 | node.style.cssText += `position: absolute;z-index: 9999;${getStartingPosition( 110 | startPosition, 111 | startOffset 112 | )}`; 113 | const { left, top } = node.getBoundingClientRect(); 114 | onInit({ 115 | left, 116 | top, 117 | }); 118 | } 119 | }, []); 120 | 121 | useEffect(() => { 122 | // attach drag handlers if not pinned 123 | if (!pin) { 124 | document.addEventListener("pointermove", onPointerMove); 125 | 126 | // cleanup 127 | return () => { 128 | document.removeEventListener("pointermove", onPointerMove); 129 | }; 130 | } 131 | }, []); 132 | 133 | return { 134 | ref, 135 | setup, 136 | }; 137 | }; 138 | 139 | export { usePosition }; 140 | -------------------------------------------------------------------------------- /src/components/menu-list-item/__tests__/menu-list-item.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, waitFor } from "@testing-library/react"; 2 | import userEvent from "@testing-library/user-event"; 3 | import { expect, it, vi } from "vitest"; 4 | import { MenuItem } from "../menu-list-item"; 5 | 6 | describe("MenuListItem", () => { 7 | it.concurrent("should render", () => { 8 | const { getByText } = render(); 9 | expect(getByText("menu item")).toBeInTheDocument(); 10 | }); 11 | 12 | // should have icon rendered 13 | it.concurrent("should have icon rendered", () => { 14 | const { getByText, getByRole } = render( 15 | icon} name="menu item" /> 16 | ); 17 | expect(getByText("icon")).toBeInTheDocument(); 18 | expect(getByRole("img")).toBeInTheDocument(); 19 | }); 20 | 21 | // should call onSelect when clicked 22 | it("should call onSelect when clicked", () => { 23 | const onSelect = vi.fn(); 24 | const { getByRole } = render( 25 | 26 | ); 27 | 28 | fireEvent.pointerDown(getByRole("listitem")); 29 | 30 | expect(onSelect).toBeCalled(); 31 | }); 32 | 33 | // should call onMouseEnter when mouse enters the menu item 34 | it("should call onMouseEnter when mouse enters the menu item", async () => { 35 | const onMouseEnter = vi.fn(); 36 | const user = userEvent.setup(); 37 | 38 | const { getByText } = render( 39 | , 45 | { container: document.body } 46 | ); 47 | 48 | expect(getByText("menu item 1")).toBeInTheDocument(); 49 | 50 | user.hover(getByText("menu item 1")); 51 | 52 | await waitFor(() => { 53 | expect(onMouseEnter).toBeCalled(); 54 | }); 55 | }); 56 | 57 | // should call onMouseLeave when mouse leaves the menu item 58 | it("should call onMouseLeave when mouse leaves the menu item", async () => { 59 | const user = userEvent.setup(); 60 | const onMouseLeave = vi.fn(); 61 | const { getByText } = render( 62 | , 68 | { container: document.body } 69 | ); 70 | 71 | expect(getByText("menu item 1")).toBeInTheDocument(); 72 | 73 | user.hover(getByText("menu item 1")); 74 | 75 | await waitFor(() => { 76 | expect(onMouseLeave).toBeCalled(); 77 | }); 78 | }); 79 | 80 | // trigger enter key on the menu item 81 | it("trigger enter key on the menu item", async () => { 82 | const user = userEvent.setup(); 83 | const onSelect = vi.fn(); 84 | const { getByText } = render( 85 | 86 | ); 87 | 88 | expect(getByText("menu item")).toBeInTheDocument(); 89 | 90 | user.click(getByText("menu item")); 91 | 92 | await waitFor(() => { 93 | expect(onSelect).toBeCalled(); 94 | }); 95 | }); 96 | 97 | // should open the sub menu when clicked 98 | it("should open the sub menu when clicked", async () => { 99 | const user = userEvent.setup(); 100 | const onToggleSubMenu = vi.fn(); 101 | const { getByText } = render( 102 | , 119 | { 120 | container: document.body, 121 | } 122 | ); 123 | 124 | user.click(getByText("File")); 125 | 126 | await waitFor(() => { 127 | expect(onToggleSubMenu).toBeCalled(); 128 | }); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-float-menu", 3 | "version": "0.4.7", 4 | "author": { 5 | "name": "Prabhu Murthy", 6 | "email": "prabhu.m.murthy@gmail.com" 7 | }, 8 | "homepage": "https://github.com/prabhuignoto/react-float-menu", 9 | "license": "MIT", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/prabhuignoto/react-float-menu" 13 | }, 14 | "description": "Smart draggable floating menu component for React", 15 | "keywords": [ 16 | "react", 17 | "float", 18 | "menu", 19 | "smart menu", 20 | "floating menu", 21 | "draggable menu" 22 | ], 23 | "publishConfig": { 24 | "registry": "https://registry.npmjs.org/", 25 | "cache": "cache/.npm" 26 | }, 27 | "type": "module", 28 | "scripts": { 29 | "dev": "vite", 30 | "build": "tsc && vite build", 31 | "preview": "vite preview", 32 | "typecheck": "tsc --noEmit", 33 | "lint:js": "eslint src/**/*.tsx", 34 | "lint:js-fix": "eslint src/**/*.tsx --fix", 35 | "lint:css": "stylelint src/**/*.scss", 36 | "lint:css-fix": "stylelint --fix src/**/*.scss", 37 | "lint:all": "pnpm lint:js && pnpm lint:css", 38 | "format": "prettier --write src/**/*.{tsx,scss}", 39 | "snyk:monitor": "pnpm dlx snyk monitor", 40 | "snyk:test": "pnpm dlx snyk test", 41 | "test": "pnpm vitest run -c ./vitest.config.ts", 42 | "cypress:open": "pnpm dlx cypress open", 43 | "cypress:quiet": "pnpm exec cypress run --headless --quiet --browser chrome", 44 | "test:dev": "vitest watch -c ./vitest.config.ts --silent", 45 | "test:coverage": "vitest run -c ./vitest.config.ts --coverage" 46 | }, 47 | "browserslist": [ 48 | "last 2 versions", 49 | "not op_mini all", 50 | "not ie <= 11" 51 | ], 52 | "dependencies": { 53 | "classnames": "^2.3.2", 54 | "nanoid": "^4.0.2", 55 | "react": "^18.0.0", 56 | "react-dom": "^18.0.0" 57 | }, 58 | "peerDependencies": { 59 | "react": "^17.0.0", 60 | "react-dom": "^17.0.0" 61 | }, 62 | "main": "dist/react-float-menu.cjs", 63 | "module": "dist/react-float-menu.esm.js", 64 | "types": "dist/index.d.ts", 65 | "exports": { 66 | ".": { 67 | "types": "./dist/index.d.ts", 68 | "import": "./dist/react-float-menu.esm.js", 69 | "require": "./dist/react-float-menu.cjs" 70 | }, 71 | "./package.json": "./package.json" 72 | }, 73 | "files": [ 74 | "dist" 75 | ], 76 | "devDependencies": { 77 | "@eslint/js": "^9.0.0", 78 | "@testing-library/dom": "^9.3.1", 79 | "@testing-library/jest-dom": "^5.17.0", 80 | "@testing-library/react": "^14.0.0", 81 | "@testing-library/user-event": "^14.4.3", 82 | "@types/node": "^20.11.0", 83 | "@types/react": "^18.2.45", 84 | "@types/react-dom": "^18.2.18", 85 | "@types/testing-library__jest-dom": "^5.14.8", 86 | "@typescript-eslint/eslint-plugin": "^8.0.0", 87 | "@typescript-eslint/parser": "^8.0.0", 88 | "@vitejs/plugin-react-swc": "^3.7.0", 89 | "@vitest/coverage-v8": "^2.0.0", 90 | "autoprefixer": "^10.4.20", 91 | "c8": "^9.1.0", 92 | "cssnano": "^7.0.0", 93 | "cypress": "^13.0.0", 94 | "eslint": "^9.0.0", 95 | "eslint-config-prettier": "^9.1.0", 96 | "eslint-plugin-cypress": "^3.1.0", 97 | "eslint-plugin-react": "^7.37.0", 98 | "eslint-plugin-react-hooks": "^5.0.0", 99 | "eslint-plugin-react-refresh": "^0.4.0", 100 | "eslint-plugin-sort-keys-fix": "^1.1.2", 101 | "globals": "^15.0.0", 102 | "husky": "^9.0.0", 103 | "jsdom": "^24.0.0", 104 | "lint-staged": "^15.0.0", 105 | "postcss": "^8.4.49", 106 | "postcss-preset-env": "^10.0.0", 107 | "postcss-scss": "^4.0.9", 108 | "prettier": "^3.3.0", 109 | "sass": "^1.81.0", 110 | "stylelint": "^16.0.0", 111 | "stylelint-config-prettier": "^9.0.5", 112 | "stylelint-config-standard": "^36.0.0", 113 | "stylelint-config-standard-scss": "^13.0.0", 114 | "typescript": "^5.9.3", 115 | "typescript-plugin-css-modules": "^5.0.1", 116 | "vite": "^6.0.0", 117 | "vite-plugin-dts": "^4.5.4", 118 | "vitest": "^2.0.0" 119 | } 120 | } -------------------------------------------------------------------------------- /src/components/menu-list-item/menu-list-item.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { 3 | KeyboardEvent, 4 | PointerEvent, 5 | useCallback, 6 | useContext, 7 | useMemo, 8 | } from "react"; 9 | import { ChevronRight } from "../../icons"; 10 | import { MenuContext } from "../context"; 11 | import { Menu } from "../menu"; 12 | import { MenuItemViewModel } from "./menu-list-item.model"; 13 | import styles from "./menu-list-item.module.scss"; 14 | 15 | const MenuItem = (props: MenuItemViewModel) => { 16 | const { 17 | name, 18 | icon, 19 | items = [], 20 | open, 21 | onSelect, 22 | index, 23 | id, 24 | onMouseEnter, 25 | onMouseLeave, 26 | onToggleSubMenu, 27 | selected, 28 | } = props; 29 | 30 | const { width = 250, RTL } = useContext(MenuContext); 31 | 32 | const itemClass = useMemo( 33 | () => 34 | classNames( 35 | styles.list_item, 36 | icon ? styles.icon : styles.no_icon, 37 | RTL ? styles.rtl : "" 38 | ), 39 | [icon] 40 | ); 41 | 42 | const canShowSubMenu = useMemo(() => items.length > 0 && selected, [ 43 | items.length, 44 | selected, 45 | ]); 46 | 47 | // handler for showing submenu on mouse enter 48 | const handleMouseEnter = useCallback((ev: PointerEvent) => { 49 | if (ev.pointerType === "mouse") { 50 | onMouseEnter?.(id); 51 | } 52 | }, []); 53 | 54 | // handler for hiding submenu on mouse leave 55 | const handleMouseLeave = useCallback((ev: PointerEvent) => { 56 | if (ev.pointerType === "mouse") { 57 | onMouseLeave?.(id); 58 | } 59 | }, []); 60 | 61 | // handler for opening a submenu or selecting menu item 62 | const handleClick = useCallback( 63 | (ev: PointerEvent) => { 64 | ev.stopPropagation(); 65 | ev.preventDefault(); 66 | 67 | if (items.length <= 0) { 68 | onSelect?.(name, index, id); 69 | } else { 70 | onToggleSubMenu?.(id); 71 | } 72 | }, 73 | [onSelect] 74 | ); 75 | 76 | // handler for opening submenu or selection 77 | const handleKeyUp = useCallback( 78 | (ev: KeyboardEvent) => { 79 | if (ev.key !== "Enter") { 80 | return; 81 | } 82 | ev.stopPropagation(); 83 | 84 | if (items.length > 0) { 85 | onToggleSubMenu?.(id); 86 | } else { 87 | onSelect?.(name, index, id); 88 | } 89 | }, 90 | [onSelect] 91 | ); 92 | 93 | return ( 94 |
  • 104 | {icon && ( 105 | 106 | {icon} 107 | 108 | )} 109 | 117 | {name} 118 | 119 | {items.length > 0 ? ( 120 | 125 | 126 | 127 | ) : null} 128 |
    136 | {canShowSubMenu && ( 137 | 145 | )} 146 |
    147 |
  • 148 | ); 149 | }; 150 | 151 | MenuItem.displayName = "MenuItem"; 152 | 153 | export { MenuItem }; 154 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | This is **react-float-menu**, a smart draggable floating menu component for React. It's a library distributed via npm that provides a configurable floating menu with features like edge detection, auto-flipping, keyboard navigation, and theme customization. 8 | 9 | ## Development Commands 10 | 11 | ### Building 12 | - **Build library**: `pnpm build` - Builds the library using Vite in production mode with multiple formats (ESM, CJS, UMD), type declarations, and source maps 13 | - **Type check**: `pnpm typecheck` - Run TypeScript type checking without emitting files 14 | 15 | ### Development Server 16 | - `pnpm dev` - Start Vite development server with hot module replacement for testing the component 17 | 18 | ### Testing 19 | - **Run all tests**: `pnpm test` - Runs all Vitest tests once 20 | - **Test in watch mode**: `pnpm test:dev` - Runs tests in watch mode with silent output 21 | - **Coverage report**: `pnpm test:coverage` - Generates test coverage report with HTML, LCOV, and text output 22 | - **E2E tests**: `pnpm cypress:open` - Opens Cypress for interactive E2E testing 23 | - **E2E headless**: `pnpm cypress:quiet` - Runs Cypress tests headless in Chrome 24 | 25 | Note: Test files are located at `src/**/*test.tsx` and use Vitest (v2) with jsdom environment. 26 | 27 | ### Linting & Formatting 28 | - **Lint JS**: `pnpm lint:js` - Lint TypeScript/TSX files with ESLint v9 (flat config) 29 | - **Fix JS**: `pnpm lint:js-fix` - Auto-fix linting issues 30 | - **Lint CSS**: `pnpm lint:css` - Lint SCSS files with Stylelint v16 31 | - **Fix CSS**: `pnpm lint:css-fix` - Auto-fix SCSS linting issues 32 | - **Lint all**: `pnpm lint:all` - Run both JS and CSS linting 33 | - **Format**: `pnpm format` - Format code using Prettier v3 34 | 35 | ## Architecture 36 | 37 | ### Entry Point 38 | - `src/react-float-menu.ts` - Main library export that exports the `Menu` component (alias for `MenuHead`) 39 | 40 | ### Core Components 41 | - **MenuHead** (`src/components/main/index.tsx`) - The main floating menu button component that orchestrates all functionality 42 | - Manages state for menu open/close, position, drag state 43 | - Handles menu positioning logic including edge detection and auto-flipping 44 | - Provides MenuContext to child components 45 | - **MenuContainer** (`src/components/menu-container/menu-container.tsx`) - Container for the actual menu content 46 | - **MenuItem** (`src/components/menu-list-item/menu-list-item.tsx`) - Individual menu items with submenu support 47 | - **Context** (`src/components/context.ts`) - React Context providing menu configuration to all child components 48 | 49 | ### Custom Hooks (src/effects/) 50 | The component uses several custom hooks for modular functionality: 51 | - **usePosition** - Handles dragging, positioning, and pointer events for the floating button 52 | - **useMenuHidden** - Detects when menu is hidden beyond screen edges 53 | - **useKeyboardNav** - Implements keyboard navigation within menus 54 | - **useCloseOnClick** - Closes menu when clicking outside 55 | - **useCloseOnEscape** - Closes menu on Escape key 56 | - **useMenuToFront** - Brings menu to focus when near screen edges 57 | 58 | ### Menu Item Model 59 | Menu items support hierarchical structure with `children` for submenus. Each item has: 60 | - `name` - Label text 61 | - `id` - Unique identifier (auto-generated if not provided) 62 | - `children` - Array of menu items for submenu 63 | - `icon` - Optional icon component 64 | - `selected` - Internal state for selected item 65 | 66 | ### Build Configuration 67 | - **Vite** (`vite.config.ts`) - Primary build tool for both development and library distribution 68 | - Entry: `src/react-float-menu.ts` 69 | - Output formats: ESM (`react-float-menu.esm.js`), CJS (`react-float-menu.cjs`), UMD (`react-float-menu.umd.js`) 70 | - Type declarations: Generated automatically with `vite-plugin-dts` → `dist/index.d.ts` (rolled up from all source files) 71 | - Important: `globals.d.ts` must be included in vite-plugin-dts config for SCSS module type declarations 72 | - Externals: React/ReactDOM are peer dependencies (not bundled) 73 | - Development: Uses SWC compiler (@vitejs/plugin-react-swc) for fast transpilation and HMR 74 | - Production: Minification with Terser, source maps enabled, CSS code-split 75 | - Package exports configured for proper ESM/CJS resolution with conditional exports 76 | - **PostCSS** (`postcss.config.js`) - Modern plugin stack 77 | - postcss-preset-env (stage 2) - Modern CSS syntax support 78 | - autoprefixer - Vendor prefixing 79 | - cssnano (production only) - CSS minification 80 | - **Vitest** (`vitest.config.ts`) - Test runner with jsdom environment 81 | - **ESLint** (`eslint.config.js`) - Flat config format (v9) for JS/TS linting 82 | - **Stylelint** - SCSS linting with modern standards config (v16) 83 | 84 | ### Styling 85 | - Uses SCSS modules for component styling with CSS Modules features 86 | - CSS variables for theming (primary color, dimensions, width) 87 | - Modern CSS syntax (nesting, custom properties) via PostCSS 88 | - Dart Sass compiler (v1.81+) for fast SCSS processing 89 | - **Type declarations**: `globals.d.ts` provides TypeScript declarations for `*.module.scss` imports 90 | 91 | ### Type System 92 | - `globals.d.ts` - Global type declarations for CSS/SCSS modules 93 | - `jest-setup.d.ts` - Test environment type declarations 94 | - TypeScript configured with `moduleResolution: "bundler"` for modern tooling compatibility 95 | - `skipLibCheck: true` to avoid conflicts with external package type definitions 96 | 97 | ### Build Notes 98 | - API Extractor may show an informational message about TypeScript version mismatch - this is harmless and doesn't affect build output 99 | - All three output formats (ESM, CJS, UMD) are tested and verified to work correctly 100 | - CSS is extracted into a single `react-float-menu.css` file 101 | - Source maps are generated for all output formats for easier debugging 102 | 103 | ## Key Features Implementation 104 | 105 | ### Auto-flip Menu 106 | The menu automatically flips vertically when near the bottom of the screen (controlled by `autoFlipMenu` prop and `shouldFlipVertical` logic in MenuHead). 107 | 108 | ### Edge Detection 109 | The `useMenuHidden` hook and related logic in MenuHead detect when the menu button is near screen edges and adjusts menu positioning accordingly (`bringMenuToFocus` prop). 110 | 111 | ### Draggable Button 112 | The `usePosition` hook implements dragging using pointer events (not when `pin` prop is set). 113 | 114 | ### Theme Customization 115 | Theme is merged with default theme (`src/utils/theme-default.ts`) and provided via context. CSS variables are set on the menu head element. 116 | -------------------------------------------------------------------------------- /src/components/menu/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { nanoid } from "nanoid"; 3 | import { 4 | CSSProperties, 5 | FunctionComponent, 6 | KeyboardEvent, 7 | memo, 8 | PointerEvent, 9 | useCallback, 10 | useContext, 11 | useMemo, 12 | useRef, 13 | useState, 14 | } from "react"; 15 | import { useCloseOnClick } from "../../effects/useCloseOnClick"; 16 | import { useCloseOnEscape } from "../../effects/useCloseOnEscape"; 17 | import { useKeyboardNav } from "../../effects/useKeyboardNav"; 18 | import { CloseIcon } from "../../icons"; 19 | import { MenuContext } from "../context"; 20 | import { MenuItem } from "../menu-list-item/menu-list-item"; 21 | import { MenuItemProps, MenuProps } from "./menu-model"; 22 | import styles from "./menu.module.scss"; 23 | 24 | const Menu: FunctionComponent = memo((props) => { 25 | const { 26 | items = [], 27 | menuHeadPosition, 28 | open, 29 | onClose, 30 | closeImmediate, 31 | onRender, 32 | disableHeader = false, 33 | disableAnimation = false, 34 | isSubMenu = false, 35 | onSelect, 36 | } = props; 37 | 38 | const [_items, setItems] = useState(() => 39 | items.map((item) => ({ ...item, id: nanoid(), selected: false })) 40 | ); 41 | 42 | const listRef = useRef(); 43 | const outerRef = useRef(null); 44 | 45 | const [height, setHeight] = useState(0); 46 | 47 | const { theme, iconSize, RTL, closeOnClickOutside } = useContext(MenuContext); 48 | 49 | useCloseOnEscape(listRef, () => { 50 | handleClose(); 51 | }); 52 | 53 | if (closeOnClickOutside) { 54 | useCloseOnClick(outerRef, open, () => { 55 | handleClose(); 56 | }); 57 | } 58 | 59 | useKeyboardNav(listRef, _items, (index) => { 60 | const elementToFocus = listRef.current?.querySelectorAll( 61 | `li:nth-of-type(${index + 1})` 62 | )[0] as HTMLElement; 63 | 64 | elementToFocus?.focus(); 65 | }); 66 | 67 | const activeIndex = useRef(0); 68 | 69 | const style = useMemo( 70 | () => 71 | ({ 72 | "--menu-height": `${height}px`, 73 | "--rc-fltmenu-icon-size": iconSize, 74 | "--rc-fltmenu-menu-bg-color": theme?.menuBackgroundColor, 75 | "--rc-fltmenu-menu-item-hover": theme?.menuItemHoverColor, 76 | "--rc-fltmenu-menu-item-hover-text": theme?.menuItemHoverTextColor, 77 | "--rc-fltmenu-menu-item-text": theme?.menuItemTextColor, 78 | "--rc-fltmenu-primary": theme?.primary, 79 | "--rc-fltmenu-secondary": theme?.secondary, 80 | } as CSSProperties), 81 | [height, JSON.stringify(menuHeadPosition)] 82 | ); 83 | 84 | const canOpen = useMemo(() => open && !closeImmediate && !disableAnimation, [ 85 | open, 86 | closeImmediate, 87 | ]); 88 | 89 | const canClose = useMemo(() => !closeImmediate && open !== null, [open]); 90 | 91 | const openClass = useMemo(() => { 92 | if (canOpen) { 93 | return styles.menu_open; 94 | } else if (canClose) { 95 | return styles.menu_close; 96 | } else if (!isSubMenu) { 97 | return styles.hide; 98 | } else { 99 | return ""; 100 | } 101 | }, [canOpen, canClose]); 102 | 103 | const wrapperClass = useMemo( 104 | () => 105 | classNames( 106 | styles.wrapper, 107 | RTL ? styles.flip : "", 108 | disableAnimation ? styles.no_animation : "", 109 | closeImmediate ? styles.no_animation : "", 110 | isSubMenu ? styles.is_sub_menu : "", 111 | openClass 112 | ), 113 | [canOpen, RTL, canClose] 114 | ); 115 | const listClass = useMemo( 116 | () => classNames(styles.list, !open ? styles.close : ""), 117 | [open] 118 | ); 119 | 120 | const onWrapperInit = useCallback( 121 | (node: HTMLUListElement) => { 122 | if (node) { 123 | listRef.current = node; 124 | 125 | setTimeout(() => { 126 | const wrapperHeight = node.clientHeight + 40; 127 | setHeight(wrapperHeight); 128 | onRender?.(wrapperHeight, node.clientWidth); 129 | }, 500); 130 | } 131 | }, 132 | [_items.length, activeIndex] 133 | ); 134 | 135 | const handleClose = useCallback( 136 | (ev?: PointerEvent) => { 137 | ev?.stopPropagation(); 138 | // activeIndex.current = -1; 139 | onClose?.(); 140 | }, 141 | [onClose] 142 | ); 143 | 144 | const handleCloseViaKeyboard = useCallback( 145 | (ev: KeyboardEvent) => { 146 | if (ev.key === "Enter") { 147 | onClose?.(); 148 | } 149 | }, 150 | [onClose] 151 | ); 152 | 153 | const handleSelection = (name: string, index?: number, id?: string) => { 154 | onSelect?.(name, index); 155 | setItems((prev) => 156 | prev.map((item) => ({ 157 | ...item, 158 | selected: item.id === id, 159 | })) 160 | ); 161 | }; 162 | 163 | const handleMouseEnter = (id?: string) => { 164 | setItems((prev) => 165 | prev.map((item) => ({ 166 | ...item, 167 | selected: item.id === id, 168 | })) 169 | ); 170 | }; 171 | 172 | const onToggleSubMenu = useCallback((id?: string) => { 173 | setItems((prev) => 174 | prev.map((item) => ({ 175 | ...item, 176 | selected: item.id === id ? !item.selected : false, 177 | })) 178 | ); 179 | }, []); 180 | 181 | const onCloseSubMenu = useCallback((id?: string) => { 182 | setItems((prev) => 183 | prev.map((item) => ({ 184 | ...item, 185 | selected: item.id === id ? false : item.selected, 186 | })) 187 | ); 188 | }, []); 189 | 190 | return ( 191 |
    192 | {!disableHeader && ( 193 |
    194 | 203 | 204 | 205 |
    206 | )} 207 |
      208 | {_items.map((item, index) => ( 209 | 222 | ))} 223 |
    224 |
    225 | ); 226 | }); 227 | 228 | Menu.displayName = "Menu"; 229 | 230 | export { Menu }; 231 | -------------------------------------------------------------------------------- /src/components/main/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { 3 | CSSProperties, 4 | FunctionComponent, 5 | useCallback, 6 | useEffect, 7 | useMemo, 8 | useRef, 9 | useState, 10 | } from "react"; 11 | import { useMenuHidden } from "../../effects/useMenuHidden"; 12 | import { usePosition } from "../../effects/usePosition"; 13 | import { MenuHeadProps } from "../../models/menu-head.model"; 14 | import { defaultTheme } from "../../utils/theme-default"; 15 | import { MenuContext } from "../context"; 16 | import { MenuContainer } from "../menu-container/menu-container"; 17 | import styles from "./main.module.scss"; 18 | 19 | const MenuHead: FunctionComponent = ({ 20 | dimension = 30, 21 | children, 22 | shape = "circle", 23 | items = [], 24 | startPosition = "top left", 25 | theme = defaultTheme, 26 | disableHeader = false, 27 | width = 250, 28 | onSelect, 29 | startOffset = 10, 30 | closeOnClickOutside = true, 31 | autoFlipMenu = true, 32 | bringMenuToFocus = true, 33 | iconSize = "1rem", 34 | pin, 35 | RTL = false, 36 | }) => { 37 | const [pressedState, setPressedState] = useState(false); 38 | const [openMenu, setMenuOpen] = useState(null); 39 | const [headPosition, setHeadPosition] = useState<{ 40 | x: number; 41 | y: number; 42 | }>({ x: 0, y: 0 }); 43 | const [closeMenuImmediate, setCloseMenuImmediate] = useState(false); 44 | const [isDragged, setIsDragged] = useState(false); 45 | 46 | const finalTheme = useMemo(() => ({ ...defaultTheme, ...theme }), []); 47 | 48 | const [menuDimension, setMenuDimension] = useState<{ 49 | height: number; 50 | width: number; 51 | }>({ height: 0, width: 0 }); 52 | 53 | const [menuPosition, setMenuPosition] = useState<{ 54 | left: number; 55 | top?: number; 56 | bottom?: number; 57 | }>({ bottom: 0, left: 0, top: 0 }); 58 | 59 | const [menuHiddenTowards, setMenuHiddenTowards] = useState< 60 | "left" | "right" | null 61 | >(); 62 | 63 | const headHalfWidth = useMemo(() => Math.round(dimension / 2), [dimension]); 64 | 65 | const isFirstRender = useRef(true); 66 | 67 | const { setup, ref } = usePosition({ 68 | dimension, 69 | onClosed: () => { 70 | setMenuOpen(false); 71 | setPressedState(false); 72 | }, 73 | onDragEnd: ({ left, top }) => { 74 | setHeadPosition({ 75 | x: left || 0, 76 | y: (top || 0) + dimension + 10, 77 | }); 78 | setMenuOpen(false); 79 | setPressedState(false); 80 | setIsDragged(false); 81 | }, 82 | onDragStart: ({ left, top }) => { 83 | setHeadPosition({ 84 | x: left || 0, 85 | y: (top || 0) + dimension + 10, 86 | }); 87 | setCloseMenuImmediate(true); 88 | setMenuOpen(false); 89 | setIsDragged(true); 90 | }, 91 | onInit: ({ left, top }) => { 92 | setHeadPosition({ 93 | x: left || 0, 94 | y: (top || 0) + dimension + 10, 95 | }); 96 | }, 97 | onPointerDown: () => { 98 | setPressedState(true); 99 | setCloseMenuImmediate(false); 100 | }, 101 | onPointerUp: useCallback(() => { 102 | setPressedState(false); 103 | setMenuOpen((prev) => !prev); 104 | }, []), 105 | pin, 106 | startOffset, 107 | startPosition, 108 | }); 109 | 110 | useMenuHidden(menuPosition.left, menuDimension.width, (dir) => { 111 | setMenuHiddenTowards(dir); 112 | }); 113 | 114 | const style = useMemo( 115 | () => 116 | ({ 117 | "--dimension": `${dimension}px`, 118 | "--rc-fltmenu-primary": finalTheme.primary, 119 | "--rc-fltmenu-width": `${width}px`, 120 | } as CSSProperties), 121 | [finalTheme.primary] 122 | ); 123 | 124 | const pressedClass = useMemo(() => { 125 | if (isFirstRender.current) { 126 | return ""; 127 | } 128 | return pressedState ? styles.pressed : styles.released; 129 | }, [pressedState]); 130 | 131 | const menuHeadClass = useMemo(() => { 132 | return classNames( 133 | styles.menu_head, 134 | pressedClass, 135 | isDragged ? styles.is_dragged : "", 136 | { 137 | [styles[shape]]: true, 138 | } 139 | ); 140 | }, [pressedClass, isDragged]); 141 | 142 | const handleMenuClose = useCallback(() => { 143 | setMenuOpen(false); 144 | setCloseMenuImmediate(false); 145 | 146 | ref?.current?.focus(); 147 | }, []); 148 | 149 | const shouldFlipVertical = useMemo(() => { 150 | return ( 151 | autoFlipMenu && 152 | headPosition.y + dimension + menuDimension.height > window.innerHeight 153 | ); 154 | }, [ 155 | headPosition.x, 156 | headPosition.y, 157 | JSON.stringify(menuDimension), 158 | openMenu, 159 | autoFlipMenu, 160 | ]); 161 | 162 | const onMenuRender = useCallback( 163 | (menuHeight: number, menuWidth: number) => 164 | setMenuDimension({ height: menuHeight, width: menuWidth }), 165 | [] 166 | ); 167 | 168 | useEffect(() => { 169 | setMenuPosition({ 170 | left: Math.round( 171 | headPosition.x - (Math.round(menuDimension.width / 2) - headHalfWidth) 172 | ), 173 | [shouldFlipVertical ? "bottom" : "top"]: !shouldFlipVertical 174 | ? headPosition.y + 10 175 | : Math.abs(window.innerHeight - headPosition.y) + dimension + 20, 176 | }); 177 | }, [ 178 | shouldFlipVertical, 179 | headPosition.x, 180 | headPosition.y, 181 | menuDimension.width, 182 | headHalfWidth, 183 | ]); 184 | 185 | const shouldAdjustMenuPosition = useMemo( 186 | () => !!(!isFirstRender.current && bringMenuToFocus && ref?.current), 187 | [openMenu, bringMenuToFocus] 188 | ); 189 | 190 | useEffect(() => { 191 | if (!shouldAdjustMenuPosition) { 192 | return; 193 | } 194 | 195 | const alignedTo = startPosition.split(" ")[1]; 196 | const { width: menuWidth } = menuDimension; 197 | const { innerWidth } = window; 198 | const headRef = ref.current as HTMLDivElement; 199 | 200 | if (menuHiddenTowards === "left") { 201 | setMenuPosition({ 202 | left: startOffset, 203 | }); 204 | headRef.style.cssText += `left: ${ 205 | Math.round(menuWidth / 2) - headHalfWidth + startOffset 206 | }px;`; 207 | } else if (menuHiddenTowards === "right") { 208 | setMenuPosition({ 209 | left: innerWidth - menuWidth - startOffset, 210 | }); 211 | headRef.style.cssText += `left: ${ 212 | Math.round(innerWidth - menuWidth / 2) - headHalfWidth - 10 213 | }px;`; 214 | } else if (alignedTo === "left" && headPosition.x <= startOffset && pin) { 215 | headRef.style.cssText += `left: ${startOffset}px;`; 216 | setMenuPosition((prev) => ({ 217 | ...prev, 218 | left: -menuWidth, 219 | })); 220 | } else if ( 221 | alignedTo === "right" && 222 | headPosition.x >= innerWidth - dimension - startOffset && 223 | pin 224 | ) { 225 | headRef.style.cssText += `left: ${ 226 | innerWidth - dimension - startOffset 227 | }px;`; 228 | setMenuPosition((prev) => ({ 229 | ...prev, 230 | left: innerWidth, 231 | })); 232 | } 233 | }, [openMenu, headPosition.x, shouldAdjustMenuPosition]); 234 | 235 | useEffect(() => { 236 | if (isFirstRender.current) { 237 | isFirstRender.current = false; 238 | } 239 | }, []); 240 | 241 | const handleSelection = useCallback((path: string) => { 242 | onSelect?.(path); 243 | handleMenuClose(); 244 | }, []); 245 | 246 | return ( 247 | 260 |
    268 | 272 | {children} 273 | 274 |
    275 | 286 |
    287 | ); 288 | }; 289 | 290 | export { MenuHead }; 291 | -------------------------------------------------------------------------------- /cypress/e2e/3-float-menu/interactions.cy.ts: -------------------------------------------------------------------------------- 1 | describe("Menu head", () => { 2 | // visit the page 3 | beforeEach(() => { 4 | cy.visit("http://localhost:5173/"); 5 | }); 6 | 7 | // check if the menu head exists 8 | it("should have a menu head", () => { 9 | cy.get('[data-cy="rc-fltmenu-head"]').should("exist"); 10 | }); 11 | 12 | // check if the icon is rendered inside the menu head 13 | it("should have an icon", () => { 14 | cy.get('[data-cy="rc-fltmenu-head"]') 15 | .find('[data-cy="rc-fltmenu-icon"]') 16 | .should("exist"); 17 | }); 18 | 19 | // should render svg icon inside menu head 20 | it("should have a svg icon", () => { 21 | cy.get('[data-cy="rc-fltmenu-head"]') 22 | .find('[data-cy="rc-fltmenu-icon"]') 23 | .find("svg") 24 | .should("exist"); 25 | }); 26 | 27 | // check if the menu is available on clicking the menu head 28 | it("should have a menu", () => { 29 | cy.get('[data-cy="rc-fltmenu-head"]') 30 | .find('[data-cy="rc-fltmenu-icon"]') 31 | .click(); 32 | cy.get('[data-cy="rc-fltmenu-container"]').should("exist"); 33 | cy.get('[data-cy="rc-fltmenu-container"]').find("ul").should("be.visible"); 34 | }); 35 | 36 | // the menu should have a close button 37 | it("should have a close button", () => { 38 | cy.get('[data-cy="rc-fltmenu-head"]').click(); 39 | 40 | cy.get('[data-cy="rc-fltmenu-container"]') 41 | .find('[role="button"], [data-cy="rc-fltmenu-close"]') 42 | .should("exist"); 43 | }); 44 | 45 | // should close the menu on clicking the close button 46 | it("should close the menu on clicking the close button", () => { 47 | cy.get('[data-cy="rc-fltmenu-head"]').click(); 48 | 49 | cy.get('[data-cy="rc-fltmenu-container"]') 50 | .find('[role="button"], [data-cy="rc-fltmenu-close"]') 51 | .click(); 52 | 53 | cy.get('[data-cy="rc-fltmenu-container"]').should("not.be.visible"); 54 | }); 55 | 56 | // should close the menu on clicking outside the menu 57 | it("should close the menu on clicking outside the menu", () => { 58 | cy.get('[data-cy="rc-fltmenu-head"]').click(); 59 | 60 | cy.get("body").click(50, 50, { force: true }); 61 | 62 | cy.get('[data-cy="rc-fltmenu-container"]').should("not.be.visible"); 63 | }); 64 | 65 | // should have exactly 6 menu items when the menu is opened 66 | it("should have exactly 6 menu items when the menu is opened", () => { 67 | cy.get('[data-cy="rc-fltmenu-head"]').click(); 68 | 69 | cy.get('[data-cy="rc-fltmenu-container"]') 70 | .find("ul") 71 | .find("li") 72 | .should("have.length", 6); 73 | }); 74 | 75 | // check if the submenu open when the menu item "Edit" is clicked 76 | it("should open the submenu when the menu item 'Edit' is clicked", () => { 77 | cy.get('[data-cy="rc-fltmenu-head"]').click(); 78 | 79 | cy.get('[data-cy="rc-fltmenu-container"]') 80 | .find("ul") 81 | .find("li") 82 | .contains("Edit") 83 | .trigger("pointerdown", { 84 | pointerId: 1, 85 | }); 86 | 87 | cy.get('[data-cy="rc-fltmenu-submenu"]').should("exist"); 88 | cy.get('[data-cy="rc-fltmenu-submenu"]').should("be.visible"); 89 | 90 | cy.get('[data-cy="rc-fltmenu-submenu"]') 91 | .find("li") 92 | .should("have.length", 2); 93 | 94 | cy.get('[data-cy="rc-fltmenu-submenu"]').find("li").contains("Cut"); 95 | 96 | // click on the menu item "Cut" 97 | cy.get('[data-cy="rc-fltmenu-submenu"]') 98 | .find("li") 99 | .contains("Cut") 100 | .trigger("pointerdown", { 101 | pointerId: 2, 102 | }); 103 | 104 | // menu should be closed when clicked on the head 105 | // cy.get('[data-cy="rc-fltmenu-head"]').click(); 106 | 107 | // menu should be closed when clicked on the body 108 | cy.get("body").click(50, 50, { force: true }); 109 | 110 | cy.get('[data-cy="rc-fltmenu-container"]').should("not.be.visible"); 111 | }); 112 | 113 | // drag the menu head 200px to right and 300px down 114 | it("should drag the menu head 200px to right and 300px down", () => { 115 | cy.get('[data-cy="rc-fltmenu-head"]').click(); 116 | 117 | cy.get('[data-cy="rc-fltmenu-head"]') 118 | .trigger("pointerdown", { 119 | pointerId: 1, 120 | }) 121 | .trigger("pointermove", { 122 | clientX: 300, 123 | clientY: 200, 124 | pointerId: 1, 125 | }) 126 | .trigger("pointerup", { 127 | pointerId: 1, 128 | }); 129 | 130 | // open the menu 131 | cy.get('[data-cy="rc-fltmenu-head"]').click(); 132 | 133 | // check if the menu is opened at the right position 134 | cy.get('[data-cy="rc-fltmenu-container"]').should("be.visible"); 135 | }); 136 | 137 | // drag and drop the menu head to the bottom center of the screen and check if the menu is flipped 138 | it("should drag and drop the menu head to the bottom center of the screen", () => { 139 | cy.get('[data-cy="rc-fltmenu-head"]').click(); 140 | 141 | // get cypress viewport height 142 | const cypressViewportHeight = Cypress.config("viewportHeight"); 143 | 144 | cy.get('[data-cy="rc-fltmenu-head"]') 145 | .trigger("pointerdown", { 146 | pointerId: 1, 147 | }) 148 | .trigger("pointermove", { 149 | clientX: 300, 150 | clientY: cypressViewportHeight - 50, 151 | pointerId: 1, 152 | }) 153 | .trigger("pointerup", { 154 | pointerId: 1, 155 | }); 156 | 157 | // open the menu 158 | cy.get('[data-cy="rc-fltmenu-head"]').click(); 159 | 160 | // check if the menu is opened at the right position 161 | cy.get('[data-cy="rc-fltmenu-container"]').should("be.visible"); 162 | }); 163 | 164 | // drag and drop the menu head to the top right corner of the screen and check if the menu is visible 165 | it("should drag and drop the menu head to the top right corner of the screen", () => { 166 | cy.get('[data-cy="rc-fltmenu-head"]').click(); 167 | 168 | cy.get('[data-cy="rc-fltmenu-head"]') 169 | .trigger("pointerdown", { 170 | pointerId: 1, 171 | }) 172 | .trigger("pointermove", { 173 | clientX: Cypress.config("viewportWidth") - 50, 174 | clientY: 50, 175 | pointerId: 1, 176 | }) 177 | .trigger("pointerup", { 178 | pointerId: 1, 179 | }); 180 | 181 | // open the menu 182 | cy.get('[data-cy="rc-fltmenu-head"]').click(); 183 | 184 | // check if the menu is opened at the right position 185 | cy.get('[data-cy="rc-fltmenu-container"]').should("be.visible"); 186 | }); 187 | 188 | // drag and drop the menu head to a random place 10 times and check if the menu is visible 189 | it("should drag and drop the menu head to a random place 10 times", () => { 190 | cy.get('[data-cy="rc-fltmenu-head"]').click(); 191 | 192 | for (let i = 0; i < 30; i++) { 193 | cy.get('[data-cy="rc-fltmenu-head"]') 194 | .trigger("pointerdown", { 195 | pointerId: 1, 196 | }) 197 | .trigger("pointermove", { 198 | clientX: Math.floor(Math.random() * Cypress.config("viewportWidth")), 199 | clientY: Math.floor(Math.random() * Cypress.config("viewportHeight")), 200 | pointerId: 1, 201 | }) 202 | .trigger("pointerup", { 203 | pointerId: 1, 204 | }); 205 | // open the menu 206 | cy.get('[data-cy="rc-fltmenu-head"]').click(); 207 | 208 | // check if the menu is opened at the right position 209 | cy.get('[data-cy="rc-fltmenu-container"]').should("be.visible"); 210 | } 211 | }); 212 | 213 | // open the menu head by triggering the pointer down event on menu head 214 | it("should open the menu head by triggering the pointer down event on menu head", () => { 215 | // focus menu head 216 | cy.get('[data-cy="rc-fltmenu-head"]').focus(); 217 | 218 | cy.get('[data-cy="rc-fltmenu-head"]').trigger("pointerup", { 219 | pointerId: 1, 220 | }); 221 | 222 | // check if the menu is opened 223 | cy.get('[data-cy="rc-fltmenu-container"]').should("be.visible"); 224 | 225 | cy.get('[data-cy="rc-fltmenu-head"]').trigger("pointerup", { 226 | pointerId: 1, 227 | }); 228 | 229 | // check if the menu is opened 230 | cy.get('[data-cy="rc-fltmenu-container"]').should("not.be.visible"); 231 | }); 232 | 233 | it("Should close the menu when pressing Escape", () => { 234 | // focus menu head 235 | cy.get('[data-cy="rc-fltmenu-head"]').focus(); 236 | 237 | cy.get('[data-cy="rc-fltmenu-head"]').trigger("pointerup", { 238 | pointerId: 1, 239 | }); 240 | 241 | // check if the menu is opened 242 | cy.get('[data-cy="rc-fltmenu-container"]').should("be.visible"); 243 | 244 | cy.get('[data-cy="rc-fltmenu-head"]').trigger("keyup", { 245 | key: "Escape", 246 | }); 247 | 248 | // check if the menu is opened 249 | cy.get('[data-cy="rc-fltmenu-container"]').should("not.be.visible"); 250 | }); 251 | }); 252 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

    2 | 3 | logo 4 | 5 |

    6 | 7 |

    8 | minzip 9 | npm version 10 | build status 11 | snyk 12 | codiga 13 | depfu 14 | cypress 15 |

    16 | 17 | 18 |

    Features

    19 | 20 | - ⚡ Configurable and smart floating menu for react 21 | - ⚙️ Comes with a lot of options to customize the behavior of the menu 22 | - 💡 Auto detects edges of the screen and places the menu accordingly 23 | - 🎨 Customize colors with ease 24 | - 📱 Seamless support for mobile and desktop 25 | - 💪 Built with Typescript 26 | 27 |

    Table of Contents

    28 | 29 | - [📦 Installation](#-installation) 30 | - [🚀 Getting started](#-getting-started) 31 | - [⚙️ Properties](#️-properties) 32 | - [items](#items) 33 | - [🎨 Theme](#-theme) 34 | - [🧪 Tests](#-tests) 35 | - [🍫 CodeSandbox Examples](#-codesandbox-examples) 36 | - [🤝 Contributing](#-contributing) 37 | - [📃 Meta](#-meta) 38 | 39 | ## 📦 Installation 40 | 41 | Install the package with `npm` or `yarn`. 42 | 43 | ```bash 44 | npm install react-float-menu 45 | ``` 46 | 47 | or 48 | 49 | ``` bash 50 | yarn add react-float-menu 51 | ``` 52 | 53 | ## 🚀 Getting started 54 | 55 | Let's get started by creating a new floating menu with drag super power. 56 | 57 | ```jsx 58 | console.log(val)} 84 | > 85 | 86 | 87 | ``` 88 | 89 | ## ⚙️ Properties 90 | 91 | | name | description | default | 92 | | :------------------ | :------------------------------------------------------------------------------------------------------------------------ | :--------- | 93 | | autoFlipMenu | Flips the menu when the button is at the bottom of the screen and there is no space to display the menu | true | 94 | | bringMenuToFocus | Automatically moves the menu and brings it to focus when the menu is activated from the left or right edge of the screen. | true | 95 | | closeOnClickOutside | Closes the menu if you touch or click outside the menu. | true | 96 | | dimension | Sets the height and width of the button | 30 | 97 | | disableHeader | Disables the header section of the menu. | false | 98 | | iconSize | Size of the menu item icons | "1rem" | 99 | | items | Collection of menu items. Please refer [Menu item model](#menu-item-model) | [] | 100 | | pin | Disables dragging and pins the menu. The button's initial placement will be determined by `startPosition` | 30 | 101 | | shape | Shape of the button. can be `square` or `circle` | `circle` | 102 | | RTL | Renders the menu items right to left. The submenu's will open to the `left side`. | False | 103 | | startPosition | Starting position of the button. can be `top left`,`top right`,`bottom left`,`bottom right` | `top left` | 104 | | theme | With the `theme` object, you can change the colors of different elements. | | 105 | | width | Width of the menu | 30 | 106 | 107 | ### items 108 | 109 | `items` is an array of menu item objects. A menu item represents the individual menu item and its properties. 110 | 111 | | name | description | 112 | | :------- | :----------------------------------------------------------------------------------------------------- | 113 | | name | Label of the menu item | 114 | | id | Unique id of the menu item. This is optional as the ids are automatically generated by the library | 115 | | children | The prop takes an array of Menu item objects and it will be rendered as the submenu for this menu item | 116 | | icon | Use this prop to display a icon for the menu item | 117 | | selected | This is an internal prop that is set to true when the menu item is selected | 118 | 119 | > submenus are activated by clicking on the menu item or by hovering over the menu item. 120 | 121 | ## 🎨 Theme 122 | 123 | Use the theme object to customize the colors of the different elements of the menu. 124 | 125 | | name | description | default | 126 | | :--------------------- | :------------------------------------------------- | :------ | 127 | | menuBackgroundColor | background color of the menu | #FFFFFF | 128 | | menuItemHoverColor | background color of the menu item when its hovered | #318CE7 | 129 | | menuItemHoverTextColor | Text color of the menu item when its hovered | #fff | 130 | | primary | Primary color or the predominant color of the menu | #318CE7 | 131 | | secondary | Secondary color of the menu | #FFFFFF | 132 | 133 | ```jsx 134 | 135 | import {Menu} from 'react-float-menu'; 136 | 137 | 147 | 148 | ``` 149 | 150 | ## 🧪 Tests 151 | 152 | We use [cypress](https://docs.cypresshq.com/guides/guides/introduction/getting-started/) to test the library. 153 | 154 | To run the tests, run the following command in the root directory of the project. 155 | 156 | ```bash 157 | pnpm install 158 | pnpm run cypress:open 159 | ``` 160 | 161 | ## 🍫 CodeSandbox Examples 162 | 163 | 1. [Basic](https://codesandbox.io/s/black-dawn-xzw0xd) 164 | 2. [Pinned Menu](https://codesandbox.io/s/staging-darkness-ycfqkm) 165 | 3. [Custom Colors](https://codesandbox.io/s/cocky-satoshi-hkm28g) 166 | 4. [RTL](https://codesandbox.io/s/interesting-haslett-ulv3re) 167 | 168 | ## 🤝 Contributing 169 | 170 | 1. [Fork it](https://github.com/prabhuignoto/react-float-menu/fork) 171 | 2. Create your feature branch (git checkout -b new-feature) 172 | 3. Commit your changes (git commit -am 'Add feature') 173 | 4. Push to the branch (git push origin new-feature) 174 | 5. Create a new Pull Request 175 | 176 | Check out the [contributing guide](/CONTRIBUTING.md) for more details. 177 | 178 | ## 📃 Meta 179 | 180 | Distributed under the MIT license. See `LICENSE` for more information. 181 | 182 | Prabhu Murthy – [@prabhumurthy2](https://twitter.com/prabhumurthy2) – prabhu.m.murthy@gmail.com 183 | [https://github.com/prabhuignoto](https://github.com/prabhuignoto) 184 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | react-float-menuPOWERFUL APISMART & INTUITIVETHEMEABLETOUCH ENABLED --------------------------------------------------------------------------------