├── .browserlistrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ ├── github-page.yml │ ├── publish.yml │ └── test.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .vscode └── extensions.json ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── apps └── demo │ ├── .eslintrc.json │ ├── jest.config.ts │ ├── project.json │ ├── src │ ├── app-demo │ │ ├── app-demo.component.html │ │ ├── app-demo.component.scss │ │ └── app-demo.component.ts │ ├── app │ │ ├── app.component.html │ │ ├── app.component.scss │ │ ├── app.component.ts │ │ └── app.module.ts │ ├── assets │ │ └── .gitkeep │ ├── favicon.ico │ ├── index.html │ ├── main.ts │ ├── styles.scss │ └── test-setup.ts │ ├── tsconfig.app.json │ ├── tsconfig.editor.json │ ├── tsconfig.json │ └── tsconfig.spec.json ├── jest.config.ts ├── jest.preset.js ├── libs └── ngx-contextmenu │ ├── .eslintrc.json │ ├── .storybook │ ├── main.ts │ ├── manager-head.html │ ├── manager.ts │ ├── preview.ts │ ├── public │ │ ├── favicon-16x16.png │ │ ├── favicon-180x180.png │ │ ├── favicon-192x192.png │ │ ├── favicon-256x256.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ └── safari-pinned-tab.svg │ ├── theme │ │ └── assets │ │ │ └── logo.jpg │ └── tsconfig.json │ ├── README.md │ ├── jest.config.ts │ ├── ng-package.json │ ├── package.json │ ├── project.json │ ├── src │ ├── assets │ │ └── stylesheets │ │ │ ├── base.scss │ │ │ ├── button-reset.scss │ │ │ └── dark-theme.scss │ ├── index.ts │ ├── lib │ │ ├── components │ │ │ ├── context-menu-content │ │ │ │ ├── context-menu-content.component.html │ │ │ │ ├── context-menu-content.component.spec.ts │ │ │ │ └── context-menu-content.component.ts │ │ │ └── context-menu │ │ │ │ ├── context-menu.component.helpers.ts │ │ │ │ ├── context-menu.component.interface.ts │ │ │ │ ├── context-menu.component.spec.ts │ │ │ │ └── context-menu.component.ts │ │ ├── directives │ │ │ ├── context-menu-content-item │ │ │ │ ├── context-menu-content-item.directive.spec.ts │ │ │ │ └── context-menu-content-item.directive.ts │ │ │ ├── context-menu-item │ │ │ │ ├── context-menu-item.directive.spec.ts │ │ │ │ └── context-menu-item.directive.ts │ │ │ └── context-menu │ │ │ │ ├── context-menu.directive.integ.spec.ts │ │ │ │ ├── context-menu.directive.spec.ts │ │ │ │ └── context-menu.directive.ts │ │ ├── helper │ │ │ ├── evaluate.spec.ts │ │ │ └── evaluate.ts │ │ ├── ngx-contextmenu.module.ts │ │ └── services │ │ │ ├── context-menu-overlays │ │ │ ├── context-menu-overlays.service.spec.ts │ │ │ └── context-menu-overlays.service.ts │ │ │ └── context-menu │ │ │ ├── context-menu.service.spec.ts │ │ │ └── context-menu.service.ts │ ├── stories │ │ ├── APContextMenuService.mdx │ │ ├── APIContextMenuComponent.mdx │ │ ├── APIContextMenuDirective.mdx │ │ ├── APIContextMenuItemDirective.mdx │ │ ├── APIIntroduction.mdx │ │ ├── APIKeyboardNavigation.mdx │ │ ├── APIStyling.mdx │ │ ├── Changelog.mdx │ │ ├── ContextMenu.stories.ts │ │ ├── Faq.mdx │ │ ├── Introduction.mdx │ │ ├── Setup.mdx │ │ ├── assets │ │ │ ├── code-brackets.svg │ │ │ ├── colors.svg │ │ │ ├── comments.svg │ │ │ ├── contextmenu.png │ │ │ ├── direction.svg │ │ │ ├── flow.svg │ │ │ ├── plugin.svg │ │ │ ├── repo.svg │ │ │ ├── stackalt.svg │ │ │ └── stylesheets │ │ │ │ └── index.scss │ │ └── ngx-contextmenu │ │ │ ├── ngx-contextmenu.component.html │ │ │ ├── ngx-contextmenu.component.scss │ │ │ └── ngx-contextmenu.component.ts │ └── test-setup.ts │ ├── tsconfig.json │ ├── tsconfig.lib.json │ ├── tsconfig.lib.prod.json │ └── tsconfig.spec.json ├── migrations.json ├── nx.json ├── package.json ├── tsconfig.base.json └── yarn.lock /.browserlistrc: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # You can see what browsers were selected by your queries by running: 6 | # npx browserslist 7 | 8 | > 0.5% 9 | last 2 versions 10 | Firefox ESR 11 | not dead 12 | not IE 9-11 # For IE 9-11 support, remove 'not'. -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": ["**/*"], 4 | "plugins": ["@nx"], 5 | "overrides": [ 6 | { 7 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 8 | "rules": { 9 | "@nx/enforce-module-boundaries": [ 10 | "error", 11 | { 12 | "enforceBuildableLibDependency": true, 13 | "allow": [], 14 | "depConstraints": [ 15 | { 16 | "sourceTag": "*", 17 | "onlyDependOnLibsWithTags": ["*"] 18 | } 19 | ] 20 | } 21 | ] 22 | } 23 | }, 24 | { 25 | "files": ["*.ts", "*.tsx"], 26 | "extends": ["plugin:@nx/typescript"], 27 | "rules": {} 28 | }, 29 | { 30 | "files": ["*.js", "*.jsx"], 31 | "extends": ["plugin:@nx/javascript"], 32 | "rules": {} 33 | }, 34 | { 35 | "files": ["*.spec.ts", "*.spec.tsx", "*.spec.js", "*.spec.jsx"], 36 | "env": { 37 | "jest": true 38 | }, 39 | "rules": {} 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Stackblitz Example** 21 | Please fork this [base example](https://stackblitz.com/edit/ngx-contextmenu-example) to demonstrate the issue you're experiencing. 22 | -------------------------------------------------------------------------------- /.github/workflows/github-page.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | build-and-deploy: 8 | concurrency: ci-${{ github.ref }} # Recommended if you intend to make multiple deployments in quick succession. 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 🛎️ 12 | uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: '>=18.13.0' 16 | 17 | - name: Install and Build 🔧 # This example project is built using npm and outputs the result to the 'build' folder. Replace with the commands required to build your project, or remove this step entirely if your site is pre-built. 18 | run: | 19 | yarn 20 | yarn run build:doc 21 | 22 | - name: Deploy 🚀 23 | uses: JamesIves/github-pages-deploy-action@v4.2.5 24 | with: 25 | branch: gh-pages # The branch the action should deploy to. 26 | folder: dist/storybook/@perfectmemory/ngx-contextmenu # The folder the action should deploy. 27 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to NPM 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | # Setup .npmrc file to publish to npm 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: '>=18.13.0' 16 | registry-url: 'https://registry.npmjs.org/' 17 | - run: yarn 18 | - run: yarn run build:lib 19 | - run: yarn publish ./dist/libs/ngx-contextmenu 20 | env: 21 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 22 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | 8 | jobs: 9 | master: 10 | runs-on: ubuntu-latest 11 | if: ${{ github.event_name != 'pull_request' }} 12 | steps: 13 | - uses: actions/checkout@v4 14 | name: Checkout [master] 15 | with: 16 | fetch-depth: 0 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: '>=18.13.0' 20 | - run: yarn 21 | - run: yarn run build:lib 22 | - run: yarn run ci:test 23 | - uses: codecov/codecov-action@v4 24 | with: 25 | token: ${{ secrets.CODECOV_TOKEN }} 26 | files: ./coverage/@perfectmemory/ngx-contextmenu/lcov.info 27 | fail_ci_if_error: true # optional (default = false) 28 | verbose: true # optional (default = false) 29 | pr: 30 | runs-on: ubuntu-latest 31 | if: ${{ github.event_name == 'pull_request' }} 32 | steps: 33 | - uses: actions/checkout@v4 34 | with: 35 | ref: ${{ github.event.pull_request.head.ref }} 36 | fetch-depth: 0 37 | - uses: actions/setup-node@v4 38 | with: 39 | node-version: '>=18.13.0' 40 | - run: yarn 41 | - run: yarn run build:lib 42 | - run: yarn run ci:test 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | dist 5 | tmp 6 | /out-tsc 7 | storybook-static 8 | 9 | # dependencies 10 | node_modules 11 | 12 | # IDEs and editors 13 | /.idea 14 | .project 15 | .classpath 16 | .c9/ 17 | *.launch 18 | .settings/ 19 | *.sublime-workspace 20 | 21 | # IDE - VSCode 22 | .vscode/* 23 | !.vscode/settings.json 24 | !.vscode/tasks.json 25 | !.vscode/launch.json 26 | !.vscode/extensions.json 27 | 28 | # misc 29 | /.sass-cache 30 | /connect.lock 31 | /coverage 32 | /libpeerconnection.log 33 | npm-debug.log 34 | yarn-error.log 35 | testem.log 36 | /typings 37 | 38 | # System Files 39 | .DS_Store 40 | Thumbs.db 41 | 42 | .angular 43 | 44 | .nx/cache 45 | .nx/workspace-data 46 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | always-auth=true 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Add files here to ignore them from prettier formatting 2 | /dist 3 | /coverage 4 | .angular 5 | 6 | /.nx/cache 7 | /.nx/workspace-data -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "nrwl.angular-console", 4 | "esbenp.prettier-vscode", 5 | "firsttris.vscode-jest-runner", 6 | "dbaeumer.vscode-eslint" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## [Unreleased] 4 | 5 | ## [19.0.0] - 2024-12-11 6 | 7 | ### BREAKING CHANGES 8 | 9 | - **Dependency**: Require Angular 19 10 | 11 | ## [18.0.0] - 2024-05-30 12 | 13 | ### BREAKING CHANGES 14 | 15 | - **Dependency**: Require Angular 18 16 | 17 | ## [17.0.3] - 2024-05-30 18 | 19 | ### Fix 20 | 21 | - Close all instances of contextmenu when opening one 22 | 23 | ## [17.0.2] - 2024-03-08 24 | 25 | ## [17.0.1] - 2024-03-08 26 | 27 | ### Fix 28 | 29 | - Prevent several instances of a sub menu when opening with the mouse 30 | 31 | ## [17.0.0] - 2023-11-10 32 | 33 | ### BREAKING CHANGES 34 | 35 | - **Dependency**: Require Angular 17 36 | 37 | ### Changed 38 | 39 | - More complete ARIA support, tested with NVDA 40 | 41 | ### BREAKING CHANGES 42 | 43 | - ARIA support involved HTML rework which could lead to custom styling issue 44 | - Remove `ContextMenuCloseEvent` deprecated API 45 | 46 | ### Other 47 | 48 | - Migrate repo to NX 49 | 50 | ## [16.1.0-alpha.0] - 2023-09-08 51 | 52 | ### Changed 53 | 54 | - Improved ARIA navigation support when opening a context menu, impact some HTML attributs, thus some styling 55 | 56 | ## [16.0.2] - 2023-08-29 57 | 58 | ### Fixed 59 | 60 | - Documentation on `[contextMenuItem]` `let-value` was not correct. Fixed code to properly reflect the documentation 61 | - Fix documentation on `[passive]=true` 62 | 63 | ## [16.0.1] - 2023-07-03 64 | 65 | ### Fixed 66 | 67 | - Remove erroneous `aria-labelledby="menubutton"` on `ContextMenuContentComponent` 68 | 69 | ## [16.0.0] - 2023-06-15 70 | 71 | ### BREAKING CHANGES 72 | 73 | - **Dependency**: Require Angular 16 74 | 75 | ## [15.1.1] - 2023-01-30 76 | 77 | ### Fixed 78 | 79 | - Constraint context menu height to 100vh, can be changed with the `--ngx-contextmenu-max-height` CSS property 80 | 81 | ## [15.1.0] - 2023-01-16 82 | 83 | ### Added 84 | 85 | - Add `closeAll` and `hasOpenMenu` methods to the `ContextMenuService` 86 | 87 | ## [15.0.3] - 2022-11-24 88 | 89 | ### Fixed 90 | 91 | - Opened sub menus close when hovering other menu, even without submenu themselves 92 | 93 | ## [15.0.2] - 2022-11-23 94 | 95 | ### Fixed 96 | 97 | - Fix dependencies in `/projects/ngx-contextmenu/package.json` 98 | 99 | ## [15.0.1] - 2022-11-23 100 | 101 | ### Fixed 102 | 103 | - Update Angular CDK dependency to ^15.0.0 104 | 105 | ## [15.0.0] - 2022-11-21 106 | 107 | ### BREAKING CHANGES 108 | 109 | - **Dependency**: Require Angular 15 110 | 111 | ## [14.1.0] - 2022-09-05 112 | 113 | ### Added 114 | 115 | - Forward port changes from 8.1.0 116 | 117 | ### Documentation 118 | 119 | - Add `let-value` example in the documentation related to the `contextMenuItem` directive [(#11)](https://github.com/PerfectMemory/ngx-contextmenu/issues/11) 120 | 121 | ## [14.0.0] - 2022-06-13 122 | 123 | ### BREAKING CHANGES 124 | 125 | - **Dependency**: Require Angular 14 126 | 127 | ## [8.1.0] - 2022-09-05 128 | 129 | ### Added 130 | 131 | - `ContextMenuDirective` is now exported as `ngxContextMenu` [(#10)](https://github.com/PerfectMemory/ngx-contextmenu/issues/10) 132 | - Add `open` and `close` methods to the `ContextMenuDirective` [(#10)](https://github.com/PerfectMemory/ngx-contextmenu/issues/10) 133 | - Deprecated `ContextMenuCancelEvent`, `ContextMenuExecuteEvent` and `ContextMenuCloseEvent` 134 | 135 | ## [8.0.2] - 2022-05-25 136 | 137 | ### Fixed 138 | 139 | - `tabindex` property should be a string to properly work with native `tabindex` attribute 140 | 141 | ## [8.0.1] - 2022-05-18 142 | 143 | ### Fixed 144 | 145 | - Submenu wrongly positioned when parent menu has HTML content [(#3)](https://github.com/PerfectMemory/ngx-contextmenu/issues/3) 146 | 147 | ## [8.0.0] - 2022-03-09 148 | 149 | ### BREAKING CHANGES 150 | 151 | - Remove Bootstrap dependency in favor of CSS variables based theming 152 | - Remove contextMenuActions as it is not a building block of a `contextMenu` 153 | - Remove `IContextMenuOptions`, `CONTEXT_MENU_OPTIONS` 154 | - Remove unneeded `ContextMenuModule#forRoot` method and `autofocus` options 155 | - Replace all reference to `any` type 156 | - Rename interfaces and class properties for more consistency across the codebase 157 | 158 | ### Added 159 | 160 | - Support for rtl contextmenu 161 | - Better ARIA support 162 | - context menu items are now typed with by [generics](https://www.typescriptlang.org/docs/handbook/2/generics.html) 163 | 164 | ### Documentation 165 | 166 | - Added Storybook demos and documentation 167 | 168 | ## 7.0.1 - 2022-02-09 169 | 170 | ### Fixed 171 | 172 | - Fix opening submenu with the keyboard would not always properly position it next to its parent menu 173 | 174 | ## 7.0.0 - 2022-02-07 175 | 176 | - Create tests and add code coverage 177 | 178 | ## 7.0.0-alpha.0 - (2022-01-20) 179 | 180 | ### BREAKING CHANGES 181 | 182 | - **Dependency**: Require Angular 13 183 | 184 | ## 6.0.0 - 2022-02-03 185 | 186 | - Improve README 187 | - Setup github workflows 188 | 189 | ## 6.0.0-alpha.1 - 2022-02-03 190 | 191 | - Setup the CI 192 | 193 | ## 6.0.0-alpha.0 - 2022-01-20 194 | 195 | - Project forked from 196 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Context menu for Angular 2 | 3 | [![Angular](https://img.shields.io/badge/Angular-B52E31?logo=angular)](https://angular.io/) 4 | [![Test](https://github.com/PerfectMemory/ngx-contextmenu/actions/workflows/test.yml/badge.svg)](https://github.com/PerfectMemory/ngx-contextmenu/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/PerfectMemory/ngx-contextmenu/branch/master/graph/badge.svg?token=5DSYMY9C9A)](https://codecov.io/gh/PerfectMemory/ngx-contextmenu) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 5 | [![NPM](https://img.shields.io/badge/NPM-D70012?logo=npm)](https://www.npmjs.com/package/@perfectmemory/ngx-contextmenu) 6 | 7 | A context menu component for Angular. 8 | 9 | Documentation and demos https://perfectmemory.github.io/ngx-contextmenu/ 10 | 11 | [![Contextmenu Screenshot](./libs/ngx-contextmenu/src/stories/assets/contextmenu.png)](https://perfectmemory.github.io/ngx-contextmenu/) 12 | ## Features 13 | 14 | - [x] Context menu triggered by right click or keyboard context menu key 15 | - [x] Sub menus 16 | - [x] Dividers 17 | - [x] Form (example checkboxes) 18 | - [x] Keyboard navigation 19 | - [x] Support direction `ltr` (left to right) and `rtl` (right to left) 20 | - [x] Accessibility with ARIA 21 | -------------------------------------------------------------------------------- /apps/demo/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts"], 7 | "extends": [ 8 | "plugin:@nx/angular", 9 | "plugin:@angular-eslint/template/process-inline-templates" 10 | ], 11 | "rules": { 12 | "@angular-eslint/directive-selector": [ 13 | "error", 14 | { 15 | "type": "attribute", 16 | "prefix": "app", 17 | "style": "camelCase" 18 | } 19 | ], 20 | "@angular-eslint/component-selector": [ 21 | "error", 22 | { 23 | "type": "element", 24 | "prefix": "app", 25 | "style": "kebab-case" 26 | } 27 | ], 28 | "@angular-eslint/prefer-standalone": "off" 29 | } 30 | }, 31 | { 32 | "files": ["*.html"], 33 | "extends": ["plugin:@nx/angular-template"], 34 | "rules": {} 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /apps/demo/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default { 3 | displayName: 'demo', 4 | preset: '../../jest.preset.js', 5 | setupFilesAfterEnv: ['/src/test-setup.ts'], 6 | coverageDirectory: '../../coverage/apps/demo', 7 | transform: { 8 | '^.+\\.(ts|mjs|js|html)$': [ 9 | 'jest-preset-angular', 10 | { 11 | tsconfig: '/tsconfig.spec.json', 12 | stringifyContentPathRegex: '\\.(html|svg)$', 13 | }, 14 | ], 15 | }, 16 | transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], 17 | snapshotSerializers: [ 18 | 'jest-preset-angular/build/serializers/no-ng-attributes', 19 | 'jest-preset-angular/build/serializers/ng-snapshot', 20 | 'jest-preset-angular/build/serializers/html-comment', 21 | ], 22 | }; 23 | -------------------------------------------------------------------------------- /apps/demo/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "projectType": "application", 5 | "prefix": "app", 6 | "sourceRoot": "apps/demo/src", 7 | "tags": [], 8 | "targets": { 9 | "build": { 10 | "executor": "@angular-devkit/build-angular:application", 11 | "outputs": ["{options.outputPath}"], 12 | "options": { 13 | "outputPath": "dist/apps/demo", 14 | "index": "apps/demo/src/index.html", 15 | "browser": "apps/demo/src/main.ts", 16 | "polyfills": ["zone.js"], 17 | "tsConfig": "apps/demo/tsconfig.app.json", 18 | "inlineStyleLanguage": "scss", 19 | "assets": [ 20 | { 21 | "glob": "**/*", 22 | "input": "apps/demo/public" 23 | } 24 | ], 25 | "styles": ["apps/demo/src/styles.scss"], 26 | "scripts": [] 27 | }, 28 | "configurations": { 29 | "production": { 30 | "budgets": [ 31 | { 32 | "type": "initial", 33 | "maximumWarning": "500kb", 34 | "maximumError": "1mb" 35 | }, 36 | { 37 | "type": "anyComponentStyle", 38 | "maximumWarning": "2kb", 39 | "maximumError": "4kb" 40 | } 41 | ], 42 | "outputHashing": "all" 43 | }, 44 | "development": { 45 | "optimization": false, 46 | "extractLicenses": false, 47 | "sourceMap": true 48 | } 49 | }, 50 | "defaultConfiguration": "production" 51 | }, 52 | "serve": { 53 | "executor": "@angular-devkit/build-angular:dev-server", 54 | "configurations": { 55 | "production": { 56 | "buildTarget": "demo:build:production" 57 | }, 58 | "development": { 59 | "buildTarget": "demo:build:development" 60 | } 61 | }, 62 | "defaultConfiguration": "development" 63 | }, 64 | "extract-i18n": { 65 | "executor": "@angular-devkit/build-angular:extract-i18n", 66 | "options": { 67 | "buildTarget": "demo:build" 68 | } 69 | }, 70 | "lint": { 71 | "executor": "@nx/eslint:lint" 72 | }, 73 | "test": { 74 | "executor": "@nx/jest:jest", 75 | "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], 76 | "options": { 77 | "jestConfig": "apps/demo/jest.config.ts" 78 | } 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /apps/demo/src/app-demo/app-demo.component.html: -------------------------------------------------------------------------------- 1 | 8 | 9 | When you right click on this text, a context menu will appear 10 | 11 | 12 | When you right click on this text, a context menu with form inputs will 13 | appear 14 | 15 | in the right to left direction 16 | (is menu opened ? ) {{ ngxContextMenu.isOpen ? 'yes' : 'no' }} 17 | 18 | 19 | 20 | 25 | When you right click on this text, no context menu will appear because it is 26 | disabled 27 | 28 | 29 | 30 | 38 | Context menu title 41 | Cut "{{ value }}" 48 | Copy "{{ value }}" 55 | Paste "{{ value }}" 62 | Disabled menu item 65 | 70 | Special pastes... 76 | 77 | 82 | 87 | 92 | 97 | 102 | 107 | 108 | 109 | Paste as HTML 112 | Paste unformatted 115 | Sub sub menu 1... 120 | Sub sub menu 2... 125 | 126 | 127 | Sub sub menu item 1 - A 130 | Sub sub menu item 1 - B 133 | 134 | 135 | Sub sub menu item 2 - A 138 | Sub sub menu item 2 - B 141 | 142 |
143 |
144 |
145 |
146 | 149 | -------------------------------------------------------------------------------- /apps/demo/src/app-demo/app-demo.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PerfectMemory/ngx-contextmenu/ac7445b05ded36e39004103fc34ac0b11045d669/apps/demo/src/app-demo/app-demo.component.scss -------------------------------------------------------------------------------- /apps/demo/src/app-demo/app-demo.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | EventEmitter, 4 | Input, 5 | Output, 6 | ViewChild, 7 | } from '@angular/core'; 8 | import { 9 | ContextMenuDirective, 10 | ContextMenuOpenEvent, 11 | } from '@perfectmemory/ngx-contextmenu'; 12 | 13 | @Component({ 14 | selector: 'app-demo-context-menu', 15 | styles: [ 16 | ` 17 | .dashboardContainer { 18 | width: 100%; 19 | height: 100%; 20 | position: fixed; 21 | } 22 | 23 | .componentsContainer { 24 | position: fixed; 25 | bottom: 0; 26 | top: 100px; 27 | width: 100%; 28 | } 29 | 30 | .componentContainer { 31 | overflow: auto; 32 | position: absolute; 33 | } 34 | `, 35 | ], 36 | templateUrl: './app-demo.component.html', 37 | standalone: false 38 | }) 39 | export class AppDemoComponent { 40 | @Input() 41 | public menuClass = ''; 42 | 43 | @Input() 44 | public disabled = false; 45 | 46 | @Input() 47 | public dir: 'ltr' | 'rtl' | undefined; 48 | 49 | @Input() 50 | public value: unknown = 'a user defined item'; 51 | 52 | @Input() 53 | public demoMode: 'simple' | 'form' = 'simple'; 54 | 55 | @Input() 56 | public programmaticOpen = false; 57 | 58 | @Output() 59 | public onOpen = new EventEmitter>(); 60 | 61 | @Output() 62 | public onClose = new EventEmitter<'void'>(); 63 | 64 | @Output() 65 | public onMenuItemExecuted = new EventEmitter(); 66 | 67 | /** 68 | * @internal 69 | */ 70 | @ViewChild(ContextMenuDirective) 71 | public contextMenuDirective?: ContextMenuDirective; 72 | 73 | /** 74 | * @internal 75 | */ 76 | public execute(text: string, value: any) { 77 | console.log(value); 78 | this.onMenuItemExecuted.next(`${text}: ${value.value}`); 79 | } 80 | 81 | /** 82 | * @internal 83 | */ 84 | public open(value: ContextMenuOpenEvent) { 85 | this.onOpen.next(value); 86 | } 87 | 88 | /** 89 | * @internal 90 | */ 91 | public close() { 92 | this.onClose.next('void'); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /apps/demo/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /apps/demo/src/app/app.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PerfectMemory/ngx-contextmenu/ac7445b05ded36e39004103fc34ac0b11045d669/apps/demo/src/app/app.component.scss -------------------------------------------------------------------------------- /apps/demo/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | EventEmitter, 4 | Input, 5 | Output, 6 | ViewChild, 7 | } from '@angular/core'; 8 | import { 9 | ContextMenuDirective, 10 | ContextMenuOpenEvent, 11 | } from '@perfectmemory/ngx-contextmenu'; 12 | 13 | @Component({ 14 | selector: 'demo-context-menu-demo', 15 | styles: [ 16 | ` 17 | .dashboardContainer { 18 | width: 100%; 19 | height: 100%; 20 | position: fixed; 21 | } 22 | 23 | .componentsContainer { 24 | position: fixed; 25 | bottom: 0; 26 | top: 100px; 27 | width: 100%; 28 | } 29 | 30 | .componentContainer { 31 | overflow: auto; 32 | position: absolute; 33 | } 34 | `, 35 | ], 36 | templateUrl: './app.component.html', 37 | standalone: false 38 | }) 39 | export class AppComponent { 40 | @Input() 41 | public menuClass = ''; 42 | 43 | @Input() 44 | public disabled = false; 45 | 46 | @Input() 47 | public dir: 'ltr' | 'rtl' | undefined; 48 | 49 | @Input() 50 | public value: unknown = 'a user defined item'; 51 | 52 | @Input() 53 | public demoMode: 'simple' | 'form' = 'simple'; 54 | 55 | @Input() 56 | public programmaticOpen = false; 57 | 58 | @Output() 59 | public onOpen = new EventEmitter>(); 60 | 61 | @Output() 62 | public onClose = new EventEmitter<'void'>(); 63 | 64 | @Output() 65 | public onMenuItemExecuted = new EventEmitter(); 66 | 67 | /** 68 | * @internal 69 | */ 70 | @ViewChild(ContextMenuDirective) 71 | public contextMenuDirective?: ContextMenuDirective; 72 | 73 | /** 74 | * @internal 75 | */ 76 | public execute(text: string, value: any) { 77 | console.log(value); 78 | this.onMenuItemExecuted.next(`${text}: ${value.value}`); 79 | } 80 | 81 | /** 82 | * @internal 83 | */ 84 | public open(value: ContextMenuOpenEvent) { 85 | this.onOpen.next(value); 86 | } 87 | 88 | /** 89 | * @internal 90 | */ 91 | public close() { 92 | this.onClose.next('void'); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /apps/demo/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { BrowserModule } from '@angular/platform-browser'; 3 | import { ContextMenuModule } from '@perfectmemory/ngx-contextmenu'; 4 | import { AppComponent } from './app.component'; 5 | import { AppDemoComponent } from '../app-demo/app-demo.component'; 6 | 7 | @NgModule({ 8 | declarations: [AppComponent, AppDemoComponent], 9 | imports: [BrowserModule, ContextMenuModule], 10 | providers: [], 11 | bootstrap: [AppComponent], 12 | }) 13 | export class AppModule {} 14 | -------------------------------------------------------------------------------- /apps/demo/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PerfectMemory/ngx-contextmenu/ac7445b05ded36e39004103fc34ac0b11045d669/apps/demo/src/assets/.gitkeep -------------------------------------------------------------------------------- /apps/demo/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PerfectMemory/ngx-contextmenu/ac7445b05ded36e39004103fc34ac0b11045d669/apps/demo/src/favicon.ico -------------------------------------------------------------------------------- /apps/demo/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | demo 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /apps/demo/src/main.ts: -------------------------------------------------------------------------------- 1 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 2 | import { AppModule } from './app/app.module'; 3 | 4 | platformBrowserDynamic() 5 | .bootstrapModule(AppModule) 6 | .catch((err) => console.error(err)); 7 | -------------------------------------------------------------------------------- /apps/demo/src/styles.scss: -------------------------------------------------------------------------------- 1 | @use 'sass:meta'; 2 | /* You can add global styles to this file, and also import other style files */ 3 | 4 | @import '@angular/cdk/overlay-prebuilt.css'; 5 | @include meta.load-css( 6 | '../../../libs/ngx-contextmenu/src/assets/stylesheets/base.scss' 7 | ); 8 | @include meta.load-css( 9 | '../../../libs/ngx-contextmenu/src/assets/stylesheets/dark-theme.scss' 10 | ); 11 | -------------------------------------------------------------------------------- /apps/demo/src/test-setup.ts: -------------------------------------------------------------------------------- 1 | // @ts-expect-error https://thymikee.github.io/jest-preset-angular/docs/getting-started/test-environment 2 | globalThis.ngJest = { 3 | testEnvironmentOptions: { 4 | errorOnUnknownElements: true, 5 | errorOnUnknownProperties: true, 6 | }, 7 | }; 8 | import 'jest-preset-angular/setup-jest'; 9 | -------------------------------------------------------------------------------- /apps/demo/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "types": [] 6 | }, 7 | "files": ["src/main.ts"], 8 | "include": ["src/**/*.d.ts"], 9 | "exclude": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /apps/demo/tsconfig.editor.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/**/*.ts"], 4 | "compilerOptions": {}, 5 | "exclude": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts"] 6 | } 7 | -------------------------------------------------------------------------------- /apps/demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "useDefineForClassFields": false, 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "noImplicitOverride": true, 9 | "noPropertyAccessFromIndexSignature": true, 10 | "noImplicitReturns": true, 11 | "noFallthroughCasesInSwitch": true 12 | }, 13 | "files": [], 14 | "include": [], 15 | "references": [ 16 | { 17 | "path": "./tsconfig.editor.json" 18 | }, 19 | { 20 | "path": "./tsconfig.app.json" 21 | }, 22 | { 23 | "path": "./tsconfig.spec.json" 24 | } 25 | ], 26 | "extends": "../../tsconfig.base.json", 27 | "angularCompilerOptions": { 28 | "enableI18nLegacyMessageIdFormat": false, 29 | "strictInjectionParameters": true, 30 | "strictInputAccessModifiers": true, 31 | "strictTemplates": true 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /apps/demo/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "target": "es2016", 7 | "types": ["jest", "node"] 8 | }, 9 | "files": ["src/test-setup.ts"], 10 | "include": [ 11 | "jest.config.ts", 12 | "src/**/*.test.ts", 13 | "src/**/*.spec.ts", 14 | "src/**/*.d.ts" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import { getJestProjectsAsync } from '@nx/jest'; 2 | 3 | export default async () => ({ 4 | projects: await getJestProjectsAsync(), 5 | }); 6 | -------------------------------------------------------------------------------- /jest.preset.js: -------------------------------------------------------------------------------- 1 | const nxPreset = require('@nx/jest/preset').default; 2 | 3 | module.exports = { ...nxPreset }; 4 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*", "storybook-static"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts"], 7 | "extends": [ 8 | "plugin:@nx/angular", 9 | "plugin:@angular-eslint/template/process-inline-templates" 10 | ], 11 | "rules": { 12 | "@angular-eslint/directive-selector": [ 13 | "error", 14 | { 15 | "type": "attribute", 16 | "prefix": "context-menu", 17 | "style": "camelCase" 18 | } 19 | ], 20 | "@angular-eslint/component-selector": [ 21 | "error", 22 | { 23 | "type": "element", 24 | "prefix": "context-menu", 25 | "style": "kebab-case" 26 | } 27 | ], 28 | "@angular-eslint/prefer-standalone": "off" 29 | } 30 | }, 31 | { 32 | "files": ["*.html"], 33 | "extends": ["plugin:@nx/angular-template"], 34 | "rules": {} 35 | }, 36 | { 37 | "files": ["*.json"], 38 | "parser": "jsonc-eslint-parser", 39 | "rules": { 40 | "@nx/dependency-checks": "error" 41 | } 42 | } 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from '@storybook/angular'; 2 | 3 | const config: StorybookConfig = { 4 | framework: { 5 | name: '@storybook/angular', 6 | options: {}, 7 | }, 8 | 9 | stories: ['../**/*.@(mdx|stories.@(js|jsx|ts|tsx))'], 10 | 11 | addons: [ 12 | '@storybook/addon-essentials', 13 | '@storybook/addon-interactions', 14 | '@storybook/addon-mdx-gfm', 15 | ], 16 | staticDirs: [ 17 | './public', 18 | { 19 | from: '../src/stories/assets', 20 | to: '/assets', 21 | }, 22 | ], 23 | 24 | docs: {}, 25 | }; 26 | 27 | export default config; 28 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/.storybook/manager-head.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | @perfectmemory/ngx-contextmenu 10 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/.storybook/manager.ts: -------------------------------------------------------------------------------- 1 | import { addons } from '@storybook/manager-api'; 2 | import { create } from '@storybook/theming'; 3 | // @ts-expect-error TS2307 4 | import logo from './theme/assets/logo.jpg'; 5 | 6 | addons.setConfig({ 7 | theme: create({ 8 | base: 'light', 9 | brandTitle: '@perfectmemory/ngx-contextmenu', 10 | brandUrl: '/', 11 | brandImage: logo, 12 | }), 13 | isToolshown: false, 14 | }); 15 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | export const tags = ['autodocs']; 2 | 3 | export const parameters = { 4 | actions: { argTypesRegex: '^on[A-Z].*' }, 5 | controls: { 6 | matchers: { 7 | color: /(background|color)$/i, 8 | date: /Date$/, 9 | }, 10 | }, 11 | options: { 12 | storySort: { 13 | order: [ 14 | 'Context Menu', 15 | [ 16 | 'Introduction', 17 | 'Installation and setup', 18 | 'Demo', 19 | 'Documentation', 20 | [ 21 | 'In a nutshell', 22 | '[contextMenu]', 23 | '', 24 | '[contextMenuItem]', 25 | 'ContextMenuService', 26 | 'Styling', 27 | 'Keyboard navigation', 28 | ], 29 | 'FAQ', 30 | 'Changelog', 31 | ], 32 | ], 33 | }, 34 | }, 35 | docs: { inlineStories: true }, 36 | }; 37 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/.storybook/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PerfectMemory/ngx-contextmenu/ac7445b05ded36e39004103fc34ac0b11045d669/libs/ngx-contextmenu/.storybook/public/favicon-16x16.png -------------------------------------------------------------------------------- /libs/ngx-contextmenu/.storybook/public/favicon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PerfectMemory/ngx-contextmenu/ac7445b05ded36e39004103fc34ac0b11045d669/libs/ngx-contextmenu/.storybook/public/favicon-180x180.png -------------------------------------------------------------------------------- /libs/ngx-contextmenu/.storybook/public/favicon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PerfectMemory/ngx-contextmenu/ac7445b05ded36e39004103fc34ac0b11045d669/libs/ngx-contextmenu/.storybook/public/favicon-192x192.png -------------------------------------------------------------------------------- /libs/ngx-contextmenu/.storybook/public/favicon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PerfectMemory/ngx-contextmenu/ac7445b05ded36e39004103fc34ac0b11045d669/libs/ngx-contextmenu/.storybook/public/favicon-256x256.png -------------------------------------------------------------------------------- /libs/ngx-contextmenu/.storybook/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PerfectMemory/ngx-contextmenu/ac7445b05ded36e39004103fc34ac0b11045d669/libs/ngx-contextmenu/.storybook/public/favicon-32x32.png -------------------------------------------------------------------------------- /libs/ngx-contextmenu/.storybook/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PerfectMemory/ngx-contextmenu/ac7445b05ded36e39004103fc34ac0b11045d669/libs/ngx-contextmenu/.storybook/public/favicon.ico -------------------------------------------------------------------------------- /libs/ngx-contextmenu/.storybook/public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 15 | 18 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/.storybook/theme/assets/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PerfectMemory/ngx-contextmenu/ac7445b05ded36e39004103fc34ac0b11045d669/libs/ngx-contextmenu/.storybook/theme/assets/logo.jpg -------------------------------------------------------------------------------- /libs/ngx-contextmenu/.storybook/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "emitDecoratorMetadata": true 5 | }, 6 | "exclude": ["../**/*.spec.ts"], 7 | "include": [ 8 | "../src/**/*.stories.ts", 9 | "../src/**/*.stories.js", 10 | "../src/**/*.stories.jsx", 11 | "../src/**/*.stories.tsx", 12 | "../src/**/*.stories.mdx", 13 | "*.js", 14 | "*.ts" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/README.md: -------------------------------------------------------------------------------- 1 | # @perfectmemory/ngx-contextmenu 2 | 3 | This library was generated with [Nx](https://nx.dev). 4 | 5 | ## Running unit tests 6 | 7 | Run `nx test @perfectmemory/ngx-contextmenu` to execute the unit tests. 8 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest'; 2 | 3 | const config: Config = { 4 | displayName: '@perfectmemory/ngx-contextmenu', 5 | preset: '../../jest.preset.js', 6 | setupFilesAfterEnv: ['/src/test-setup.ts'], 7 | coverageDirectory: '../../coverage/@perfectmemory/ngx-contextmenu', 8 | coverageReporters: ['lcov'], 9 | transform: { 10 | '^.+\\.(ts|mjs|js|html)$': [ 11 | 'jest-preset-angular', 12 | { 13 | tsconfig: '/tsconfig.spec.json', 14 | stringifyContentPathRegex: '\\.(html|svg)$', 15 | }, 16 | ], 17 | }, 18 | transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], 19 | snapshotSerializers: [ 20 | 'jest-preset-angular/build/serializers/no-ng-attributes', 21 | 'jest-preset-angular/build/serializers/ng-snapshot', 22 | 'jest-preset-angular/build/serializers/html-comment', 23 | ], 24 | }; 25 | 26 | export default config; 27 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../dist/libs/ngx-contextmenu", 4 | "lib": { 5 | "entryFile": "src/index.ts" 6 | }, 7 | "assets": ["./src/assets/**/*.scss"] 8 | } 9 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@perfectmemory/ngx-contextmenu", 3 | "version": "19.0.0", 4 | "description": "A context menu component for Angular", 5 | "keywords": [ 6 | "angular", 7 | "ngx", 8 | "ng2", 9 | "contextmenu", 10 | "ngx-contextmenu", 11 | "right click", 12 | "contextual", 13 | "shortcut", 14 | "pop-up", 15 | "pop-up menu" 16 | ], 17 | "contributors": [ 18 | "Stephane Roucheray ", 19 | "Isaac Mann " 20 | ], 21 | "license": "MIT", 22 | "repository": { 23 | "type": "git", 24 | "url": "git+ssh://git@github.com:PerfectMemory/ngx-contextmenu.git" 25 | }, 26 | "peerDependencies": { 27 | "@angular/cdk": "^19.0.0", 28 | "@angular/common": "^19.0.0", 29 | "@angular/core": "^19.0.0" 30 | }, 31 | "dependencies": { 32 | "tslib": "^2.3.0" 33 | }, 34 | "sideEffects": false 35 | } 36 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@perfectmemory/ngx-contextmenu", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "sourceRoot": "libs/ngx-contextmenu/src", 5 | "prefix": "lib", 6 | "projectType": "library", 7 | "tags": ["ui"], 8 | "targets": { 9 | "build": { 10 | "executor": "@nx/angular:package", 11 | "outputs": ["{workspaceRoot}/dist/{projectRoot}"], 12 | "options": { 13 | "project": "libs/ngx-contextmenu/ng-package.json" 14 | }, 15 | "configurations": { 16 | "production": { 17 | "tsConfig": "libs/ngx-contextmenu/tsconfig.lib.prod.json" 18 | }, 19 | "development": { 20 | "tsConfig": "libs/ngx-contextmenu/tsconfig.lib.json" 21 | } 22 | }, 23 | "defaultConfiguration": "production" 24 | }, 25 | "test": { 26 | "executor": "@nx/jest:jest", 27 | "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], 28 | "options": { 29 | "jestConfig": "libs/ngx-contextmenu/jest.config.ts" 30 | } 31 | }, 32 | "lint": { 33 | "executor": "@nx/eslint:lint" 34 | }, 35 | "storybook": { 36 | "executor": "@storybook/angular:start-storybook", 37 | "options": { 38 | "port": 4400, 39 | "configDir": "libs/ngx-contextmenu/.storybook", 40 | "browserTarget": "@perfectmemory/ngx-contextmenu:build-storybook", 41 | "styles": [ 42 | "node_modules/@angular/cdk/overlay-prebuilt.css", 43 | "libs/ngx-contextmenu/src/assets/stylesheets/base.scss", 44 | "libs/ngx-contextmenu/src/assets/stylesheets/button-reset.scss", 45 | "libs/ngx-contextmenu/src/stories/assets/stylesheets/index.scss" 46 | ], 47 | "compodoc": false 48 | }, 49 | "configurations": { 50 | "ci": { 51 | "quiet": true 52 | } 53 | } 54 | }, 55 | "build-storybook": { 56 | "executor": "@storybook/angular:build-storybook", 57 | "outputs": ["{options.outputDir}"], 58 | "options": { 59 | "outputDir": "dist/storybook/@perfectmemory/ngx-contextmenu", 60 | "configDir": "libs/ngx-contextmenu/.storybook", 61 | "browserTarget": "@perfectmemory/ngx-contextmenu:build-storybook", 62 | "styles": [ 63 | "node_modules/@angular/cdk/overlay-prebuilt.css", 64 | "libs/ngx-contextmenu/src/assets/stylesheets/base.scss", 65 | "libs/ngx-contextmenu/src/assets/stylesheets/button-reset.scss", 66 | "libs/ngx-contextmenu/src/stories/assets/stylesheets/index.scss" 67 | ], 68 | "compodoc": false 69 | }, 70 | "configurations": { 71 | "ci": { 72 | "quiet": true 73 | } 74 | } 75 | }, 76 | "test-storybook": { 77 | "executor": "nx:run-commands", 78 | "options": { 79 | "command": "test-storybook -c libs/ngx-contextmenu/.storybook --url=http://localhost:4400" 80 | } 81 | }, 82 | "static-storybook": { 83 | "executor": "@nx/web:file-server", 84 | "options": { 85 | "buildTarget": "@perfectmemory/ngx-contextmenu:build-storybook", 86 | "staticFilePath": "dist/storybook/@perfectmemory/ngx-contextmenu", 87 | "spa": true 88 | }, 89 | "configurations": { 90 | "ci": { 91 | "buildTarget": "@perfectmemory/ngx-contextmenu:build-storybook:ci" 92 | } 93 | } 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/assets/stylesheets/base.scss: -------------------------------------------------------------------------------- 1 | @use 'button-reset.scss'; 2 | 3 | :root { 4 | // Styling of the element where a context menu can appear 5 | --ngx-contextmenu-focusable-border-bottom: 1px dotted #70757e; 6 | 7 | // Styling of the context menu itself 8 | --ngx-contextmenu-font-family: sans-serif; 9 | --ngx-contextmenu-background-color: white; 10 | --ngx-contextmenu-border-radius: 4px; 11 | --ngx-contextmenu-border: 1px solid rgba(0, 0, 0, 0.18); 12 | --ngx-contextmenu-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.18); 13 | --ngx-contextmenu-font-size: 14px; 14 | --ngx-contextmenu-margin: 2px 0 0; 15 | --ngx-contextmenu-min-width: 160px; 16 | --ngx-contextmenu-outline: 1px solid #70757e; 17 | --ngx-contextmenu-padding: 5px 0; 18 | --ngx-contextmenu-text-color: #70757e; 19 | --ngx-contextmenu-text-disabled-color: #b5bec8; 20 | --ngx-contextmenu-max-height: 100vh; 21 | 22 | // Styling of context menu items 23 | --ngx-contextmenu-item-arrow-left: '◀'; 24 | --ngx-contextmenu-item-arrow-right: '▶'; 25 | --ngx-contextmenu-item-background-hover-color: #f8f8f8; 26 | --ngx-contextmenu-item-separator-color: #b5bec8; 27 | --ngx-contextmenu-item-separator-padding: 10px; 28 | --ngx-contextmenu-item-separator-width: 96%; 29 | --ngx-contextmenu-item-padding: 6px 20px; 30 | --ngx-contextmenu-item-text-hover-color: #5a6473; 31 | } 32 | 33 | .ngx-contextmenu { 34 | background-clip: padding-box; 35 | background-color: var(--ngx-contextmenu-background-color); 36 | border-radius: var(--ngx-contextmenu-border-radius); 37 | border: var(--ngx-contextmenu-border); 38 | box-shadow: var(--ngx-contextmenu-box-shadow); 39 | color: var(--ngx-contextmenu-text-color); 40 | display: flex; 41 | flex-direction: column; 42 | font-family: var(--ngx-contextmenu-font-family); 43 | font-size: var(--ngx-contextmenu-font-size); 44 | margin: var(--ngx-contextmenu-margin); 45 | max-height: var(--ngx-contextmenu-max-height); 46 | min-width: var(--ngx-contextmenu-min-width); 47 | overflow-x: hidden; 48 | overflow-y: auto; 49 | padding: var(--ngx-contextmenu-padding); 50 | text-align: start; 51 | 52 | &:focus-visible { 53 | outline: var(--ngx-contextmenu-outline); 54 | } 55 | 56 | &:empty { 57 | display: none; 58 | } 59 | 60 | .ngx-context-menu-item { 61 | display: flex; 62 | 63 | &.ngx-contextmenu--parent-menu:after { 64 | content: var(--ngx-contextmenu-item-arrow-right); 65 | } 66 | 67 | &[role='separator'] { 68 | border-width: 0 0 1px 0; 69 | border-bottom: 1px solid var(--ngx-contextmenu-item-separator-color); 70 | margin: var(--ngx-contextmenu-item-separator-padding) 2%; 71 | width: var(--ngx-contextmenu-item-separator-width); 72 | } 73 | 74 | &[role='menuitem'] { 75 | padding: var(--ngx-contextmenu-item-padding); 76 | } 77 | 78 | &[role='menuitem'] { 79 | outline: none; 80 | color: var(--ngx-contextmenu-text-color); 81 | flex-direction: row; 82 | justify-content: space-between; 83 | text-decoration: none; 84 | white-space: nowrap; 85 | } 86 | 87 | &:not(.disabled):not(:disabled):not(.ngx-contextmenu-item--passive) { 88 | &:hover, 89 | &:focus-visible { 90 | text-decoration: none; 91 | background-color: var(--ngx-contextmenu-item-background-hover-color); 92 | color: var(--ngx-contextmenu-item-text-hover-color); 93 | } 94 | } 95 | 96 | &.disabled, 97 | &:disabled { 98 | cursor: default; 99 | &, 100 | &:hover, 101 | &.active { 102 | color: var(--ngx-contextmenu-text-disabled-color); 103 | } 104 | } 105 | } 106 | 107 | &[dir='rtl'] { 108 | .ngx-contextmenu--parent-menu:after { 109 | content: var(--ngx-contextmenu-item-arrow-left); 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/assets/stylesheets/button-reset.scss: -------------------------------------------------------------------------------- 1 | button[role='menuitem'].ngx-context-menu-item { 2 | border: none; 3 | margin: 0; 4 | padding: 0; 5 | width: auto; 6 | overflow: visible; 7 | 8 | background: transparent; 9 | 10 | /* inherit font & color from ancestor */ 11 | color: inherit; 12 | font: inherit; 13 | 14 | /* Normalize `line-height`. Cannot be changed from `normal` in Firefox 4+. */ 15 | line-height: normal; 16 | 17 | /* Corrects font smoothing for webkit */ 18 | -webkit-font-smoothing: inherit; 19 | -moz-osx-font-smoothing: inherit; 20 | 21 | /* Corrects inability to style clickable `input` types in iOS */ 22 | -webkit-appearance: none; 23 | 24 | /* Remove excess padding and border in Firefox 4+ */ 25 | &::-moz-focus-inner { 26 | border: 0; 27 | padding: 0; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/assets/stylesheets/dark-theme.scss: -------------------------------------------------------------------------------- 1 | @use 'base.scss'; 2 | 3 | .dark-theme { 4 | // Styling of the element where a context menu can appear 5 | --ngx-contextmenu-focusable-border-bottom: 1px dotted #70757e; 6 | 7 | // Styling of the context menu itself 8 | --ngx-contextmenu-font-family: sans-serif; 9 | --ngx-contextmenu-background-color: #2B2B2B; 10 | --ngx-contextmenu-border-radius: 4px; 11 | --ngx-contextmenu-border: 1px solid rgba(0, 0, 0, 0.18); 12 | --ngx-contextmenu-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.18); 13 | --ngx-contextmenu-font-size: 14px; 14 | --ngx-contextmenu-margin: 2px 0 0; 15 | --ngx-contextmenu-min-width: 160px; 16 | --ngx-contextmenu-outline: 1px solid #70757e; 17 | --ngx-contextmenu-padding: 5px 0; 18 | --ngx-contextmenu-text-color: #70757e; 19 | --ngx-contextmenu-text-disabled-color: #8a909a; 20 | 21 | // Styling of context menu items 22 | --ngx-contextmenu-item-arrow-left: '◀'; 23 | --ngx-contextmenu-item-arrow-right: '▶'; 24 | --ngx-contextmenu-item-background-hover-color: #f8f8f8; 25 | --ngx-contextmenu-item-separator-color: #8a909a; 26 | --ngx-contextmenu-item-separator-padding: 10px; 27 | --ngx-contextmenu-item-padding: 6px 20px; 28 | --ngx-contextmenu-item-text-hover-color: #5a6473; 29 | } 30 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Public API Surface of @perfectmemory/ngx-contextmenu 3 | */ 4 | 5 | export { ContextMenuModule } from './lib/ngx-contextmenu.module'; 6 | /** 7 | * Components 8 | */ 9 | export { ContextMenuComponent } from './lib/components/context-menu/context-menu.component'; 10 | export { ContextMenuOpenEvent } from './lib/components/context-menu/context-menu.component.interface'; 11 | /** 12 | * Directives 13 | */ 14 | export { ContextMenuItemDirective } from './lib/directives/context-menu-item/context-menu-item.directive'; 15 | export { ContextMenuDirective } from './lib/directives/context-menu/context-menu.directive'; 16 | 17 | /** 18 | * Services 19 | */ 20 | export { ContextMenuService } from './lib/services/context-menu/context-menu.service'; 21 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/lib/components/context-menu-content/context-menu-content.component.html: -------------------------------------------------------------------------------- 1 | 2 |
8 | 31 | 32 | 43 | 47 | 48 |
49 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/lib/components/context-menu-content/context-menu-content.component.ts: -------------------------------------------------------------------------------- 1 | import { FocusKeyManager } from '@angular/cdk/a11y'; 2 | import { DOCUMENT } from '@angular/common'; 3 | import { 4 | AfterViewInit, 5 | ChangeDetectionStrategy, 6 | Component, 7 | ElementRef, 8 | EventEmitter, 9 | HostBinding, 10 | HostListener, 11 | Inject, 12 | Input, 13 | OnDestroy, 14 | Output, 15 | QueryList, 16 | ViewChildren, 17 | } from '@angular/core'; 18 | import { Subscription } from 'rxjs'; 19 | import { ContextMenuContentItemDirective } from '../../directives/context-menu-content-item/context-menu-content-item.directive'; 20 | import type { ContextMenuItemDirective } from '../../directives/context-menu-item/context-menu-item.directive'; 21 | import { evaluateIfFunction } from '../../helper/evaluate'; 22 | import { ContextMenuOverlaysService } from '../../services/context-menu-overlays/context-menu-overlays.service'; 23 | import type { ContextMenuComponent } from '../context-menu/context-menu.component'; 24 | 25 | /** 26 | * For testing purpose only 27 | */ 28 | export const TESTING_WRAPPER = { 29 | FocusKeyManager, 30 | }; 31 | 32 | @Component({ 33 | selector: 'context-menu-content', 34 | templateUrl: './context-menu-content.component.html', 35 | changeDetection: ChangeDetectionStrategy.OnPush, 36 | host: { 37 | tabindex: '0', 38 | role: 'dialog', 39 | class: 'ngx-contextmenu', 40 | }, 41 | standalone: false 42 | }) 43 | export class ContextMenuContentComponent 44 | implements OnDestroy, AfterViewInit 45 | { 46 | /** 47 | * The list of `IContextMenuItemDirective` that represent each menu items 48 | */ 49 | @Input() 50 | public menuDirectives: ContextMenuItemDirective[] = []; 51 | 52 | /** 53 | * The item on which the menu act 54 | */ 55 | @Input() 56 | public value?: T; 57 | 58 | /** 59 | * The orientation of the component 60 | * @see https://developer.mozilla.org/fr/docs/Web/HTML/Global_attributes/dir 61 | */ 62 | @Input() 63 | @HostBinding('attr.dir') 64 | public dir: 'ltr' | 'rtl' | undefined; 65 | 66 | /** 67 | * The parent menu of the instance 68 | */ 69 | @Input() 70 | public parentContextMenu!: ContextMenuContentComponent; 71 | 72 | /** 73 | * A CSS class to apply a theme to the the menu 74 | */ 75 | @HostBinding('class') 76 | @Input() 77 | public menuClass = ''; 78 | 79 | /** 80 | * Emit when a menu item is selected 81 | */ 82 | @Output() 83 | public execute = new EventEmitter<{ 84 | event: MouseEvent | KeyboardEvent; 85 | value?: T; 86 | menuDirective: ContextMenuItemDirective; 87 | }>(); 88 | 89 | /** 90 | * Emit when all menus is closed 91 | */ 92 | @Output() 93 | public close = new EventEmitter(); 94 | 95 | /** 96 | * @internal 97 | */ 98 | @ViewChildren(ContextMenuContentItemDirective, { 99 | read: ContextMenuContentItemDirective, 100 | }) 101 | public contextMenuContentItems!: QueryList< 102 | ContextMenuContentItemDirective 103 | >; 104 | 105 | /** 106 | * Accessibility 107 | * 108 | * @internal 109 | */ 110 | @HostBinding('attr.role') 111 | public role = 'menu'; 112 | /** 113 | * Accessibility 114 | * 115 | * @internal 116 | */ 117 | @HostBinding('aria-orientation') 118 | public ariaOrientation = 'vertical'; 119 | 120 | private focusKeyManager?: FocusKeyManager>; 121 | private subscription: Subscription = new Subscription(); 122 | private activeElement?: HTMLElement | null; 123 | 124 | // TODO: should be private but issue in spec with NullInjectorError: No provider for ElementRef! 125 | constructor( 126 | private elementRef: ElementRef, 127 | @Inject(DOCUMENT) 128 | public document: Document, 129 | private contextMenuOverlaysService: ContextMenuOverlaysService 130 | ) {} 131 | 132 | /** 133 | * @internal 134 | */ 135 | public ngAfterViewInit() { 136 | this.setupDirectives(); 137 | this.activeElement = this.document.activeElement as HTMLElement | null; 138 | this.elementRef.nativeElement.focus(); 139 | } 140 | 141 | /** 142 | * @internal 143 | */ 144 | public ngOnDestroy() { 145 | this.activeElement?.focus(); 146 | this.subscription.unsubscribe(); 147 | this.focusKeyManager?.destroy(); 148 | } 149 | 150 | /** 151 | * @internal 152 | */ 153 | @HostListener('keydown.ArrowDown', ['$event']) 154 | @HostListener('keydown.ArrowUp', ['$event']) 155 | public onKeyArrowDownOrUp(event: KeyboardEvent): void { 156 | this.focusKeyManager?.onKeydown(event); 157 | } 158 | 159 | /** 160 | * @internal 161 | */ 162 | @HostListener('keydown.ArrowRight', ['$event']) 163 | public onKeyArrowRight(event: KeyboardEvent): void { 164 | this.openCloseActiveItemSubMenu(this.dir !== 'rtl', event); 165 | } 166 | 167 | /** 168 | * @internal 169 | */ 170 | @HostListener('keydown.ArrowLeft', ['$event']) 171 | public onKeyArrowLeft(event: KeyboardEvent): void { 172 | this.openCloseActiveItemSubMenu(this.dir === 'rtl', event); 173 | } 174 | 175 | /** 176 | * @internal 177 | */ 178 | @HostListener('window:keydown.Enter', ['$event']) 179 | @HostListener('window:keydown.Space', ['$event']) 180 | public onKeyEnterOrSpace(event: KeyboardEvent): void { 181 | if (!this.focusKeyManager?.activeItem) { 182 | return; 183 | } 184 | 185 | this.onMenuItemSelect(this.focusKeyManager.activeItem, event); 186 | } 187 | 188 | /** 189 | * @internal 190 | */ 191 | @HostListener('document:click', ['$event']) 192 | public onClickOrRightClick(event: MouseEvent): void { 193 | if (event.type === 'click' && event.button === 2) { 194 | return; 195 | } 196 | 197 | if (this.elementRef.nativeElement.contains(event.target as Node)) { 198 | return; 199 | } 200 | 201 | this.contextMenuOverlaysService.closeAll(); 202 | } 203 | 204 | /** 205 | * @internal 206 | */ 207 | public hideSubMenus(): void { 208 | this.menuDirectives.forEach((menuDirective) => { 209 | menuDirective.subMenu?.hide(); 210 | }); 211 | } 212 | 213 | /** 214 | * @internal 215 | */ 216 | public stopEvent(event: MouseEvent) { 217 | event.stopPropagation(); 218 | } 219 | 220 | /** 221 | * @internal 222 | */ 223 | public isMenuItemDisabled(menuItem: ContextMenuItemDirective): boolean { 224 | return evaluateIfFunction(menuItem.disabled, this.value); 225 | } 226 | 227 | /** 228 | * @internal 229 | */ 230 | public isMenuItemVisible( 231 | menuItem: ContextMenuContentItemDirective 232 | ): boolean { 233 | return evaluateIfFunction( 234 | menuItem.contextMenuContentItem?.visible, 235 | this.value 236 | ); 237 | } 238 | 239 | /** 240 | * @internal 241 | */ 242 | public openSubMenu( 243 | subMenu: ContextMenuComponent | undefined, 244 | event: MouseEvent | KeyboardEvent 245 | ): void { 246 | if (!subMenu) { 247 | return; 248 | } 249 | 250 | if (this.focusKeyManager?.activeItemIndex === null) { 251 | return; 252 | } 253 | 254 | const anchorContextMenuContentItem = 255 | this.contextMenuContentItems?.toArray()[ 256 | this.focusKeyManager?.activeItemIndex ?? 0 257 | ]; 258 | const anchorElement = 259 | anchorContextMenuContentItem && 260 | anchorContextMenuContentItem.nativeElement; 261 | 262 | if (anchorElement && event instanceof KeyboardEvent) { 263 | subMenu.show({ 264 | anchoredTo: 'element', 265 | anchorElement, 266 | value: this.value, 267 | parentContextMenu: this, 268 | }); 269 | 270 | return; 271 | } 272 | 273 | if (subMenu.isOpen) { 274 | return; 275 | } 276 | 277 | if (event.currentTarget) { 278 | subMenu.show({ 279 | anchoredTo: 'element', 280 | anchorElement: event.currentTarget, 281 | value: this.value, 282 | parentContextMenu: this, 283 | }); 284 | 285 | return; 286 | } 287 | 288 | subMenu.show({ 289 | anchoredTo: 'position', 290 | x: (event as MouseEvent).clientX, 291 | y: (event as MouseEvent).clientY, 292 | value: this.value, 293 | }); 294 | } 295 | 296 | /** 297 | * @internal 298 | */ 299 | public onMenuItemSelect( 300 | menuContentItem: ContextMenuContentItemDirective, 301 | event: MouseEvent | KeyboardEvent 302 | ): void { 303 | this.cancelEvent(event); 304 | this.openSubMenu(menuContentItem.contextMenuContentItem?.subMenu, event); 305 | if (!menuContentItem.contextMenuContentItem?.subMenu) { 306 | this.triggerExecute(menuContentItem, event); 307 | } 308 | } 309 | 310 | private triggerExecute( 311 | menuItem: ContextMenuContentItemDirective, 312 | event: MouseEvent | KeyboardEvent 313 | ): void { 314 | menuItem.contextMenuContentItem?.triggerExecute(event, this.value); 315 | } 316 | 317 | private setupDirectives() { 318 | this.menuDirectives.forEach((menuDirective) => { 319 | menuDirective.value = this.value; 320 | this.subscription.add( 321 | menuDirective.execute.subscribe((event) => { 322 | this.execute.emit({ ...event, menuDirective }); 323 | }) 324 | ); 325 | }); 326 | this.focusKeyManager = new TESTING_WRAPPER.FocusKeyManager< 327 | ContextMenuContentItemDirective 328 | >(this.contextMenuContentItems).withWrap(); 329 | } 330 | 331 | private openCloseActiveItemSubMenu(open: boolean, event: KeyboardEvent) { 332 | if (open) { 333 | this.openActiveItemSubMenu(event); 334 | return; 335 | } 336 | 337 | this.closeActiveItemSubMenu(event); 338 | } 339 | 340 | private openActiveItemSubMenu(event: KeyboardEvent) { 341 | if (this.focusKeyManager?.activeItemIndex === null) { 342 | return; 343 | } 344 | 345 | this.cancelEvent(event); 346 | 347 | if (this.focusKeyManager?.activeItem) { 348 | this.openSubMenu( 349 | this.focusKeyManager.activeItem?.contextMenuContentItem?.subMenu, 350 | event 351 | ); 352 | } 353 | } 354 | 355 | private closeActiveItemSubMenu(event: KeyboardEvent) { 356 | this.hideSubMenus(); 357 | if (!this.focusKeyManager?.activeItemIndex) { 358 | return; 359 | } 360 | 361 | this.close.emit(); 362 | this.cancelEvent(event); 363 | } 364 | 365 | private cancelEvent(event?: MouseEvent | KeyboardEvent): void { 366 | if (!event || !event.target) { 367 | return; 368 | } 369 | 370 | const target = event.target as HTMLElement; 371 | if ( 372 | ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || 373 | target.isContentEditable 374 | ) { 375 | return; 376 | } 377 | 378 | event.preventDefault(); 379 | event.stopPropagation(); 380 | } 381 | } 382 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/lib/components/context-menu/context-menu.component.helpers.ts: -------------------------------------------------------------------------------- 1 | import { ConnectedPosition } from '@angular/cdk/overlay'; 2 | 3 | export const getPositionsToXY = ( 4 | dir: 'rtl' | 'ltr' = 'ltr' 5 | ): ConnectedPosition[] => { 6 | if (dir === 'ltr') { 7 | return [ 8 | { 9 | originX: 'start', 10 | originY: 'bottom', 11 | overlayX: 'start', 12 | overlayY: 'top', 13 | }, 14 | { 15 | originX: 'start', 16 | originY: 'top', 17 | overlayX: 'start', 18 | overlayY: 'bottom', 19 | }, 20 | { 21 | originX: 'end', 22 | originY: 'top', 23 | overlayX: 'start', 24 | overlayY: 'top', 25 | }, 26 | { 27 | originX: 'start', 28 | originY: 'top', 29 | overlayX: 'end', 30 | overlayY: 'top', 31 | }, 32 | { 33 | originX: 'end', 34 | originY: 'center', 35 | overlayX: 'start', 36 | overlayY: 'center', 37 | }, 38 | { 39 | originX: 'start', 40 | originY: 'center', 41 | overlayX: 'end', 42 | overlayY: 'center', 43 | }, 44 | ]; 45 | } 46 | 47 | return [ 48 | { 49 | originX: 'end', 50 | originY: 'bottom', 51 | overlayX: 'end', 52 | overlayY: 'top', 53 | }, 54 | { 55 | originX: 'end', 56 | originY: 'top', 57 | overlayX: 'end', 58 | overlayY: 'bottom', 59 | }, 60 | { 61 | originX: 'start', 62 | originY: 'top', 63 | overlayX: 'end', 64 | overlayY: 'top', 65 | }, 66 | { 67 | originX: 'end', 68 | originY: 'top', 69 | overlayX: 'start', 70 | overlayY: 'top', 71 | }, 72 | { 73 | originX: 'start', 74 | originY: 'center', 75 | overlayX: 'end', 76 | overlayY: 'center', 77 | }, 78 | { 79 | originX: 'end', 80 | originY: 'center', 81 | overlayX: 'start', 82 | overlayY: 'center', 83 | }, 84 | ]; 85 | }; 86 | 87 | export const getPositionsToAnchorElement = ( 88 | dir: 'rtl' | 'ltr' = 'ltr' 89 | ): ConnectedPosition[] => { 90 | if (dir === 'ltr') { 91 | return [ 92 | { 93 | originX: 'end', 94 | originY: 'top', 95 | overlayX: 'start', 96 | overlayY: 'top', 97 | }, 98 | { 99 | originX: 'start', 100 | originY: 'top', 101 | overlayX: 'end', 102 | overlayY: 'top', 103 | }, 104 | { 105 | originX: 'end', 106 | originY: 'bottom', 107 | overlayX: 'start', 108 | overlayY: 'bottom', 109 | }, 110 | { 111 | originX: 'start', 112 | originY: 'bottom', 113 | overlayX: 'end', 114 | overlayY: 'bottom', 115 | }, 116 | ]; 117 | } else { 118 | return [ 119 | { 120 | originX: 'start', 121 | originY: 'top', 122 | overlayX: 'end', 123 | overlayY: 'top', 124 | }, 125 | { 126 | originX: 'end', 127 | originY: 'top', 128 | overlayX: 'start', 129 | overlayY: 'top', 130 | }, 131 | { 132 | originX: 'start', 133 | originY: 'bottom', 134 | overlayX: 'end', 135 | overlayY: 'bottom', 136 | }, 137 | { 138 | originX: 'end', 139 | originY: 'bottom', 140 | overlayX: 'start', 141 | overlayY: 'bottom', 142 | }, 143 | ]; 144 | } 145 | }; 146 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/lib/components/context-menu/context-menu.component.interface.ts: -------------------------------------------------------------------------------- 1 | import { ContextMenuItemDirective } from '../../directives/context-menu-item/context-menu-item.directive'; 2 | import { ContextMenuContentComponent } from '../context-menu-content/context-menu-content.component'; 3 | 4 | export interface ContextMenuBaseEvent { 5 | anchoredTo: 'position' | 'element'; 6 | /** 7 | * Optional associated data to the context menu, will be emitted when a menu item is selected 8 | */ 9 | value?: T; 10 | } 11 | 12 | export interface ContextMenuAnchoredToPositionEvent 13 | extends ContextMenuBaseEvent { 14 | /** 15 | * Open the menu to an x/y position 16 | */ 17 | anchoredTo: 'position'; 18 | /** 19 | * The horizontal position of the menu 20 | */ 21 | x: number; 22 | /** 23 | * The vertical position of the menu 24 | */ 25 | y: number; 26 | } 27 | 28 | export interface ContextMenuAnchoredToElementEvent 29 | extends ContextMenuBaseEvent { 30 | /** 31 | * Open the menu anchored to a DOM element 32 | */ 33 | anchoredTo: 'element'; 34 | /** 35 | * The anchor element to display the menu next to 36 | */ 37 | anchorElement: Element | EventTarget; 38 | /** 39 | * The parent context menu from which this menu will be displayed 40 | */ 41 | parentContextMenu: ContextMenuContentComponent; 42 | } 43 | 44 | export type ContextMenuOpenEvent = 45 | | ContextMenuAnchoredToPositionEvent 46 | | ContextMenuAnchoredToElementEvent; 47 | 48 | export type IContextMenuContext = ContextMenuOpenEvent & { 49 | menuItemDirectives: ContextMenuItemDirective[]; 50 | menuClass: string; 51 | dir: 'ltr' | 'rtl' | undefined; 52 | }; 53 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/lib/components/context-menu/context-menu.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CloseScrollStrategy, 3 | FlexibleConnectedPositionStrategy, 4 | Overlay, 5 | OverlayModule, 6 | OverlayRef, 7 | ScrollStrategyOptions, 8 | } from '@angular/cdk/overlay'; 9 | import { ComponentRef, ElementRef, QueryList } from '@angular/core'; 10 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 11 | import { Subject } from 'rxjs'; 12 | import { ContextMenuItemDirective } from '../../directives/context-menu-item/context-menu-item.directive'; 13 | import { ContextMenuOverlaysService } from '../../services/context-menu-overlays/context-menu-overlays.service'; 14 | import { ContextMenuContentComponent } from '../context-menu-content/context-menu-content.component'; 15 | import { ContextMenuComponent } from './context-menu.component'; 16 | import { ContextMenuOpenEvent } from './context-menu.component.interface'; 17 | 18 | describe('Component: ContextMenuComponent', () => { 19 | let component: ContextMenuComponent; 20 | let fixture: ComponentFixture>; 21 | let scrollStrategyClose: jest.Mock; 22 | let overlayPosition: jest.Mock; 23 | let overlayFlexibleConnectedTo: jest.Mock; 24 | let overlayWithPositions: jest.Mock; 25 | let overlayCreate: jest.Mock; 26 | let overlayRefAttach: jest.Mock; 27 | let overlayRefDetach: jest.Mock; 28 | let overlayRefDispose: jest.Mock; 29 | let positionStrategy: FlexibleConnectedPositionStrategy; 30 | let overlayRef: OverlayRef; 31 | let contextMenuContentRef: ComponentRef>; 32 | let closeScrollStrategy: CloseScrollStrategy; 33 | let contextMenuOverlaysService: ContextMenuOverlaysService; 34 | 35 | beforeEach(async () => { 36 | await TestBed.configureTestingModule({ 37 | imports: [OverlayModule], 38 | declarations: [ContextMenuComponent], 39 | }).compileComponents(); 40 | contextMenuContentRef = { 41 | instance: { 42 | execute: new Subject(), 43 | close: new Subject(), 44 | openSubMenu: new Subject(), 45 | closeSubMenus: new Subject(), 46 | }, 47 | onDestroy: jest.fn(), 48 | changeDetectorRef: { 49 | detectChanges: jest.fn(), 50 | }, 51 | } as unknown as ComponentRef>; 52 | overlayRefAttach = jest.fn().mockReturnValue(contextMenuContentRef); 53 | overlayRefDetach = jest.fn(); 54 | overlayRefDispose = jest.fn(); 55 | positionStrategy = { 56 | id: 'position-strategy', 57 | } as unknown as FlexibleConnectedPositionStrategy; 58 | overlayRef = { 59 | id: 'overlay-ref', 60 | attach: overlayRefAttach, 61 | detach: overlayRefDetach, 62 | dispose: overlayRefDispose, 63 | } as unknown as OverlayRef; 64 | overlayWithPositions = jest.fn().mockReturnValue(positionStrategy); 65 | overlayCreate = jest.fn().mockReturnValue(overlayRef); 66 | overlayFlexibleConnectedTo = jest 67 | .fn() 68 | .mockReturnValue({ withPositions: overlayWithPositions }); 69 | overlayPosition = jest 70 | .fn() 71 | .mockReturnValue({ flexibleConnectedTo: overlayFlexibleConnectedTo }); 72 | closeScrollStrategy = { 73 | id: 'closeScrollStrategy', 74 | } as unknown as CloseScrollStrategy; 75 | scrollStrategyClose = jest.fn().mockReturnValue(closeScrollStrategy); 76 | TestBed.configureTestingModule({ 77 | imports: [OverlayModule], 78 | providers: [ 79 | { 80 | provide: Overlay, 81 | useValue: { 82 | position: overlayPosition, 83 | create: overlayCreate, 84 | }, 85 | }, 86 | { 87 | provide: ScrollStrategyOptions, 88 | useValue: { 89 | close: scrollStrategyClose, 90 | }, 91 | }, 92 | ], 93 | }); 94 | fixture = TestBed.createComponent(ContextMenuComponent); 95 | component = fixture.componentInstance; 96 | fixture.detectChanges(); 97 | contextMenuOverlaysService = TestBed.inject(ContextMenuOverlaysService); 98 | jest.spyOn(contextMenuOverlaysService, 'push'); 99 | jest.spyOn(contextMenuOverlaysService, 'closeAll'); 100 | }); 101 | 102 | it('should create', () => { 103 | expect(component).toBeTruthy(); 104 | }); 105 | 106 | describe('#ngOnInit', () => { 107 | it('should listen to overlays allClosed', () => { 108 | component.show({ anchoredTo: 'position', x: 0, y: 0 }); 109 | expect(component.isOpen).toEqual(true); 110 | 111 | contextMenuOverlaysService.closeAll(); 112 | 113 | expect(component.isOpen).toEqual(false); 114 | }); 115 | }); 116 | 117 | describe('#show', () => { 118 | describe('when open anchoredTo position', () => { 119 | it('should get a position strategy with position and create an overlay from it', () => { 120 | const context: ContextMenuOpenEvent = { 121 | anchoredTo: 'position', 122 | x: 0, 123 | y: 0, 124 | value: {}, 125 | }; 126 | component.dir = undefined; 127 | component.menuClass = ''; 128 | component.visibleMenuItems = []; 129 | component.show(context); 130 | 131 | expect(overlayPosition).toHaveBeenCalled(); 132 | expect(overlayFlexibleConnectedTo).toHaveBeenCalledWith({ 133 | x: 0, 134 | y: 0, 135 | }); 136 | expect(overlayWithPositions).toHaveBeenCalledWith([ 137 | { 138 | originX: 'start', 139 | originY: 'bottom', 140 | overlayX: 'start', 141 | overlayY: 'top', 142 | }, 143 | { 144 | originX: 'start', 145 | originY: 'top', 146 | overlayX: 'start', 147 | overlayY: 'bottom', 148 | }, 149 | { 150 | originX: 'end', 151 | originY: 'top', 152 | overlayX: 'start', 153 | overlayY: 'top', 154 | }, 155 | { 156 | originX: 'start', 157 | originY: 'top', 158 | overlayX: 'end', 159 | overlayY: 'top', 160 | }, 161 | { 162 | originX: 'end', 163 | originY: 'center', 164 | overlayX: 'start', 165 | overlayY: 'center', 166 | }, 167 | { 168 | originX: 'start', 169 | originY: 'center', 170 | overlayX: 'end', 171 | overlayY: 'center', 172 | }, 173 | ]); 174 | expect(overlayCreate).toHaveBeenCalledWith({ 175 | positionStrategy, 176 | panelClass: 'ngx-contextmenu-overlay', 177 | scrollStrategy: closeScrollStrategy, 178 | }); 179 | 180 | expect(contextMenuOverlaysService.push).toHaveBeenCalledWith( 181 | overlayRef 182 | ); 183 | }); 184 | 185 | it('should get a position strategy with position LTR and create an overlay from it', () => { 186 | const context: ContextMenuOpenEvent = { 187 | anchoredTo: 'position', 188 | x: 0, 189 | y: 0, 190 | value: {}, 191 | }; 192 | component.dir = 'ltr'; 193 | component.menuClass = ''; 194 | component.visibleMenuItems = []; 195 | component.show(context); 196 | 197 | expect(overlayPosition).toHaveBeenCalled(); 198 | expect(overlayFlexibleConnectedTo).toHaveBeenCalledWith({ 199 | x: 0, 200 | y: 0, 201 | }); 202 | expect(overlayWithPositions).toHaveBeenCalledWith([ 203 | { 204 | originX: 'start', 205 | originY: 'bottom', 206 | overlayX: 'start', 207 | overlayY: 'top', 208 | }, 209 | { 210 | originX: 'start', 211 | originY: 'top', 212 | overlayX: 'start', 213 | overlayY: 'bottom', 214 | }, 215 | { 216 | originX: 'end', 217 | originY: 'top', 218 | overlayX: 'start', 219 | overlayY: 'top', 220 | }, 221 | { 222 | originX: 'start', 223 | originY: 'top', 224 | overlayX: 'end', 225 | overlayY: 'top', 226 | }, 227 | { 228 | originX: 'end', 229 | originY: 'center', 230 | overlayX: 'start', 231 | overlayY: 'center', 232 | }, 233 | { 234 | originX: 'start', 235 | originY: 'center', 236 | overlayX: 'end', 237 | overlayY: 'center', 238 | }, 239 | ]); 240 | expect(overlayCreate).toHaveBeenCalledWith({ 241 | positionStrategy, 242 | panelClass: 'ngx-contextmenu-overlay', 243 | scrollStrategy: closeScrollStrategy, 244 | }); 245 | 246 | expect(contextMenuOverlaysService.push).toHaveBeenCalledWith( 247 | overlayRef 248 | ); 249 | }); 250 | 251 | it('should get a position strategy with position parent LTR and create an overlay from it', () => { 252 | const context: ContextMenuOpenEvent = { 253 | anchoredTo: 'position', 254 | x: 0, 255 | y: 0, 256 | }; 257 | component.dir = 'ltr'; 258 | component.menuClass = ''; 259 | component.visibleMenuItems = []; 260 | component.show(context); 261 | 262 | expect(overlayPosition).toHaveBeenCalled(); 263 | expect(overlayFlexibleConnectedTo).toHaveBeenCalledWith({ 264 | x: 0, 265 | y: 0, 266 | }); 267 | expect(overlayWithPositions).toHaveBeenCalledWith([ 268 | { 269 | originX: 'start', 270 | originY: 'bottom', 271 | overlayX: 'start', 272 | overlayY: 'top', 273 | }, 274 | { 275 | originX: 'start', 276 | originY: 'top', 277 | overlayX: 'start', 278 | overlayY: 'bottom', 279 | }, 280 | { 281 | originX: 'end', 282 | originY: 'top', 283 | overlayX: 'start', 284 | overlayY: 'top', 285 | }, 286 | { 287 | originX: 'start', 288 | originY: 'top', 289 | overlayX: 'end', 290 | overlayY: 'top', 291 | }, 292 | { 293 | originX: 'end', 294 | originY: 'center', 295 | overlayX: 'start', 296 | overlayY: 'center', 297 | }, 298 | { 299 | originX: 'start', 300 | originY: 'center', 301 | overlayX: 'end', 302 | overlayY: 'center', 303 | }, 304 | ]); 305 | expect(overlayCreate).toHaveBeenCalledWith({ 306 | positionStrategy, 307 | panelClass: 'ngx-contextmenu-overlay', 308 | scrollStrategy: closeScrollStrategy, 309 | }); 310 | 311 | expect(contextMenuOverlaysService.push).toHaveBeenCalledWith( 312 | overlayRef 313 | ); 314 | }); 315 | 316 | it('should get a position strategy with position RTL and create an overlay from it', () => { 317 | const context: ContextMenuOpenEvent = { 318 | anchoredTo: 'position', 319 | x: 0, 320 | y: 0, 321 | value: {}, 322 | }; 323 | component.dir = 'rtl'; 324 | component.menuClass = ''; 325 | component.visibleMenuItems = []; 326 | component.show(context); 327 | 328 | expect(overlayPosition).toHaveBeenCalled(); 329 | expect(overlayFlexibleConnectedTo).toHaveBeenCalledWith({ 330 | x: 0, 331 | y: 0, 332 | }); 333 | expect(overlayWithPositions).toHaveBeenCalledWith([ 334 | { 335 | originX: 'end', 336 | originY: 'bottom', 337 | overlayX: 'end', 338 | overlayY: 'top', 339 | }, 340 | { 341 | originX: 'end', 342 | originY: 'top', 343 | overlayX: 'end', 344 | overlayY: 'bottom', 345 | }, 346 | { 347 | originX: 'start', 348 | originY: 'top', 349 | overlayX: 'end', 350 | overlayY: 'top', 351 | }, 352 | { 353 | originX: 'end', 354 | originY: 'top', 355 | overlayX: 'start', 356 | overlayY: 'top', 357 | }, 358 | { 359 | originX: 'start', 360 | originY: 'center', 361 | overlayX: 'end', 362 | overlayY: 'center', 363 | }, 364 | { 365 | originX: 'end', 366 | originY: 'center', 367 | overlayX: 'start', 368 | overlayY: 'center', 369 | }, 370 | ]); 371 | expect(overlayCreate).toHaveBeenCalledWith({ 372 | positionStrategy, 373 | panelClass: 'ngx-contextmenu-overlay', 374 | scrollStrategy: closeScrollStrategy, 375 | }); 376 | 377 | expect(contextMenuOverlaysService.push).toHaveBeenCalledWith( 378 | overlayRef 379 | ); 380 | }); 381 | }); 382 | 383 | describe('when open anchoredTo element', () => { 384 | it('should get a position strategy with anchor Element and create an overlay from it', () => { 385 | const anchorElement = document.createElement('div'); 386 | const context: ContextMenuOpenEvent = { 387 | anchoredTo: 'element', 388 | anchorElement, 389 | parentContextMenu: TestBed.createComponent( 390 | ContextMenuContentComponent 391 | ).componentInstance, 392 | value: {}, 393 | }; 394 | component.dir = undefined; 395 | component.menuClass = ''; 396 | component.visibleMenuItems = []; 397 | context.parentContextMenu.dir = 'ltr'; 398 | component.show(context); 399 | 400 | expect(overlayFlexibleConnectedTo).toHaveBeenCalledWith( 401 | expect.any(ElementRef) 402 | ); 403 | expect(overlayFlexibleConnectedTo).toHaveBeenCalledWith({ 404 | nativeElement: anchorElement, 405 | }); 406 | expect(overlayWithPositions).toHaveBeenCalledWith([ 407 | { 408 | originX: 'end', 409 | originY: 'top', 410 | overlayX: 'start', 411 | overlayY: 'top', 412 | }, 413 | { 414 | originX: 'start', 415 | originY: 'top', 416 | overlayX: 'end', 417 | overlayY: 'top', 418 | }, 419 | { 420 | originX: 'end', 421 | originY: 'bottom', 422 | overlayX: 'start', 423 | overlayY: 'bottom', 424 | }, 425 | { 426 | originX: 'start', 427 | originY: 'bottom', 428 | overlayX: 'end', 429 | overlayY: 'bottom', 430 | }, 431 | ]); 432 | expect(overlayCreate).toHaveBeenCalledWith({ 433 | positionStrategy, 434 | panelClass: 'ngx-contextmenu-overlay', 435 | scrollStrategy: closeScrollStrategy, 436 | }); 437 | 438 | expect(contextMenuOverlaysService.push).toHaveBeenCalledWith( 439 | overlayRef 440 | ); 441 | }); 442 | 443 | it('should get a position strategy with anchor Element RTL and create an overlay from it', () => { 444 | const anchorElement = document.createElement('div'); 445 | const context: ContextMenuOpenEvent = { 446 | anchoredTo: 'element', 447 | anchorElement, 448 | parentContextMenu: TestBed.createComponent( 449 | ContextMenuContentComponent 450 | ).componentInstance, 451 | value: {}, 452 | }; 453 | component.dir = undefined; 454 | component.menuClass = ''; 455 | component.visibleMenuItems = []; 456 | context.parentContextMenu.dir = 'rtl'; 457 | component.show(context); 458 | 459 | expect(overlayFlexibleConnectedTo).toHaveBeenCalledWith( 460 | expect.any(ElementRef) 461 | ); 462 | expect(overlayFlexibleConnectedTo).toHaveBeenCalledWith({ 463 | nativeElement: anchorElement, 464 | }); 465 | expect(overlayWithPositions).toHaveBeenCalledWith([ 466 | { 467 | originX: 'start', 468 | originY: 'top', 469 | overlayX: 'end', 470 | overlayY: 'top', 471 | }, 472 | { 473 | originX: 'end', 474 | originY: 'top', 475 | overlayX: 'start', 476 | overlayY: 'top', 477 | }, 478 | { 479 | originX: 'start', 480 | originY: 'bottom', 481 | overlayX: 'end', 482 | overlayY: 'bottom', 483 | }, 484 | { 485 | originX: 'end', 486 | originY: 'bottom', 487 | overlayX: 'start', 488 | overlayY: 'bottom', 489 | }, 490 | ]); 491 | expect(overlayCreate).toHaveBeenCalledWith({ 492 | positionStrategy, 493 | panelClass: 'ngx-contextmenu-overlay', 494 | scrollStrategy: closeScrollStrategy, 495 | }); 496 | 497 | expect(contextMenuOverlaysService.push).toHaveBeenCalledWith( 498 | overlayRef 499 | ); 500 | }); 501 | }); 502 | 503 | describe('with created contextMenuContentComponent', () => { 504 | let a: ContextMenuItemDirective; 505 | let b: ContextMenuItemDirective; 506 | let c: ContextMenuItemDirective; 507 | let d: ContextMenuItemDirective; 508 | let context: ContextMenuOpenEvent; 509 | let value: unknown; 510 | 511 | beforeEach(() => { 512 | a = { 513 | visible: false, 514 | } as ContextMenuItemDirective; 515 | b = { 516 | visible: true, 517 | } as ContextMenuItemDirective; 518 | c = { 519 | visible: (_item: unknown) => false, 520 | } as ContextMenuItemDirective; 521 | d = { 522 | visible: (_item: unknown) => true, 523 | } as ContextMenuItemDirective; 524 | 525 | value = { id: 'a' }; 526 | const menuItems = new QueryList>(); 527 | menuItems.reset([a, b, c, d]); 528 | component.menuItems = menuItems; 529 | component.menuClass = 'custom-class'; 530 | component.dir = 'rtl'; 531 | context = { 532 | anchoredTo: 'position', 533 | x: 0, 534 | y: 0, 535 | value, 536 | }; 537 | component.dir = 'rtl'; 538 | component.menuClass = 'menu-class'; 539 | component.visibleMenuItems = [a, b, c, d]; 540 | }); 541 | 542 | it('should set contextMenuContentComponent properties', () => { 543 | component.show(context); 544 | expect(contextMenuContentRef.instance.value).toEqual({ id: 'a' }); 545 | expect(contextMenuContentRef.instance.menuDirectives).toEqual([b, d]); 546 | expect(contextMenuContentRef.instance.menuClass).toEqual('menu-class'); 547 | expect(contextMenuContentRef.instance.dir).toEqual('rtl'); 548 | }); 549 | 550 | it('should close all context menu when instance execute', () => { 551 | component.show(context); 552 | const event = { 553 | event: new MouseEvent('click'), 554 | item: { id: 'a' }, 555 | menuDirective: a, 556 | }; 557 | contextMenuContentRef.instance.execute.next(event); 558 | expect(overlayRef.detach).toHaveBeenCalledWith(); 559 | expect(overlayRef.dispose).toHaveBeenCalledWith(); 560 | }); 561 | 562 | it('should dispose created component when emit on close', () => { 563 | component.show(context); 564 | contextMenuContentRef.instance.close.next(); 565 | expect(overlayRef.detach).toHaveBeenCalledWith(); 566 | expect(overlayRef.dispose).toHaveBeenCalledWith(); 567 | }); 568 | 569 | it('should close all menu items when instance is destroyed', () => { 570 | component.show(context); 571 | const close = jest.fn(); 572 | component.close.subscribe(close); 573 | (contextMenuContentRef.onDestroy as jest.Mock).mock.calls.at(0)[0](); 574 | expect(close).toHaveBeenCalled(); 575 | }); 576 | 577 | it('should detect changes on created instance', () => { 578 | component.show(context); 579 | expect( 580 | contextMenuContentRef.changeDetectorRef.detectChanges 581 | ).toHaveBeenCalled(); 582 | }); 583 | }); 584 | }); 585 | }); 586 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/lib/components/context-menu/context-menu.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FlexibleConnectedPositionStrategy, 3 | Overlay, 4 | OverlayRef, 5 | ScrollStrategyOptions, 6 | } from '@angular/cdk/overlay'; 7 | import { ComponentPortal } from '@angular/cdk/portal'; 8 | import { 9 | Component, 10 | ContentChildren, 11 | ElementRef, 12 | EventEmitter, 13 | Input, 14 | OnDestroy, 15 | OnInit, 16 | Output, 17 | QueryList, 18 | ViewEncapsulation, 19 | } from '@angular/core'; 20 | import { Subscription } from 'rxjs'; 21 | import { ContextMenuItemDirective } from '../../directives/context-menu-item/context-menu-item.directive'; 22 | import { evaluateIfFunction } from '../../helper/evaluate'; 23 | import { ContextMenuOverlaysService } from '../../services/context-menu-overlays/context-menu-overlays.service'; 24 | import { ContextMenuContentComponent } from '../context-menu-content/context-menu-content.component'; 25 | import { 26 | getPositionsToAnchorElement, 27 | getPositionsToXY, 28 | } from './context-menu.component.helpers'; 29 | import { 30 | ContextMenuOpenEvent, 31 | IContextMenuContext, 32 | } from './context-menu.component.interface'; 33 | 34 | @Component({ 35 | encapsulation: ViewEncapsulation.None, 36 | selector: 'context-menu', 37 | template: '', 38 | standalone: false 39 | }) 40 | export class ContextMenuComponent implements OnInit, OnDestroy { 41 | /** 42 | * A CSS class to apply to the context menu overlay, ideal for theming and custom styling 43 | */ 44 | @Input() 45 | public menuClass = ''; 46 | 47 | /** 48 | * Disable the whole context menu 49 | */ 50 | @Input() 51 | public disabled = false; 52 | 53 | /** 54 | * Whether the menu is oriented to the right (default: `ltr`) or to the right (`rtl`) 55 | */ 56 | @Input() 57 | public dir: 'ltr' | 'rtl' | undefined; 58 | 59 | /** 60 | * Emit when the menu is opened 61 | */ 62 | @Output() 63 | public open = new EventEmitter>(); 64 | 65 | /** 66 | * Emit when the menu is closed 67 | */ 68 | @Output() 69 | public close = new EventEmitter(); 70 | 71 | /** 72 | * The menu item directives defined inside the component 73 | */ 74 | @ContentChildren(ContextMenuItemDirective) 75 | public menuItems?: QueryList>; 76 | 77 | /** 78 | * Returns true if the context menu is opened, false otherwise 79 | */ 80 | public get isOpen(): boolean { 81 | return this.#isOpen; 82 | } 83 | 84 | /** 85 | * @internal 86 | */ 87 | public visibleMenuItems: ContextMenuItemDirective[] = []; 88 | /** 89 | * @internal 90 | */ 91 | public value?: T; 92 | 93 | private subscriptions: Subscription = new Subscription(); 94 | private overlayRef?: OverlayRef; 95 | private contextMenuContentComponent?: ContextMenuContentComponent; 96 | #isOpen = false; 97 | 98 | constructor( 99 | private overlay: Overlay, 100 | private scrollStrategy: ScrollStrategyOptions, 101 | private contextMenuOverlaysService: ContextMenuOverlaysService 102 | ) {} 103 | 104 | /** 105 | * @internal 106 | */ 107 | public ngOnInit(): void { 108 | const subscription = this.contextMenuOverlaysService.allClosed.subscribe( 109 | () => { 110 | this.#isOpen = false; 111 | } 112 | ); 113 | 114 | this.subscriptions.add(subscription); 115 | } 116 | 117 | /** 118 | * @internal 119 | */ 120 | public ngOnDestroy(): void { 121 | this.subscriptions.unsubscribe(); 122 | } 123 | 124 | /** 125 | * @internal 126 | */ 127 | public show(event: ContextMenuOpenEvent): void { 128 | if (this.disabled) { 129 | return; 130 | } 131 | 132 | this.value = event.value; 133 | this.setVisibleMenuItems(); 134 | 135 | this.openContextMenu({ 136 | ...event, 137 | menuItemDirectives: this.visibleMenuItems, 138 | menuClass: this.menuClass, 139 | dir: this.dir, 140 | }); 141 | 142 | this.open.next(event); 143 | } 144 | 145 | /** 146 | * @internal 147 | */ 148 | public hide(): void { 149 | this.contextMenuContentComponent?.menuDirectives.forEach( 150 | (menuDirective) => { 151 | menuDirective.subMenu?.hide(); 152 | } 153 | ); 154 | this.overlayRef?.detach(); 155 | this.overlayRef?.dispose(); 156 | this.#isOpen = false; 157 | } 158 | 159 | /** 160 | * @internal 161 | */ 162 | public openContextMenu(context: IContextMenuContext) { 163 | let positionStrategy: FlexibleConnectedPositionStrategy; 164 | 165 | if (context.anchoredTo === 'position') { 166 | positionStrategy = this.overlay 167 | .position() 168 | .flexibleConnectedTo({ 169 | x: context.x, 170 | y: context.y, 171 | }) 172 | .withPositions(getPositionsToXY(context.dir)); 173 | } else { 174 | const { anchorElement, parentContextMenu } = context; 175 | positionStrategy = this.overlay 176 | .position() 177 | .flexibleConnectedTo(new ElementRef(anchorElement)) 178 | .withPositions(getPositionsToAnchorElement(parentContextMenu.dir)); 179 | } 180 | 181 | this.overlayRef = this.overlay.create({ 182 | positionStrategy, 183 | panelClass: 'ngx-contextmenu-overlay', 184 | scrollStrategy: this.scrollStrategy.close(), 185 | }); 186 | this.contextMenuOverlaysService.push(this.overlayRef); 187 | this.attachContextMenu(context); 188 | 189 | this.#isOpen = true; 190 | } 191 | 192 | private attachContextMenu(context: IContextMenuContext): void { 193 | const { value, menuItemDirectives } = context; 194 | const contextMenuContentRef = this.overlayRef?.attach( 195 | new ComponentPortal>( 196 | ContextMenuContentComponent 197 | ) 198 | ); 199 | const contextMenuContentComponent = contextMenuContentRef?.instance; 200 | 201 | if (!contextMenuContentComponent) { 202 | return; 203 | } 204 | 205 | this.contextMenuContentComponent = contextMenuContentComponent; 206 | 207 | contextMenuContentComponent.value = value; 208 | contextMenuContentComponent.menuDirectives = menuItemDirectives; 209 | contextMenuContentComponent.menuClass = this.getMenuClass(context); 210 | contextMenuContentComponent.dir = this.getDir(context); 211 | 212 | const closeSubscription = contextMenuContentComponent.close.subscribe( 213 | () => { 214 | this.overlayRef?.detach(); 215 | this.overlayRef?.dispose(); 216 | } 217 | ); 218 | 219 | const executeSubscription = contextMenuContentComponent.execute.subscribe( 220 | () => { 221 | this.contextMenuOverlaysService.closeAll(); 222 | } 223 | ); 224 | 225 | contextMenuContentRef.onDestroy(() => { 226 | this.close.emit(); 227 | closeSubscription.unsubscribe(); 228 | executeSubscription.unsubscribe(); 229 | }); 230 | contextMenuContentRef.changeDetectorRef.detectChanges(); 231 | } 232 | 233 | private getMenuClass(event: IContextMenuContext): string { 234 | return ( 235 | event.menuClass || 236 | (event.anchoredTo === 'element' && event?.parentContextMenu?.menuClass) || 237 | '' 238 | ); 239 | } 240 | 241 | private getDir(event: IContextMenuContext): 'ltr' | 'rtl' | undefined { 242 | return ( 243 | event.dir || 244 | (event.anchoredTo === 'element' && event?.parentContextMenu?.dir) || 245 | undefined 246 | ); 247 | } 248 | 249 | private isMenuItemVisible(menuItem: ContextMenuItemDirective): boolean { 250 | return evaluateIfFunction(menuItem.visible, this.value); 251 | } 252 | 253 | private setVisibleMenuItems(): void { 254 | this.visibleMenuItems = 255 | this.menuItems?.filter((menuItem) => this.isMenuItemVisible(menuItem)) ?? 256 | []; 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/lib/directives/context-menu-content-item/context-menu-content-item.directive.spec.ts: -------------------------------------------------------------------------------- 1 | import { Component, DebugElement, Input, TemplateRef } from '@angular/core'; 2 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 3 | import { By } from '@angular/platform-browser'; 4 | import { ContextMenuOverlaysService } from '../../services/context-menu-overlays/context-menu-overlays.service'; 5 | import { ContextMenuItemDirective } from '../context-menu-item/context-menu-item.directive'; 6 | import { ContextMenuContentItemDirective } from './context-menu-content-item.directive'; 7 | 8 | @Component({ 9 | template: '
', 10 | standalone: false, 11 | }) 12 | class TestHostComponent { 13 | @Input() 14 | public contextMenuItem?: ContextMenuItemDirective; 15 | } 16 | 17 | describe('Directive: ContextMenuContentItemDirective', () => { 18 | let fixture: ComponentFixture; 19 | let directive: ContextMenuContentItemDirective; 20 | let contextMenuItem: ContextMenuItemDirective; 21 | let directiveDebugElement: DebugElement; 22 | 23 | beforeEach(() => { 24 | TestBed.configureTestingModule({ 25 | declarations: [ 26 | ContextMenuContentItemDirective, 27 | ContextMenuItemDirective, 28 | TestHostComponent, 29 | ], 30 | }); 31 | 32 | fixture = TestBed.createComponent(TestHostComponent); 33 | directiveDebugElement = fixture.debugElement.query( 34 | By.directive(ContextMenuContentItemDirective) 35 | ); 36 | directive = directiveDebugElement.injector.get( 37 | ContextMenuContentItemDirective 38 | ); 39 | const contextMenuOverlaysService = TestBed.inject( 40 | ContextMenuOverlaysService 41 | ); 42 | contextMenuItem = new ContextMenuItemDirective( 43 | {} as unknown as TemplateRef<{ $implicit?: unknown }> 44 | ); 45 | 46 | fixture.componentInstance.contextMenuItem = contextMenuItem; 47 | fixture.detectChanges(); 48 | }); 49 | 50 | describe('#new', () => { 51 | it('should create an instance', () => { 52 | expect(directive).toBeTruthy(); 53 | }); 54 | }); 55 | 56 | describe('#focus', () => { 57 | it('should focus nativeElement', () => { 58 | jest.spyOn(directiveDebugElement.nativeElement, 'focus'); 59 | 60 | directive.focus(); 61 | 62 | expect(directiveDebugElement.nativeElement.focus).toHaveBeenCalled(); 63 | }); 64 | 65 | it('should be true if directive is passive', () => { 66 | contextMenuItem.disabled = true; 67 | expect(directive.disabled).toEqual(true); 68 | }); 69 | }); 70 | 71 | describe('#disabled', () => { 72 | it('should be false', () => { 73 | expect(directive.disabled).toEqual(false); 74 | }); 75 | 76 | it('should be true if directive is passive', () => { 77 | contextMenuItem.disabled = true; 78 | expect(directive.disabled).toEqual(true); 79 | }); 80 | }); 81 | 82 | describe('#nativeElement', () => { 83 | it('should return elementRef nativeElement', () => { 84 | expect(directive.nativeElement).toEqual( 85 | directiveDebugElement.nativeElement 86 | ); 87 | }); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/lib/directives/context-menu-content-item/context-menu-content-item.directive.ts: -------------------------------------------------------------------------------- 1 | import { FocusOrigin, FocusableOption } from '@angular/cdk/a11y'; 2 | import { Directive, ElementRef, Input } from '@angular/core'; 3 | import { evaluateIfFunction } from '../../helper/evaluate'; 4 | import { ContextMenuItemDirective } from '../context-menu-item/context-menu-item.directive'; 5 | 6 | @Directive({ 7 | selector: '[contextMenuContentItem]', 8 | exportAs: 'contextMenuContentItem', 9 | host: { 10 | class: 'ngx-context-menu-item', 11 | }, 12 | standalone: false 13 | }) 14 | export class ContextMenuContentItemDirective implements FocusableOption { 15 | @Input() 16 | public contextMenuContentItem?: ContextMenuItemDirective; 17 | 18 | public get nativeElement() { 19 | return this.elementRef.nativeElement; 20 | } 21 | 22 | constructor(private elementRef: ElementRef) {} 23 | 24 | /** 25 | * @implements FocusableOption 26 | */ 27 | public focus(origin?: FocusOrigin | undefined): void { 28 | this.elementRef.nativeElement.focus(); 29 | } 30 | 31 | /** 32 | * @implements FocusableOption 33 | */ 34 | public get disabled(): boolean | undefined { 35 | return evaluateIfFunction( 36 | this.contextMenuContentItem?.disabled, 37 | this.contextMenuContentItem?.value 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/lib/directives/context-menu-item/context-menu-item.directive.spec.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 3 | import { By } from '@angular/platform-browser'; 4 | import { ContextMenuItemDirective } from './context-menu-item.directive'; 5 | 6 | @Component({ 7 | template: '
', 8 | standalone: false, 9 | }) 10 | class TestHostComponent {} 11 | 12 | describe('Directive: ContextMenuItemDirective', () => { 13 | let fixture: ComponentFixture; 14 | let directive: ContextMenuItemDirective; 15 | 16 | beforeEach(() => { 17 | TestBed.configureTestingModule({ 18 | declarations: [ContextMenuItemDirective, TestHostComponent], 19 | }); 20 | 21 | fixture = TestBed.createComponent(TestHostComponent); 22 | const directiveEl = fixture.debugElement.query( 23 | By.directive(ContextMenuItemDirective) 24 | ); 25 | directive = directiveEl.injector.get(ContextMenuItemDirective); 26 | }); 27 | 28 | describe('#new', () => { 29 | it('should create an instance', () => { 30 | expect(directive).toBeTruthy(); 31 | }); 32 | }); 33 | 34 | describe('#disabled', () => { 35 | beforeEach(() => { 36 | directive.passive = false; 37 | directive.divider = false; 38 | directive.disabled = false; 39 | }); 40 | 41 | it('should be false', () => { 42 | expect(directive.disabled).toEqual(false); 43 | }); 44 | 45 | it('should be true if directive is passive', () => { 46 | directive.passive = true; 47 | expect(directive.disabled).toEqual(true); 48 | }); 49 | 50 | it('should be true if directive is a divider', () => { 51 | directive.divider = true; 52 | expect(directive.disabled).toEqual(true); 53 | }); 54 | 55 | it('should be true if directive is disabled', () => { 56 | directive.disabled = true; 57 | expect(directive.disabled).toEqual(true); 58 | }); 59 | 60 | it('should be false if enabled evaluate to false', () => { 61 | directive.disabled = () => false; 62 | expect(directive.disabled).toEqual(false); 63 | }); 64 | }); 65 | 66 | describe('#triggerExecute', () => { 67 | let subscriber: jest.Mock; 68 | 69 | beforeEach(() => { 70 | subscriber = jest.fn(); 71 | directive.execute.subscribe(subscriber); 72 | }); 73 | 74 | it('should emit on execute if the result of the evaluation of enabled is truthy', () => { 75 | const value = { id: 'item' }; 76 | directive.disabled = () => false; 77 | const event = new MouseEvent('click'); 78 | directive.triggerExecute(event, value); 79 | expect(subscriber).toHaveBeenCalledWith({ event, value }); 80 | }); 81 | 82 | it('should emit on execute if enabled is truthy', () => { 83 | const value = { id: 'item' }; 84 | directive.disabled = false; 85 | const event = new MouseEvent('click'); 86 | directive.triggerExecute(event, value); 87 | expect(subscriber).toHaveBeenCalledWith({ event, value }); 88 | }); 89 | 90 | it('should not emit on execute if enabled is falsy', () => { 91 | const value = { id: 'item' }; 92 | directive.disabled = true; 93 | const event = new MouseEvent('click'); 94 | directive.triggerExecute(event, value); 95 | expect(subscriber).not.toHaveBeenCalled(); 96 | }); 97 | 98 | it('should not emit on execute if the result of the evaluation of enabled is falsy', () => { 99 | const value = { id: 'item' }; 100 | directive.disabled = () => true; 101 | const event = new MouseEvent('click'); 102 | directive.triggerExecute(event, value); 103 | expect(subscriber).not.toHaveBeenCalled(); 104 | }); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/lib/directives/context-menu-item/context-menu-item.directive.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Directive, 3 | EventEmitter, 4 | Input, 5 | Optional, 6 | Output, 7 | TemplateRef, 8 | } from '@angular/core'; 9 | import type { ContextMenuComponent } from '../../components/context-menu/context-menu.component'; 10 | import { evaluateIfFunction } from '../../helper/evaluate'; 11 | 12 | @Directive({ 13 | selector: '[contextMenuItem]', 14 | standalone: false 15 | }) 16 | export class ContextMenuItemDirective { 17 | /** 18 | * Optional subMenu component ref 19 | */ 20 | @Input() 21 | public subMenu?: ContextMenuComponent; 22 | 23 | /** 24 | * True to make this menu item a divider 25 | */ 26 | @Input() 27 | public divider = false; 28 | 29 | /** 30 | * Is this menu item disabled 31 | */ 32 | @Input() 33 | public set disabled(disabled: boolean | ((value?: T) => boolean)) { 34 | this.#disabled = disabled; 35 | } 36 | 37 | public get disabled(): boolean { 38 | return ( 39 | this.passive || 40 | this.divider || 41 | evaluateIfFunction(this.#disabled, this.value) 42 | ); 43 | } 44 | 45 | /** 46 | * Is this menu item passive (for title) 47 | */ 48 | @Input() 49 | public passive = false; 50 | 51 | /** 52 | * Is this menu item visible 53 | */ 54 | @Input() 55 | public visible: boolean | ((value?: T) => boolean) = true; 56 | 57 | /** 58 | * Emits event and item 59 | */ 60 | @Output() 61 | public execute = new EventEmitter<{ 62 | event: MouseEvent | KeyboardEvent; 63 | value?: T; 64 | }>(); 65 | 66 | /** 67 | * @internal 68 | */ 69 | public value?: T; 70 | 71 | #disabled: boolean | ((value?: T) => boolean) = false; 72 | 73 | constructor( 74 | @Optional() 75 | public template: TemplateRef<{ $implicit?: T }> 76 | ) {} 77 | 78 | /** 79 | * @internal 80 | */ 81 | public triggerExecute(event: MouseEvent | KeyboardEvent, value?: T): void { 82 | if (evaluateIfFunction(this.#disabled, value)) { 83 | return; 84 | } 85 | 86 | this.execute.emit({ event, value }); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/lib/directives/context-menu/context-menu.directive.integ.spec.ts: -------------------------------------------------------------------------------- 1 | import { OverlayModule } from '@angular/cdk/overlay'; 2 | import { createHostFactory, SpectatorHost } from '@ngneat/spectator/jest'; 3 | import { ContextMenuContentComponent } from '../../components/context-menu-content/context-menu-content.component'; 4 | import { ContextMenuComponent } from '../../components/context-menu/context-menu.component'; 5 | import { ContextMenuItemDirective } from '../../directives/context-menu-item/context-menu-item.directive'; 6 | import { ContextMenuService } from '../../services/context-menu/context-menu.service'; 7 | import { ContextMenuDirective } from './context-menu.directive'; 8 | import { ContextMenuContentItemDirective } from '../context-menu-content-item/context-menu-content-item.directive'; 9 | 10 | describe('Integ: ContextMenuDirective', () => { 11 | let host: SpectatorHost>; 12 | 13 | const createHost = createHostFactory({ 14 | component: ContextMenuDirective, 15 | declarations: [ 16 | ContextMenuItemDirective, 17 | ContextMenuComponent, 18 | ContextMenuContentComponent, 19 | ContextMenuContentItemDirective, 20 | ], 21 | providers: [ContextMenuService], 22 | imports: [OverlayModule], 23 | shallow: false, 24 | detectChanges: true, 25 | }); 26 | 27 | afterEach(() => { 28 | host.fixture.destroy(); 29 | }); 30 | 31 | it('should render', () => { 32 | host = createHost('
'); 33 | expect(host.queryHost(ContextMenuDirective)).toExist(); 34 | }); 35 | 36 | describe('with menu items', () => { 37 | it('should open context menu', () => { 38 | host = createHost( 39 | ` 40 |
Right click
41 | 42 | A 43 | B 44 | C 45 | D 46 | 47 | DD 48 | 49 | 50 | `, 51 | { hostProps: { item: { id: 'item-id' } } } 52 | ); 53 | host.dispatchMouseEvent(host.debugElement, 'contextmenu'); 54 | 55 | expect( 56 | host.query( 57 | '.cdk-overlay-container .ngx-contextmenu-overlay context-menu-content', 58 | { 59 | root: true, 60 | } 61 | ) 62 | ).toExist(); 63 | expect( 64 | host.queryAll( 65 | '.cdk-overlay-container .ngx-contextmenu-overlay context-menu-content > *:nth-child(1)', 66 | { 67 | root: true, 68 | } 69 | ) 70 | ).toHaveAttribute('disabled', 'disabled'); 71 | expect( 72 | host.query( 73 | '.cdk-overlay-container .ngx-contextmenu-overlay context-menu-content > *:nth-child(2)', 74 | { 75 | root: true, 76 | } 77 | ) 78 | ).toHaveAttribute('role', 'separator'); 79 | expect( 80 | host.query( 81 | '.cdk-overlay-container .ngx-contextmenu-overlay context-menu-content > *:nth-child(3)', 82 | { 83 | root: true, 84 | } 85 | ) 86 | ).toHaveAttribute('aria-haspopup'); 87 | }); 88 | 89 | it('should navigate the menu on arrow keys', () => { 90 | host = createHost( 91 | ` 92 |
Right click
93 | 94 | A 95 | B 96 | C 97 | D 98 | 99 | DD 100 | 101 | 102 | `, 103 | { hostProps: { item: { id: 'item-id' } } } 104 | ); 105 | host.dispatchMouseEvent(host.debugElement, 'contextmenu'); 106 | expect( 107 | host.query( 108 | '.cdk-overlay-container .ngx-contextmenu-overlay context-menu-content', 109 | { 110 | root: true, 111 | } 112 | ) 113 | ).toEqual(document.activeElement); 114 | host.dispatchKeyboardEvent( 115 | document.activeElement as HTMLElement, 116 | 'keydown', 117 | { 118 | key: 'ArrowDown', 119 | keyCode: 40, 120 | } 121 | ); 122 | expect( 123 | host.query( 124 | '.cdk-overlay-container .ngx-contextmenu-overlay context-menu-content > *:nth-child(1)', 125 | { 126 | root: true, 127 | } 128 | ) 129 | ).toEqual(document.activeElement); 130 | host.dispatchKeyboardEvent( 131 | document.activeElement as HTMLElement, 132 | 'keydown', 133 | { 134 | key: 'ArrowDown', 135 | keyCode: 40, 136 | } 137 | ); 138 | expect( 139 | host.query( 140 | '.cdk-overlay-container .ngx-contextmenu-overlay context-menu-content > *:nth-child(2)', 141 | { 142 | root: true, 143 | } 144 | ) 145 | ).toEqual(document.activeElement); 146 | host.dispatchKeyboardEvent( 147 | document.activeElement as HTMLElement, 148 | 'keydown', 149 | { 150 | key: 'ArrowDown', 151 | keyCode: 40, 152 | } 153 | ); 154 | expect( 155 | host.query( 156 | '.cdk-overlay-container .ngx-contextmenu-overlay context-menu-content > *:nth-child(4)', 157 | { 158 | root: true, 159 | } 160 | ) 161 | ).toEqual(document.activeElement); 162 | expect( 163 | host.query( 164 | '.cdk-overlay-container .cdk-overlay-connected-position-bounding-box:nth-child(2)', 165 | { 166 | root: true, 167 | } 168 | ) 169 | ).not.toExist(); 170 | host.dispatchKeyboardEvent( 171 | document.activeElement as HTMLElement, 172 | 'keydown', 173 | { 174 | key: 'ArrowRight', 175 | keyCode: 39, 176 | } 177 | ); 178 | host.dispatchKeyboardEvent( 179 | document.activeElement as HTMLElement, 180 | 'keydown', 181 | { 182 | key: 'ArrowDown', 183 | keyCode: 40, 184 | } 185 | ); 186 | expect( 187 | host.query('.submenu-item', { 188 | root: true, 189 | })?.parentElement 190 | ).toEqual(document.activeElement as HTMLElement); 191 | }); 192 | }); 193 | 194 | describe('programmatically open and close menu', () => { 195 | it('should open the menu when clicking and close when typing z', () => { 196 | host = createHost( 197 | ` 198 |
Right click
205 | 206 | A 207 | B 208 | C 209 | D 210 | 211 | DD 212 | 213 | 214 | `, 215 | { hostProps: { item: { id: 'item-id' } } } 216 | ); 217 | host.dispatchMouseEvent(host.debugElement, 'click'); 218 | host.dispatchKeyboardEvent( 219 | document.activeElement as HTMLElement, 220 | 'keydown', 221 | { 222 | key: 'ArrowDown', 223 | keyCode: 40, 224 | } 225 | ); 226 | expect( 227 | host.query( 228 | '.cdk-overlay-container .ngx-contextmenu-overlay context-menu-content > *:nth-child(1)', 229 | { 230 | root: true, 231 | } 232 | ) 233 | ).toEqual(document.activeElement); 234 | host.dispatchKeyboardEvent( 235 | document.activeElement as HTMLElement, 236 | 'keydown', 237 | { 238 | key: 'z', 239 | keyCode: 90, 240 | } 241 | ); 242 | expect( 243 | host.query( 244 | '.cdk-overlay-container .ngx-contextmenu-overlay context-menu-content > *:nth-child(1)', 245 | { 246 | root: true, 247 | } 248 | ) 249 | ).not.toEqual(document.activeElement); 250 | }); 251 | 252 | it('should open the menu when typing y and close when typing z', () => { 253 | host = createHost( 254 | ` 255 |
Right click
262 | 263 | A 264 | B 265 | C 266 | D 267 | 268 | DD 269 | 270 | 271 | `, 272 | { hostProps: { item: { id: 'item-id' } } } 273 | ); 274 | host.dispatchKeyboardEvent( 275 | document.activeElement as HTMLElement, 276 | 'keydown', 277 | { 278 | key: 'y', 279 | keyCode: 90, 280 | } 281 | ); 282 | host.dispatchKeyboardEvent( 283 | document.activeElement as HTMLElement, 284 | 'keydown', 285 | { 286 | key: 'ArrowDown', 287 | keyCode: 40, 288 | } 289 | ); 290 | expect( 291 | host.query( 292 | '.cdk-overlay-container .ngx-contextmenu-overlay context-menu-content > *:nth-child(1)', 293 | { 294 | root: true, 295 | } 296 | ) 297 | ).toEqual(document.activeElement); 298 | host.dispatchKeyboardEvent( 299 | document.activeElement as HTMLElement, 300 | 'keydown', 301 | { 302 | key: 'z', 303 | keyCode: 90, 304 | } 305 | ); 306 | expect( 307 | host.query( 308 | '.cdk-overlay-container .ngx-contextmenu-overlay context-menu-content > *:nth-child(1)', 309 | { 310 | root: true, 311 | } 312 | ) 313 | ).not.toEqual(document.activeElement); 314 | }); 315 | }); 316 | }); 317 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/lib/directives/context-menu/context-menu.directive.spec.ts: -------------------------------------------------------------------------------- 1 | import { OverlayModule } from '@angular/cdk/overlay'; 2 | import { Component, DebugElement } from '@angular/core'; 3 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 4 | import { By } from '@angular/platform-browser'; 5 | import { ContextMenuContentComponent } from '../../components/context-menu-content/context-menu-content.component'; 6 | import { ContextMenuComponent } from '../../components/context-menu/context-menu.component'; 7 | import { ContextMenuOverlaysService } from '../../services/context-menu-overlays/context-menu-overlays.service'; 8 | import { ContextMenuDirective } from './context-menu.directive'; 9 | 10 | @Component({ 11 | template: '
', 12 | standalone: false, 13 | }) 14 | class TestHostComponent {} 15 | 16 | describe('Directive: ContextMenuDirective', () => { 17 | let fixture: ComponentFixture; 18 | let directive: ContextMenuDirective; 19 | let directiveEl: DebugElement; 20 | 21 | beforeEach(() => { 22 | TestBed.configureTestingModule({ 23 | imports: [OverlayModule], 24 | declarations: [ 25 | ContextMenuDirective, 26 | ContextMenuContentComponent, 27 | TestHostComponent, 28 | ], 29 | }); 30 | 31 | fixture = TestBed.createComponent(TestHostComponent); 32 | directiveEl = fixture.debugElement.query( 33 | By.directive(ContextMenuDirective) 34 | ); 35 | directive = directiveEl.injector.get(ContextMenuDirective); 36 | }); 37 | 38 | afterEach(() => { 39 | fixture.destroy(); 40 | }); 41 | 42 | describe('#new', () => { 43 | it('should create an instance', () => { 44 | expect(directive).toBeTruthy(); 45 | }); 46 | }); 47 | 48 | describe('#onContextMenu', () => { 49 | let contextMenu: ContextMenuComponent; 50 | beforeEach(() => { 51 | contextMenu = 52 | TestBed.createComponent(ContextMenuComponent).componentInstance; 53 | 54 | jest.spyOn(contextMenu, 'show'); 55 | }); 56 | 57 | it('should close all context menus', () => { 58 | const contextMenuOverlaysService = TestBed.inject( 59 | ContextMenuOverlaysService 60 | ); 61 | 62 | jest.spyOn(contextMenuOverlaysService, 'closeAll'); 63 | directive.contextMenuValue = { id: 'a' }; 64 | const event = new MouseEvent('contextmenu'); 65 | directive.onContextMenu(event); 66 | expect(contextMenuOverlaysService.closeAll).not.toHaveBeenCalled(); 67 | }); 68 | 69 | it('should show attached context menu', () => { 70 | directive.contextMenu = contextMenu; 71 | directive.contextMenuValue = { id: 'a' }; 72 | const event = new MouseEvent('contextmenu', { clientX: 42, clientY: 34 }); 73 | directive.onContextMenu(event); 74 | expect(directive.contextMenu.show).toHaveBeenCalledWith({ 75 | anchoredTo: 'position', 76 | x: 42, 77 | y: 34, 78 | value: directive.contextMenuValue, 79 | }); 80 | }); 81 | 82 | it('should prevent default and stop propagation', () => { 83 | directive.contextMenu = contextMenu; 84 | directive.contextMenuValue = { id: 'a' }; 85 | const event = new MouseEvent('contextmenu', { clientX: 42, clientY: 34 }); 86 | jest.spyOn(event, 'preventDefault'); 87 | jest.spyOn(event, 'stopPropagation'); 88 | directive.onContextMenu(event); 89 | expect(event.preventDefault).toHaveBeenCalledWith(); 90 | expect(event.stopPropagation).toHaveBeenCalledWith(); 91 | }); 92 | 93 | it('should not show attached context menu if it is disabled', () => { 94 | directive.contextMenu = contextMenu; 95 | directive.contextMenu.disabled = true; 96 | directive.contextMenuValue = { id: 'a' }; 97 | const event = new MouseEvent('contextmenu'); 98 | directive.onContextMenu(event); 99 | expect(directive.contextMenu.show).not.toHaveBeenCalled(); 100 | }); 101 | 102 | it('should show nothing if not context menu is attached', () => { 103 | directive.contextMenuValue = { id: 'a' }; 104 | const event = new MouseEvent('contextmenu'); 105 | directive.onContextMenu(event); 106 | expect(contextMenu.show).not.toHaveBeenCalled(); 107 | }); 108 | }); 109 | 110 | describe('#open', () => { 111 | describe('when using mouse event', () => { 112 | let contextMenu: ContextMenuComponent; 113 | beforeEach(() => { 114 | contextMenu = 115 | TestBed.createComponent(ContextMenuComponent).componentInstance; 116 | 117 | jest.spyOn(contextMenu, 'show'); 118 | }); 119 | 120 | it('should show attached context menu', () => { 121 | directive.contextMenu = contextMenu; 122 | directive.contextMenuValue = { id: 'a' }; 123 | const event = new MouseEvent('contextmenu', { 124 | clientX: 42, 125 | clientY: 34, 126 | }); 127 | directive.open(event); 128 | expect(directive.contextMenu.show).toHaveBeenCalledWith({ 129 | anchoredTo: 'position', 130 | x: 42, 131 | y: 34, 132 | value: directive.contextMenuValue, 133 | }); 134 | }); 135 | 136 | it('should not show attached context menu if it is disabled', () => { 137 | directive.contextMenu = contextMenu; 138 | directive.contextMenu.disabled = true; 139 | directive.contextMenuValue = { id: 'a' }; 140 | const event = new MouseEvent('contextmenu'); 141 | directive.open(event); 142 | expect(directive.contextMenu.show).not.toHaveBeenCalled(); 143 | }); 144 | 145 | it('should show nothing if not context menu is attached', () => { 146 | directive.contextMenuValue = { id: 'a' }; 147 | const event = new MouseEvent('contextmenu'); 148 | directive.open(event); 149 | expect(contextMenu.show).not.toHaveBeenCalled(); 150 | }); 151 | }); 152 | 153 | describe('when using no event', () => { 154 | let contextMenu: ContextMenuComponent; 155 | beforeEach(() => { 156 | contextMenu = 157 | TestBed.createComponent(ContextMenuComponent).componentInstance; 158 | 159 | jest.spyOn(contextMenu, 'show'); 160 | }); 161 | 162 | it('should show attached context menu', () => { 163 | directive.contextMenu = contextMenu; 164 | jest 165 | .spyOn( 166 | directiveEl.nativeElement as HTMLElement, 167 | 'getBoundingClientRect' 168 | ) 169 | .mockReturnValue({ 170 | x: 100, 171 | y: 200, 172 | height: 20, 173 | } as DOMRect); 174 | directive.contextMenuValue = { id: 'a' }; 175 | directive.open(); 176 | expect(contextMenu.show).toHaveBeenCalledWith({ 177 | anchoredTo: 'position', 178 | x: 100, 179 | y: 220, 180 | value: directive.contextMenuValue, 181 | }); 182 | }); 183 | 184 | it('should not show attached context menu if it is disabled', () => { 185 | directive.contextMenu = contextMenu; 186 | directive.contextMenu.disabled = true; 187 | directive.contextMenuValue = { id: 'a' }; 188 | directive.open(); 189 | expect(contextMenu.show).not.toHaveBeenCalled(); 190 | }); 191 | 192 | it('should show nothing if not context menu is attached', () => { 193 | directive.contextMenuValue = { id: 'a' }; 194 | directive.open(); 195 | expect(contextMenu.show).not.toHaveBeenCalled(); 196 | }); 197 | }); 198 | }); 199 | 200 | describe('#close', () => { 201 | it('should close all context menu', () => { 202 | directive.contextMenu = 203 | TestBed.createComponent(ContextMenuComponent).componentInstance; 204 | jest.spyOn(directive.contextMenu, 'hide'); 205 | directive.close(); 206 | expect(directive.contextMenu.hide).toHaveBeenCalledWith(); 207 | }); 208 | }); 209 | }); 210 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/lib/directives/context-menu/context-menu.directive.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Directive, 3 | ElementRef, 4 | HostBinding, 5 | HostListener, 6 | Input, 7 | } from '@angular/core'; 8 | import type { ContextMenuComponent } from '../../components/context-menu/context-menu.component'; 9 | import { ContextMenuOverlaysService } from '../../services/context-menu-overlays/context-menu-overlays.service'; 10 | 11 | @Directive({ 12 | selector: '[contextMenu]', 13 | exportAs: 'ngxContextMenu', 14 | host: { 15 | role: 'button', 16 | 'attr.aria-haspopup': 'menu', 17 | }, 18 | standalone: false 19 | }) 20 | export class ContextMenuDirective { 21 | /** 22 | * The value related to the context menu 23 | */ 24 | @Input() 25 | public contextMenuValue!: T; 26 | 27 | /** 28 | * The component holding the menu item directive templates 29 | */ 30 | @Input() 31 | public contextMenu?: ContextMenuComponent; 32 | 33 | /** 34 | * The directive must have a tabindex for being accessible 35 | */ 36 | @Input() 37 | @HostBinding('attr.tabindex') 38 | public tabindex: string | number = '0'; 39 | 40 | /** 41 | * Return true if the context menu is opened, false otherwise 42 | */ 43 | @HostBinding('attr.aria-expanded') 44 | public get isOpen(): boolean { 45 | return this.contextMenu?.isOpen ?? false; 46 | } 47 | 48 | constructor( 49 | private elementRef: ElementRef, 50 | private contextMenuOverlaysService: ContextMenuOverlaysService 51 | ) {} 52 | 53 | /** 54 | * @internal 55 | */ 56 | @HostListener('contextmenu', ['$event']) 57 | public onContextMenu(event: MouseEvent): void { 58 | if (!this.canOpen()) { 59 | return; 60 | } 61 | 62 | this.closeAll(); 63 | 64 | this.contextMenu?.show({ 65 | anchoredTo: 'position', 66 | x: event.clientX, 67 | y: event.clientY, 68 | value: this.contextMenuValue, 69 | }); 70 | 71 | event.preventDefault(); 72 | event.stopPropagation(); 73 | } 74 | 75 | /** 76 | * @internal 77 | */ 78 | @HostListener('window:contextmenu') 79 | @HostListener('window:keydown.Escape') 80 | public onKeyArrowEscape(): void { 81 | this.close(); 82 | } 83 | 84 | /** 85 | * Programmatically open the context menu 86 | */ 87 | public open(event?: MouseEvent): void { 88 | if (!this.canOpen()) { 89 | return; 90 | } 91 | 92 | if (event instanceof MouseEvent) { 93 | this.onContextMenu(event); 94 | return; 95 | } 96 | 97 | const { x, y, height } = 98 | this.elementRef.nativeElement.getBoundingClientRect(); 99 | 100 | this.contextMenu?.show({ 101 | anchoredTo: 'position', 102 | x, 103 | y: y + height, 104 | value: this.contextMenuValue, 105 | }); 106 | } 107 | 108 | /** 109 | * Programmatically close the context menu 110 | */ 111 | public close(): void { 112 | this.contextMenu?.hide(); 113 | } 114 | 115 | private closeAll(): void { 116 | this.contextMenuOverlaysService.closeAll(); 117 | } 118 | 119 | private canOpen(): boolean { 120 | return (this.contextMenu && !this.contextMenu.disabled) ?? false; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/lib/helper/evaluate.spec.ts: -------------------------------------------------------------------------------- 1 | import { evaluateIfFunction } from './evaluate'; 2 | 3 | describe('#evaluateIfFunction', () => { 4 | it('should return the given value if not a function', () => { 5 | const value = true; 6 | const result = evaluateIfFunction(value); 7 | expect(result).toBe(true); 8 | }); 9 | 10 | it('should return the given value if not a function', () => { 11 | const value = false; 12 | const result = evaluateIfFunction(value); 13 | expect(result).toBe(false); 14 | }); 15 | 16 | it('should return the result of the evaluation of value if it is a function', () => { 17 | const item = { id: 'item' }; 18 | const actualResult = true; 19 | const testValue = jest.fn(); 20 | const value = (...args: unknown[]) => { 21 | testValue(...args); 22 | return actualResult; 23 | }; 24 | const result = evaluateIfFunction(value, item); 25 | expect(testValue).toHaveBeenCalledWith(item); 26 | expect(result).toBe(true); 27 | }); 28 | 29 | it('should return the result of the evaluation of value if it is a function', () => { 30 | const item = { id: 'item' }; 31 | const actualResult = false; 32 | const testValue = jest.fn(); 33 | const value = (...args: unknown[]) => { 34 | testValue(...args); 35 | return actualResult; 36 | }; 37 | const result = evaluateIfFunction(value, item); 38 | expect(testValue).toHaveBeenCalledWith(item); 39 | expect(result).toBe(false); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/lib/helper/evaluate.ts: -------------------------------------------------------------------------------- 1 | export const evaluateIfFunction = ( 2 | value: boolean | ((value?: T) => boolean) | undefined, 3 | item?: T 4 | ): boolean => { 5 | if (value instanceof Function) { 6 | return value(item); 7 | } 8 | return !!value; 9 | }; 10 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/lib/ngx-contextmenu.module.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FullscreenOverlayContainer, 3 | OverlayContainer, 4 | OverlayModule, 5 | } from '@angular/cdk/overlay'; 6 | import { CommonModule } from '@angular/common'; 7 | import { NgModule } from '@angular/core'; 8 | import { ContextMenuContentComponent } from './components/context-menu-content/context-menu-content.component'; 9 | import { ContextMenuComponent } from './components/context-menu/context-menu.component'; 10 | import { ContextMenuContentItemDirective } from './directives/context-menu-content-item/context-menu-content-item.directive'; 11 | import { ContextMenuItemDirective } from './directives/context-menu-item/context-menu-item.directive'; 12 | import { ContextMenuDirective } from './directives/context-menu/context-menu.directive'; 13 | 14 | @NgModule({ 15 | declarations: [ 16 | ContextMenuComponent, 17 | ContextMenuContentComponent, 18 | ContextMenuContentItemDirective, 19 | ContextMenuDirective, 20 | ContextMenuItemDirective, 21 | ], 22 | providers: [ 23 | { provide: OverlayContainer, useClass: FullscreenOverlayContainer }, 24 | ], 25 | exports: [ 26 | ContextMenuDirective, 27 | ContextMenuComponent, 28 | ContextMenuItemDirective, 29 | ], 30 | imports: [CommonModule, OverlayModule], 31 | }) 32 | export class ContextMenuModule {} 33 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/lib/services/context-menu-overlays/context-menu-overlays.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Overlay, OverlayModule, OverlayRef } from '@angular/cdk/overlay'; 2 | import { TestBed } from '@angular/core/testing'; 3 | import { ContextMenuOverlaysService } from './context-menu-overlays.service'; 4 | 5 | describe('Service: ContextMenuOverlaysService', () => { 6 | let service: ContextMenuOverlaysService; 7 | 8 | const createOverlayRef = (): OverlayRef => { 9 | return TestBed.inject(Overlay).create(); 10 | }; 11 | 12 | beforeEach(() => { 13 | TestBed.configureTestingModule({ 14 | imports: [OverlayModule], 15 | }); 16 | service = TestBed.inject(ContextMenuOverlaysService); 17 | }); 18 | 19 | it('should be created', () => { 20 | expect(service).toBeTruthy(); 21 | }); 22 | 23 | describe('#push', () => { 24 | it('should push an item to the stack', () => { 25 | expect(service.isEmpty()).toEqual(true); 26 | const item = createOverlayRef(); 27 | service.push(item); 28 | expect(service.isEmpty()).toEqual(false); 29 | const item2 = createOverlayRef(); 30 | service.push(item2); 31 | expect(service.isEmpty()).toEqual(false); 32 | const item3 = createOverlayRef(); 33 | service.push(item3); 34 | expect(service.isEmpty()).toEqual(false); 35 | 36 | service.closeAll(); 37 | expect(service.isEmpty()).toEqual(true); 38 | }); 39 | }); 40 | 41 | describe('#isEmpty', () => { 42 | it('should return true if the service is empty', () => { 43 | expect(service.isEmpty()).toEqual(true); 44 | }); 45 | 46 | it('should return false if the service is not empty', () => { 47 | const item3 = createOverlayRef(); 48 | service.push(item3); 49 | expect(service.isEmpty()).toEqual(false); 50 | }); 51 | }); 52 | 53 | describe('#closeAll', () => { 54 | it('should empty the service', () => { 55 | const item = createOverlayRef(); 56 | service.push(item); 57 | 58 | service.closeAll(); 59 | expect(service.isEmpty()).toEqual(true); 60 | }); 61 | 62 | it('should emit on allClosed', () => { 63 | const subscriber = jest.fn(); 64 | service.allClosed.subscribe(subscriber); 65 | service.closeAll(); 66 | expect(subscriber).toHaveBeenCalledWith(undefined); 67 | }); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/lib/services/context-menu-overlays/context-menu-overlays.service.ts: -------------------------------------------------------------------------------- 1 | import { OverlayRef } from '@angular/cdk/overlay'; 2 | import { EventEmitter, Injectable } from '@angular/core'; 3 | 4 | @Injectable({ 5 | providedIn: 'root', 6 | }) 7 | export class ContextMenuOverlaysService { 8 | /** 9 | * Emits when all context menus are closed 10 | */ 11 | public allClosed = new EventEmitter(); 12 | 13 | private stack: OverlayRef[] = []; 14 | 15 | /** 16 | * Add an item to the stack 17 | */ 18 | public push(value: OverlayRef) { 19 | this.stack.push(value); 20 | } 21 | 22 | /** 23 | * Clear the whole stack 24 | */ 25 | public closeAll(): void { 26 | this.stack.forEach((item) => this.dispose(item)); 27 | this.stack = []; 28 | this.allClosed.emit(); 29 | } 30 | 31 | public isEmpty(): boolean { 32 | return this.stack.length === 0; 33 | } 34 | 35 | private dispose(overlayRef: OverlayRef) { 36 | overlayRef.detach(); 37 | overlayRef.dispose(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/lib/services/context-menu/context-menu.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { ContextMenuContentComponent } from '../../components/context-menu-content/context-menu-content.component'; 3 | import { ContextMenuComponent } from '../../components/context-menu/context-menu.component'; 4 | import { ContextMenuOverlaysService } from '../context-menu-overlays/context-menu-overlays.service'; 5 | import { ContextMenuService } from './context-menu.service'; 6 | 7 | describe('Service: ContextMenuService', () => { 8 | let service: ContextMenuService; 9 | let contextMenuOverlaysService: ContextMenuOverlaysService; 10 | 11 | beforeEach(() => { 12 | TestBed.configureTestingModule({ 13 | declarations: [ContextMenuComponent, ContextMenuContentComponent], 14 | }); 15 | service = TestBed.inject(ContextMenuService); 16 | contextMenuOverlaysService = TestBed.inject(ContextMenuOverlaysService); 17 | }); 18 | 19 | it('should be created', () => { 20 | expect(service).toBeTruthy(); 21 | }); 22 | 23 | describe('#show', () => { 24 | it('should emit a show event', () => { 25 | const component = 26 | TestBed.createComponent(ContextMenuComponent).componentInstance; 27 | jest.spyOn(component, 'show'); 28 | service.show(component); 29 | expect(component.show).toHaveBeenCalledWith({ 30 | anchoredTo: 'position', 31 | x: 0, 32 | y: 0, 33 | value: undefined, 34 | }); 35 | }); 36 | 37 | it('should emit a show event with options', () => { 38 | const component = 39 | TestBed.createComponent(ContextMenuComponent).componentInstance; 40 | jest.spyOn(component, 'show'); 41 | service.show(component, { x: 42, y: 34, value: { any: 'thing' } }); 42 | expect(component.show).toHaveBeenCalledWith({ 43 | anchoredTo: 'position', 44 | x: 42, 45 | y: 34, 46 | value: { any: 'thing' }, 47 | }); 48 | }); 49 | }); 50 | 51 | describe('#closeAll', () => { 52 | it('should trigger closeAll', () => { 53 | jest.spyOn(contextMenuOverlaysService, 'closeAll'); 54 | service.closeAll(); 55 | expect(contextMenuOverlaysService.closeAll).toHaveBeenCalled(); 56 | }); 57 | }); 58 | 59 | describe('#hasOpenMenu', () => { 60 | it('should get information from overlays service', () => { 61 | const spy = jest 62 | .spyOn(contextMenuOverlaysService, 'isEmpty') 63 | .mockReturnValue(true); 64 | expect(service.hasOpenMenu()).toEqual(false); 65 | spy.mockReturnValue(false); 66 | expect(service.hasOpenMenu()).toEqual(true); 67 | expect(contextMenuOverlaysService.isEmpty).toHaveBeenCalledTimes(2); 68 | }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/lib/services/context-menu/context-menu.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ContextMenuComponent } from '../../components/context-menu/context-menu.component'; 3 | import { ContextMenuOverlaysService } from '../context-menu-overlays/context-menu-overlays.service'; 4 | 5 | export interface ContextMenuOpenAtPositionOptions { 6 | /** 7 | * Optional associated data to the context menu, will be emitted when a menu item is selected 8 | */ 9 | value?: T; 10 | /** 11 | * The horizontal position of the menu 12 | */ 13 | x: number; 14 | /** 15 | * The vertical position of the menu 16 | */ 17 | y: number; 18 | } 19 | 20 | /** 21 | * Programmatically open a ContextMenuComponent to a X/Y position 22 | */ 23 | @Injectable({ 24 | providedIn: 'root', 25 | }) 26 | export class ContextMenuService { 27 | constructor(private contextMenuOverlaysService: ContextMenuOverlaysService) {} 28 | /** 29 | * Show the given `ContextMenuComponent` at a specified X/Y position 30 | */ 31 | public show( 32 | contextMenu: ContextMenuComponent, 33 | options: ContextMenuOpenAtPositionOptions = { x: 0, y: 0 } 34 | ) { 35 | contextMenu.show({ 36 | anchoredTo: 'position', 37 | value: options.value, 38 | x: options.x, 39 | y: options.y, 40 | }); 41 | } 42 | 43 | /** 44 | * Close all open `ContextMenuComponent` 45 | */ 46 | public closeAll(): void { 47 | this.contextMenuOverlaysService.closeAll(); 48 | } 49 | 50 | /** 51 | * Return true if any `ContextMenuComponent` is open 52 | */ 53 | public hasOpenMenu(): boolean { 54 | return !this.contextMenuOverlaysService.isEmpty(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/stories/APContextMenuService.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/blocks'; 2 | 3 | 4 | 5 | # ContextMenuService 6 | 7 | The `ContextMenuService` is an Angular service that can be injected in your component or service. 8 | It provides: 9 | 10 | * a `closeAll` method that will close any open context menu 11 | * a `hasOpenMenu` to check if there is at least one context menu opened 12 | * a `show` method to programmatically open a context menu, but it mots cases you would prefer to use the open method of the context menu directive 13 | 14 | | `ContextMenuService` | Parameters | Description | 15 | | :------------------- | :----------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------- | 16 | | `closeAll()` | - | Close all open context menus | 17 | | `hasOpenMenu` | - | Check if there is at least one context menu opened | 18 | | `show` | - contextMenu: ContextMenuComponent
- options: ContextMenuOpenAtPositionOptions (default: \{ x: 0, y: 0 }) | Programmatically open a context menu, but in most cases you should prefer to use the open method of the context menu directive | 19 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/stories/APIContextMenuComponent.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/blocks'; 2 | 3 | 4 | 5 | # `` component 6 | 7 | A `` component to hold the menu content and be referenced by the `[contextMenu]` directive 8 | 9 | > NB. this component has no template itself and can only have `` children, 10 | > as you might guess it is not intended to be rendered in place but to populate the context menu overlay 11 | 12 | | `Input()` | Type | Description | 13 | | :------------ | :------------------------------------ | :--------------------------------------------------------------------------------------- | 14 | | `[menuClass]` | `string`
(default: `''`) | A CSS class to apply to the context menu overlay, ideal for theming and custom styling | 15 | | `[disabled]` | `boolean`
(default: `false`) | Disable the whole context menu | 16 | | `[dir]` | `ltr` | `rtl`
(default: `ltr`) | Whether the menu is oriented to the right
(default: `ltr`) or to the right (`rtl`) | 17 | 18 | | `Output()` | Type | Description | 19 | | :--------- | :--------------------- | :--------------------------- | 20 | | `(open)` | `ContextMenuOpenEvent` | Emit when the menu is opened | 21 | | `(close)` | `void` | Emit when the menu is closed | 22 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/stories/APIContextMenuDirective.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/blocks'; 2 | 3 | 4 | 5 | # `[contextMenu]` directive 6 | 7 | A `contextMenu` directive to apply to any HTML element where a right click should display a context menu 8 | 9 | | `Input()` | Type | Description | 10 | | :------------------- | :---------------------------- | :------------------------------------------------------ | 11 | | `[contextMenu]` | `ContextMenuComponent` | The component holding the menu item directive templates | 12 | | `[contextMenuValue]` | `T` (Generic) | The value related to the context menu | 13 | | `[tabindex]` | `number`
(default: `0`) | The default tabindex for the directive to be accessible | 14 | 15 | | `Output()` | Parameters | Description | 16 | | :--------- | :------------------------------ | :--------------------------------------------------------------------------------------------------------------------------------- | 17 | | `open()` | `event?: MouseEvent` (optional) | Programmatically opens the context menu. If a MousEvent is passed, it will be used to position the menu next to the mouse position | 18 | | `close()` | | Programmatically closes the context menu | 19 | 20 | > NB: `open` and `close` methods are for advanced use cases and are not necessary for usual right click 21 | 22 | ```html 23 | 24 | When you right click on this text, a context menu will appear 25 | 26 | 27 | ... 28 | 29 | 30 | 33 | ``` 34 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/stories/APIContextMenuItemDirective.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/blocks'; 2 | 3 | 4 | 5 | # `[contextMenuItem]` directive API 6 | 7 | A `contextMenuItem` directive to apply to a `` inside the `` component 8 | 9 | | `Input()` | Type | Description | 10 | | :----------- | :------------------------------------------------------------ | :--------------------------------------------------------------------- | 11 | | `[subMenu]` | `ContextMenuComponent` | The component holding the sub menu item directive templates | 12 | | `[divider]` | `boolean`
(default: `false`) | Is this menu item a divider (no action is executed) | 13 | | `[disabled]` | `boolean` | `(item?: T) => boolean`
(default: `false`) | Is this menu item disabled (no action is executed) | 14 | | `[passive]` | `boolean`
(default: `false`) | Is this menu item passive (for title, or forms, no action is executed) | 15 | | `[visible]` | `boolean` | `(item?: T) => boolean`
(default: `false`) | Is this menu item visible | 16 | 17 | | `Output()` | Type | Description | 18 | | :---------- | :--------------------------------------------- | :---------------------------- | 19 | | `(execute)` | `event: MouseEvent \| KeyboardEvent;item?: T;` | Emit when the menu is clicked | 20 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/stories/APIIntroduction.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/blocks'; 2 | 3 | 4 | 5 | # Documentation in a nutshell 6 | 7 | Context Menu comes in 3 parts: 8 | 9 | 1. A `contextMenu` directive to apply to any HTML element where a right click should display a context menu 10 | 2. A `` component to hold the menu content and be referenced by the above directive 11 | > NB. this component has no template itself and can only have `` children, 12 | > as you might guess it is not intended to be rendered in place but to populate the context menu overlay 13 | 3. A `contextMenuItem` directive to apply to a `` inside the above component 14 | 15 | ## Simple menu 16 | 17 | > Component 18 | 19 | ```typescript 20 | @Component({...}) 21 | export class Component { 22 | // The value can be typed 23 | public value: string = 'a simple value attached to the context menu'; 24 | 25 | // The value can be typed 26 | public execute(text: string, value: string) { 27 | console.log(text, value); 28 | } 29 | } 30 | ``` 31 | 32 | > Template 33 | 34 | ```html 35 | 36 | You can right click on this text 39 | 40 | 41 | 42 | 43 | 44 | 45 | Context menu title 46 | 47 | 48 | This is the context menu value "{{ value }}" 49 | 50 | Cut {{ value }} 51 | Copy {{ value }} 52 | Paste {{ value }} 53 | Disabled menu item 56 | 57 | ``` 58 | 59 | ## Sub menu 60 | 61 | ```html 62 | You can right click on this text 65 | 66 | 67 | Cut 70 | Copy 73 | Paste 76 | 77 | Special pastes... 80 | 81 | 82 | 83 | Paste as HTML 86 | Paste unformatted 89 | 90 | ``` 91 | 92 | ## Programmatically open and close a context menu 93 | 94 | By default the context menu opens when an `contextmenu` event is triggered with a right mouse click or when the context menu keyboard key is pressed. The menu closes when clicking outside or when selecting a menu item. 95 | 96 | ### By reusing directive 97 | 98 | However, in some cases you may want to open or close programmatically. You can access `open` and `close` methods of the `[contextMenu]` directive. Below is an example where the menu is opened when left clicking or when `shift + Y` is pressed while being focused on the menu. When `shift + Z` is pressed anywhere on the page the menu closes. 99 | 100 | ```html 101 | You can right click on this text 111 | ``` 112 | 113 | ### By using context menu service 114 | 115 | The `ContextMenuService` provides a `closeAll` method that will close any open context menu. It also provides an `hasOpenMenu` to check if there is at least one. 116 | 117 | ## Generic 118 | 119 | > A `contextMenuItem` can be passed to a `[contextMenu]` directive via the `[contextMenuValue]` input and then will be emitted by \[contextMenuItem] via the `(execute)` output when clicked. 120 | > The type of the item is [generic](https://www.typescriptlang.org/docs/handbook/2/generics.html) and noted as `T` in the documentation below. 121 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/stories/APIKeyboardNavigation.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/blocks'; 2 | 3 | 4 | 5 | # Keyboard navigation 6 | 7 | | Key | Code | Action | 8 | | :---------------------- | :------- | -------------------------------------------------------- | 9 | | ContextMenu | 93 | Open a contextmenu when its DOM element is focused | 10 | | ArrowDown | 40 | Move to next menu item (wrapping) | 11 | | ArrowUp | 38 | Move to previous menu item (wrapping) | 12 | | ArrowLeft | ArrowRight | 37 | 39 | Open / Close submenu depending on `dir` (`ltr` or `ltr`) | 13 | | Enter | Space | 13 | 32 | Open submenu or execute current menu item | 14 | | Esc | 27 | Close current menu | 15 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/stories/APIStyling.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/blocks'; 2 | 3 | 4 | 5 | # Context menu styling 6 | 7 | Context menu positioning is handled by the Angular CDK `@angular/cdk/overlay-prebuilt.css`. 8 | Its default styles are defined in the `node_modules/@perfectmemory/ngx-contextmenu/src/assets/stylesheets/base.scss` file. 9 | This file also provides a number of CSS variables for customization. 10 | 11 | ## CSS variables 12 | 13 | To customize the contextmenu styling, add a CSS class name to the `menuClass` property and then adjust CSS variables in your own CSS file. 14 | 15 | > html 16 | 17 | ```html 18 | 19 | ``` 20 | 21 | > css 22 | 23 | ```css 24 | .custom-style { 25 | /* Styling of the element where a context menu can appear */ 26 | --ngx-contextmenu-focusable-border-bottom: 1px dotted #70757e; 27 | 28 | /* Styling of the context menu itself */ 29 | --ngx-contextmenu-font-family: sans-serif; 30 | --ngx-contextmenu-background-color: white; 31 | --ngx-contextmenu-border-radius: 4px; 32 | --ngx-contextmenu-border: 1px solid rgba(0, 0, 0, 0.18); 33 | --ngx-contextmenu-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.18); 34 | --ngx-contextmenu-font-size: 14px; 35 | --ngx-contextmenu-margin: 2px 0 0; 36 | --ngx-contextmenu-min-width: 160px; 37 | --ngx-contextmenu-outline: 1px solid #70757e; 38 | --ngx-contextmenu-padding: 5px 0; 39 | --ngx-contextmenu-text-color: #70757e; 40 | --ngx-contextmenu-text-disabled-color: #8a909a; 41 | --ngx-contextmenu-max-height: 100vh; 42 | 43 | /* Styling of context menu items */ 44 | --ngx-contextmenu-item-arrow-left: '◀'; 45 | --ngx-contextmenu-item-arrow-right: '▶'; 46 | --ngx-contextmenu-item-background-hover-color: #f8f8f8; 47 | --ngx-contextmenu-item-separator-color: #8a909a; 48 | --ngx-contextmenu-item-separator-padding: 10px; 49 | --ngx-contextmenu-item-separator-width: 96%; 50 | --ngx-contextmenu-item-padding: 6px 20px; 51 | --ngx-contextmenu-item-text-hover-color: #5a6473; 52 | } 53 | ``` 54 | 55 | ## More styling 56 | 57 | If you want more control over styling just copy the `node_modules/@perfectmemory/ngx-contextmenu/src/assets/stylesheets/base.scss` and make your own adjustments 58 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/stories/Changelog.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/blocks'; 2 | import CHANGELOG from '../../../../CHANGELOG.md'; 3 | import { Markdown } from '@storybook/blocks'; 4 | 5 | 6 | 7 | 8 | {CHANGELOG} 9 | 10 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/stories/ContextMenu.stories.ts: -------------------------------------------------------------------------------- 1 | import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; 2 | import { ContextMenuModule } from '../lib/ngx-contextmenu.module'; 3 | import ContextMenuComponent from './ngx-contextmenu/ngx-contextmenu.component'; 4 | 5 | export default { 6 | title: 'Context Menu/Demo', 7 | component: ContextMenuComponent, 8 | parameters: { 9 | // More on Story layout: https://storybook.js.org/docs/angular/configure/story-layout 10 | layout: 'centered', 11 | }, 12 | decorators: [ 13 | moduleMetadata({ 14 | imports: [ContextMenuModule], 15 | }), 16 | ], 17 | play: undefined, 18 | argTypes: { 19 | dir: { 20 | name: 'Direction', 21 | description: 22 | 'Defines the orientation of the context menu, left-to-right or right-to-left', 23 | options: ['left-to-right', 'right-to-left'], 24 | mapping: { 25 | 'left-to-right': 'ltr', 26 | 'right-to-left': 'rtl', 27 | }, 28 | control: { type: 'radio' }, 29 | }, 30 | menuItemExecuted: { 31 | action: 'From the context menu, you chose', 32 | table: { 33 | disable: true, 34 | }, 35 | }, 36 | menuClass: { 37 | description: 'CSS class to apply to the menu', 38 | options: ['none', 'custom-theme-blue'], 39 | control: { type: 'select' }, 40 | }, 41 | contextMenuOpened: { 42 | action: 'Context menu was opened', 43 | table: { 44 | disable: true, 45 | }, 46 | }, 47 | contextMenuClosed: { 48 | action: 'Context menu was closed', 49 | table: { 50 | disable: true, 51 | }, 52 | }, 53 | close: { 54 | table: { 55 | disable: true, 56 | }, 57 | }, 58 | open: { 59 | table: { 60 | disable: true, 61 | }, 62 | }, 63 | execute: { 64 | table: { 65 | disable: true, 66 | }, 67 | }, 68 | demoMode: { 69 | name: 'Mode', 70 | description: 'Display context menu with form elements', 71 | control: 'radio', 72 | options: ['simple', 'form'], 73 | defaultValue: 'simple', 74 | table: { 75 | disable: false, 76 | }, 77 | }, 78 | programmaticOpen: { 79 | name: 'Programmatically open', 80 | control: 'boolean', 81 | description: 'Programmatically open the contextmenu from a button', 82 | table: { 83 | disable: false, 84 | }, 85 | }, 86 | }, 87 | } as Meta; 88 | 89 | type Story = StoryObj; 90 | 91 | export const Demo: Story = { 92 | args: { 93 | menuClass: '', 94 | disabled: false, 95 | }, 96 | }; 97 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/stories/Faq.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/blocks'; 2 | 3 | 4 | 5 | # Frequently Asked Questions 6 | 7 | The context menu does not appear next to the mouse click ? 8 | 9 | > Make sure you imported Angular CDK overlay CSS. [See Installation and Setup](?path=/story/context-menu-installation-and-setup--page#import-the-angular-cdk-overlay-css) 10 | 11 | Where this library comes from ? 12 | 13 | > This project has been initially forked from [ngx-contextmenu](https://github.com/isaacplmann/ngx-contextmenu.git) to port it to Angular 13. 14 | > It was originally inspired by [ui.bootstrap.contextMenu](https://github.com/Templarian/ui.bootstrap.contextMenu). 15 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/stories/Introduction.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/blocks'; 2 | import { Markdown } from '@storybook/blocks'; 3 | import Code from './assets/code-brackets.svg'; 4 | import Colors from './assets/colors.svg'; 5 | import Comments from './assets/comments.svg'; 6 | import Direction from './assets/direction.svg'; 7 | import Flow from './assets/flow.svg'; 8 | import Plugin from './assets/plugin.svg'; 9 | import Repo from './assets/repo.svg'; 10 | import StackAlt from './assets/stackalt.svg'; 11 | import ContextMenu from './assets/contextmenu.png'; 12 | import LICENSE from '../../../../LICENSE.md?raw'; 13 | 14 | 15 | 16 | # Welcome 17 | 18 | `@perfectmemory/ngx-contextmenu` is a NPM package providing [![Angular](https://img.shields.io/badge/Angular-B52E31?logo=angular)](https://angular.io/) building blocks (module, directives, components) to create custom context menus. 19 | 20 | ![Contextmenu Screenshot](assets/contextmenu.png) 21 | 22 | ## Where 23 | 24 | Fork it from [![Github](https://img.shields.io/badge/Github-1A1F23?logo=github)](https://github.com/PerfectMemory/ngx-contextmenu) 25 | 26 | Install it from [![NPM](https://img.shields.io/badge/NPM-D70012?logo=npm)](https://www.npmjs.com/package/@perfectmemory/ngx-contextmenu) 27 | 28 | ## About Perfect Memory 29 | 30 | [Perfect Memory](https://www.perfect-memory.com/) is a Software Publisher 31 | 32 | We crafted a new generation of Digital Asset Management Systems, the DAM-as-a-Brain, which can refine and execute cognitive processing on content. 33 | 34 | ## License 35 | 36 | 37 | {LICENSE} 38 | 39 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/stories/Setup.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/blocks'; 2 | 3 | 4 | 5 | # Installation and setup 6 | 7 | `@perfectmemory/ngx-contextmenu` is built on top of the `@angular/cdk` so you have to install both 8 | 9 | ## Installation 10 | 11 | ```shell 12 | npm install @perfectmemory/ngx-contextmenu @angular/cdk 13 | ``` 14 | 15 | ```shell 16 | yarn add @perfectmemory/ngx-contextmenu @angular/cdk 17 | ``` 18 | 19 | ## Compatibility matrix 20 | 21 | | Angular | @perfectmemory/ngx-contextmenu | Main purpose | 22 | | --------- | ------------------------------ | ----------------------------------------------------------------------------- | 23 | | `^19.0.0` | `^19.0.0` | Align version with Angular 19 | 24 | | `^18.0.0` | `^18.0.0` | Align version with Angular 18 | 25 | | `^17.0.0` | `^17.0.0` | Align version with Angular 17 | 26 | | `^16.0.0` | `^16.0.0` | Align version with Angular 16 | 27 | | `^15.0.0` | `^15.0.0` | Align version with Angular 15 | 28 | | `^14.0.0` | `^14.0.0` | Align version with Angular 14 | 29 | | `^13.0.0` | `^8.0.0` | Public API cleanup, new features, breaking changes | 30 | | `^13.0.0` | `^7.0.0` | Support Angular 13 | 31 | | `^12.0.0` | `^6.0.0` | Fork from `ngx-contextmenu` (drop in replacement with support for Angular 12) | 32 | 33 | ## Project setup 34 | 35 | In your Angular project, you have to import: 36 | 37 | 1. Angular CDK overlay and `contextmenu` CSS files 38 | 2. `ContextMenuModule` in your Angular application module. 39 | 40 | ### Import the Angular CDK overlay CSS 41 | 42 | You can import it either in the main `styles.scss`, for example `./src/styles.scss` 43 | 44 | ```css 45 | @import '@angular/cdk/overlay-prebuilt.css'; 46 | @import './node_modules/@perfectmemory/ngx-contextmenu/src/assets/stylesheets/base.scss'; 47 | ``` 48 | 49 | Or in the `angular.json` 50 | 51 | ```json 52 | { 53 | ... 54 | "architect": { 55 | "build": { 56 | "options": { 57 | "styles": [ 58 | "@angular/cdk/overlay-prebuilt.css", 59 | "./node_modules/@perfectmemory/ngx-contextmenu/src/assets/stylesheets/base.scss", 60 | "src/styles.scss", 61 | ], 62 | }, 63 | }, 64 | }, 65 | ... 66 | } 67 | 68 | ``` 69 | 70 | ### Import `ContextMenuModule` in your Angular application module 71 | 72 | ```ts 73 | import { ContextMenuModule } from '@perfectmemory/ngx-contextmenu'; 74 | 75 | @NgModule({ 76 | ... 77 | imports: [ 78 | ContextMenuModule, 79 | ], 80 | ... 81 | }) 82 | export class AppModule {} 83 | ``` 84 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/stories/assets/code-brackets.svg: -------------------------------------------------------------------------------- 1 | illustration/code-brackets -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/stories/assets/colors.svg: -------------------------------------------------------------------------------- 1 | illustration/colors -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/stories/assets/comments.svg: -------------------------------------------------------------------------------- 1 | illustration/comments -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/stories/assets/contextmenu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PerfectMemory/ngx-contextmenu/ac7445b05ded36e39004103fc34ac0b11045d669/libs/ngx-contextmenu/src/stories/assets/contextmenu.png -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/stories/assets/direction.svg: -------------------------------------------------------------------------------- 1 | illustration/direction -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/stories/assets/flow.svg: -------------------------------------------------------------------------------- 1 | illustration/flow -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/stories/assets/plugin.svg: -------------------------------------------------------------------------------- 1 | illustration/plugin -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/stories/assets/repo.svg: -------------------------------------------------------------------------------- 1 | illustration/repo -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/stories/assets/stackalt.svg: -------------------------------------------------------------------------------- 1 | illustration/stackalt -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/stories/assets/stylesheets/index.scss: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: sans-serif; 3 | } 4 | 5 | img[src*='logo'] { 6 | vertical-align: middle; 7 | } 8 | 9 | context-menu-content.custom-theme-blue { 10 | --ngx-contextmenu-text-color: white; 11 | --ngx-contextmenu-text-disabled-color: rgba(white, 80%); 12 | --ngx-contextmenu-background-color: #01579b; 13 | } 14 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/stories/ngx-contextmenu/ngx-contextmenu.component.html: -------------------------------------------------------------------------------- 1 | 8 | 9 | When you right click on this text, a context menu will appear 10 | 11 | 12 | When you right click on this text, a context menu with form inputs will 13 | appear 14 | 15 | in the right to left direction 16 | 17 | 18 | 19 | 24 | When you right click on this text, no context menu will appear because it is 25 | disabled 26 | 27 | 28 | 29 | 37 | Context menu title 40 | Cut "{{ value }}" 47 | Copy "{{ value }}" 54 | Paste "{{ value }}" 61 | Disabled menu item 64 | 69 | Special pastes... 75 | 76 | 81 | 86 | 91 | 96 | 101 | 106 | 107 | 108 | Paste as HTML 111 | Paste unformatted 114 | 115 |
116 |
117 |
118 |
119 | 127 | 138 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/stories/ngx-contextmenu/ngx-contextmenu.component.scss: -------------------------------------------------------------------------------- 1 | label { 2 | input, 3 | span { 4 | display: inline-block; 5 | vertical-align: middle; 6 | } 7 | } 8 | 9 | .menu-item-title { 10 | text-transform: uppercase; 11 | } 12 | 13 | .ngx-context-menu-focusable { 14 | &:focus { 15 | border-bottom: var(--ngx-contextmenu-focusable-border-bottom); 16 | outline: none; 17 | } 18 | } 19 | 20 | button { 21 | background-color: rgba(51, 51, 51, 0.05); 22 | border-radius: 8px; 23 | border-width: 0; 24 | color: #333333; 25 | cursor: pointer; 26 | display: block; 27 | font-family: 'Haas Grot Text R Web', 'Helvetica Neue', Helvetica, Arial, 28 | sans-serif; 29 | font-size: 14px; 30 | font-weight: 500; 31 | line-height: 20px; 32 | list-style: none; 33 | margin: 0; 34 | padding: 10px 12px; 35 | text-align: center; 36 | transition: all 200ms; 37 | vertical-align: baseline; 38 | white-space: nowrap; 39 | user-select: none; 40 | -webkit-user-select: none; 41 | touch-action: manipulation; 42 | margin-bottom: 1rem; 43 | 44 | &:hover { 45 | background-color: rgba(51, 51, 51, 0.1); 46 | } 47 | } 48 | 49 | code { 50 | background-color: beige; 51 | padding: 3px 0.256rem; 52 | } 53 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/stories/ngx-contextmenu/ngx-contextmenu.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | EventEmitter, 4 | Input, 5 | Output, 6 | ViewChild, 7 | } from '@angular/core'; 8 | import { ContextMenuOpenEvent } from '../../lib/components/context-menu/context-menu.component.interface'; 9 | import { ContextMenuDirective } from '../../lib/directives/context-menu/context-menu.directive'; 10 | import { ContextMenuService } from '../../lib/services/context-menu/context-menu.service'; 11 | 12 | @Component({ 13 | selector: 'storybook-context-menu', 14 | templateUrl: 'ngx-contextmenu.component.html', 15 | styleUrls: ['./ngx-contextmenu.component.scss'], 16 | standalone: false 17 | }) 18 | export default class StorybookContextMenuComponent { 19 | @Input() 20 | public menuClass = ''; 21 | 22 | @Input() 23 | public disabled = false; 24 | 25 | @Input() 26 | public dir: 'ltr' | 'rtl' | undefined; 27 | 28 | @Input() 29 | public value: string = 'a user defined item'; 30 | 31 | @Input() 32 | public demoMode: 'simple' | 'form' = 'simple'; 33 | 34 | @Input() 35 | public programmaticOpen = false; 36 | 37 | @Output() 38 | public contextMenuOpened = new EventEmitter>(); 39 | 40 | @Output() 41 | public contextMenuClosed = new EventEmitter<'void'>(); 42 | 43 | @Output() 44 | public menuItemExecuted = new EventEmitter(); 45 | 46 | /** 47 | * @internal 48 | */ 49 | @ViewChild(ContextMenuDirective) 50 | public contextMenuDirective?: ContextMenuDirective; 51 | 52 | constructor(public contextMenuService: ContextMenuService) {} 53 | 54 | /** 55 | * @internal 56 | */ 57 | public execute(text: string, value: ContextMenuOpenEvent) { 58 | console.log(value); 59 | this.menuItemExecuted.next(`${text}: ${value.value}`); 60 | } 61 | 62 | /** 63 | * @internal 64 | */ 65 | public openContextMenu(value: ContextMenuOpenEvent) { 66 | this.contextMenuOpened.next(value); 67 | } 68 | 69 | /** 70 | * @internal 71 | */ 72 | public closeContextMenu() { 73 | this.contextMenuClosed.next('void'); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/src/test-setup.ts: -------------------------------------------------------------------------------- 1 | import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; 2 | 3 | setupZoneTestEnv({ 4 | errorOnUnknownElements: true, 5 | errorOnUnknownProperties: true, 6 | }); 7 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "useDefineForClassFields": false, 5 | "forceConsistentCasingInFileNames": true, 6 | "strict": true, 7 | "noImplicitOverride": true, 8 | "noPropertyAccessFromIndexSignature": true, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true 11 | }, 12 | "files": [], 13 | "include": [], 14 | "references": [ 15 | { 16 | "path": "./tsconfig.lib.json" 17 | }, 18 | { 19 | "path": "./tsconfig.spec.json" 20 | }, 21 | { 22 | "path": "./.storybook/tsconfig.json" 23 | } 24 | ], 25 | "extends": "../../tsconfig.base.json", 26 | "angularCompilerOptions": { 27 | "enableI18nLegacyMessageIdFormat": false, 28 | "strictInjectionParameters": true, 29 | "strictInputAccessModifiers": true, 30 | "strictTemplates": true 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "declaration": true, 6 | "declarationMap": true, 7 | "inlineSources": true, 8 | "types": [] 9 | }, 10 | "exclude": [ 11 | "src/**/*.spec.ts", 12 | "src/test-setup.ts", 13 | "jest.config.ts", 14 | "src/**/*.test.ts", 15 | "**/*.stories.ts", 16 | "**/*.stories.js" 17 | ], 18 | "include": ["src/**/*.ts"] 19 | } 20 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.lib.json", 3 | "compilerOptions": { 4 | "declarationMap": false 5 | }, 6 | "angularCompilerOptions": { 7 | "compilationMode": "partial" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /libs/ngx-contextmenu/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "target": "es2016", 7 | "types": ["jest", "node"] 8 | }, 9 | "files": ["src/test-setup.ts"], 10 | "include": [ 11 | "jest.config.ts", 12 | "src/**/*.test.ts", 13 | "src/**/*.spec.ts", 14 | "src/**/*.d.ts" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /migrations.json: -------------------------------------------------------------------------------- 1 | { 2 | "migrations": [ 3 | { 4 | "cli": "nx", 5 | "version": "19.2.0-beta.2", 6 | "description": "Updates the default workspace data directory to .nx/workspace-data", 7 | "implementation": "./src/migrations/update-19-2-0/move-workspace-data-directory", 8 | "package": "nx", 9 | "name": "19-2-0-move-graph-cache-directory" 10 | }, 11 | { 12 | "cli": "nx", 13 | "version": "19.2.2-beta.0", 14 | "description": "Updates the nx wrapper.", 15 | "implementation": "./src/migrations/update-17-3-0/update-nxw", 16 | "package": "nx", 17 | "name": "19-2-2-update-nx-wrapper" 18 | }, 19 | { 20 | "version": "19.2.4-beta.0", 21 | "description": "Set project name in nx.json explicitly", 22 | "implementation": "./src/migrations/update-19-2-4/set-project-name", 23 | "x-repair-skip": true, 24 | "package": "nx", 25 | "name": "19-2-4-set-project-name" 26 | }, 27 | { 28 | "version": "20.0.0-beta.7", 29 | "description": "Migration for v20.0.0-beta.7", 30 | "implementation": "./src/migrations/update-20-0-0/move-use-daemon-process", 31 | "package": "nx", 32 | "name": "move-use-daemon-process" 33 | }, 34 | { 35 | "version": "20.0.1", 36 | "description": "Set `useLegacyCache` to true for migrating workspaces", 37 | "implementation": "./src/migrations/update-20-0-1/use-legacy-cache", 38 | "x-repair-skip": true, 39 | "package": "nx", 40 | "name": "use-legacy-cache" 41 | }, 42 | { 43 | "version": "20.2.0-beta.5", 44 | "description": "Update TypeScript ESLint packages to v8.13.0 if they are already on v8", 45 | "implementation": "./src/migrations/update-20-2-0/update-typescript-eslint-v8-13-0", 46 | "package": "@nx/eslint", 47 | "name": "update-typescript-eslint-v8.13.0" 48 | }, 49 | { 50 | "cli": "nx", 51 | "version": "19.6.0-beta.0", 52 | "description": "Update workspace to use Storybook v8", 53 | "implementation": "./src/migrations/update-19-6-0/update-sb-8", 54 | "package": "@nx/storybook", 55 | "name": "update-19-6-0-add-nx-packages" 56 | }, 57 | { 58 | "cli": "nx", 59 | "version": "19.6.0-beta.0", 60 | "description": "Use serve-static or preview for webServerCommand.", 61 | "implementation": "./src/migrations/update-19-6-0/use-serve-static-preview-for-command", 62 | "package": "@nx/playwright", 63 | "name": "19-6-0-use-serve-static-preview-for-command" 64 | }, 65 | { 66 | "cli": "nx", 67 | "version": "19.6.0-beta.1", 68 | "description": "Add inferred ciTargetNames to targetDefaults with dependsOn to ensure dependent application builds are scheduled before atomized tasks.", 69 | "implementation": "./src/migrations/update-19-6-0/add-e2e-ci-target-defaults", 70 | "package": "@nx/playwright", 71 | "name": "update-19-6-0-add-e2e-ci-target-defaults" 72 | }, 73 | { 74 | "cli": "nx", 75 | "version": "20.0.0-beta.5", 76 | "description": "replace getJestProjects with getJestProjectsAsync", 77 | "implementation": "./src/migrations/update-20-0-0/replace-getJestProjects-with-getJestProjectsAsync", 78 | "package": "@nx/jest", 79 | "name": "replace-getJestProjects-with-getJestProjectsAsync" 80 | }, 81 | { 82 | "cli": "nx", 83 | "version": "19.2.1-beta.0", 84 | "requires": { "@angular-eslint/eslint-plugin": ">=18.0.0" }, 85 | "description": "Installs the '@typescript-eslint/utils' package when having installed '@angular-eslint/eslint-plugin' or '@angular-eslint/eslint-plugin-template' with version >=18.0.0.", 86 | "factory": "./src/migrations/update-19-2-1/add-typescript-eslint-utils", 87 | "package": "@nx/angular", 88 | "name": "add-typescript-eslint-utils" 89 | }, 90 | { 91 | "cli": "nx", 92 | "version": "19.5.0-beta.1", 93 | "requires": { "@angular/core": ">=18.1.0" }, 94 | "description": "Update the @angular/cli package version to ~18.1.0.", 95 | "factory": "./src/migrations/update-19-5-0/update-angular-cli", 96 | "package": "@nx/angular", 97 | "name": "update-angular-cli-version-18-1-0" 98 | }, 99 | { 100 | "cli": "nx", 101 | "version": "19.6.0-beta.4", 102 | "description": "Ensure Module Federation DTS is turned off by default.", 103 | "factory": "./src/migrations/update-19-6-0/turn-off-dts-by-default", 104 | "package": "@nx/angular", 105 | "name": "update-19-6-0" 106 | }, 107 | { 108 | "cli": "nx", 109 | "version": "19.6.0-beta.7", 110 | "requires": { "@angular/core": ">=18.2.0" }, 111 | "description": "Update the @angular/cli package version to ~18.2.0.", 112 | "factory": "./src/migrations/update-19-6-0/update-angular-cli", 113 | "package": "@nx/angular", 114 | "name": "update-angular-cli-version-18-2-0" 115 | }, 116 | { 117 | "cli": "nx", 118 | "version": "19.6.1-beta.0", 119 | "description": "Ensure Target Defaults are set correctly for Module Federation.", 120 | "factory": "./src/migrations/update-19-6-1/ensure-depends-on-for-mf", 121 | "package": "@nx/angular", 122 | "name": "update-19-6-1-ensure-module-federation-target-defaults" 123 | }, 124 | { 125 | "cli": "nx", 126 | "version": "20.2.0-beta.2", 127 | "description": "Update the ModuleFederationConfig import use @nx/module-federation.", 128 | "factory": "./src/migrations/update-20-2-0/migrate-mf-imports-to-new-package", 129 | "package": "@nx/angular", 130 | "name": "update-20-2-0-update-module-federation-config-import" 131 | }, 132 | { 133 | "cli": "nx", 134 | "version": "20.2.0-beta.2", 135 | "description": "Update the withModuleFederation import use @nx/module-federation/angular.", 136 | "factory": "./src/migrations/update-20-2-0/migrate-with-mf-import-to-new-package", 137 | "package": "@nx/angular", 138 | "name": "update-20-2-0-update-with-module-federation-import" 139 | }, 140 | { 141 | "cli": "nx", 142 | "version": "20.2.0-beta.5", 143 | "requires": { "@angular/core": ">=19.0.0" }, 144 | "description": "Update the @angular/cli package version to ~19.0.0.", 145 | "factory": "./src/migrations/update-20-2-0/update-angular-cli", 146 | "package": "@nx/angular", 147 | "name": "update-angular-cli-version-19-0-0" 148 | }, 149 | { 150 | "cli": "nx", 151 | "version": "20.2.0-beta.5", 152 | "requires": { "@angular/core": ">=19.0.0" }, 153 | "description": "Add the '@angular/localize/init' polyfill to the 'polyfills' option of targets using esbuild-based executors.", 154 | "factory": "./src/migrations/update-20-2-0/add-localize-polyfill-to-targets", 155 | "package": "@nx/angular", 156 | "name": "add-localize-polyfill-to-targets" 157 | }, 158 | { 159 | "cli": "nx", 160 | "version": "20.2.0-beta.5", 161 | "requires": { "@angular/core": ">=19.0.0" }, 162 | "description": "Update '@angular/ssr' import paths to use the new '/node' entry point when 'CommonEngine' is detected.", 163 | "factory": "./src/migrations/update-20-2-0/update-angular-ssr-imports-to-use-node-entry-point", 164 | "package": "@nx/angular", 165 | "name": "update-angular-ssr-imports-to-use-node-entry-point" 166 | }, 167 | { 168 | "cli": "nx", 169 | "version": "20.2.0-beta.6", 170 | "requires": { "@angular/core": ">=19.0.0" }, 171 | "description": "Disable the Angular ESLint prefer-standalone rule if not set.", 172 | "factory": "./src/migrations/update-20-2-0/disable-angular-eslint-prefer-standalone", 173 | "package": "@nx/angular", 174 | "name": "disable-angular-eslint-prefer-standalone" 175 | }, 176 | { 177 | "cli": "nx", 178 | "version": "20.2.0-beta.8", 179 | "requires": { "@angular/core": ">=19.0.0" }, 180 | "description": "Remove Angular ESLint rules that were removed in v19.0.0.", 181 | "factory": "./src/migrations/update-20-2-0/remove-angular-eslint-rules", 182 | "package": "@nx/angular", 183 | "name": "remove-angular-eslint-rules" 184 | }, 185 | { 186 | "cli": "nx", 187 | "version": "20.2.0-beta.8", 188 | "requires": { "@angular/core": ">=19.0.0" }, 189 | "description": "Remove the deprecated 'tailwindConfig' option from ng-packagr executors. Tailwind CSS configurations located at the project or workspace root will be picked up automatically.", 190 | "factory": "./src/migrations/update-20-2-0/remove-tailwind-config-from-ng-packagr-executors", 191 | "package": "@nx/angular", 192 | "name": "remove-tailwind-config-from-ng-packagr-executors" 193 | }, 194 | { 195 | "version": "19.0.0", 196 | "description": "Updates non-standalone Directives, Component and Pipes to 'standalone:false' and removes 'standalone:true' from those who are standalone", 197 | "factory": "./bundles/explicit-standalone-flag#migrate", 198 | "package": "@angular/core", 199 | "name": "explicit-standalone-flag" 200 | }, 201 | { 202 | "version": "19.0.0", 203 | "description": "Updates ExperimentalPendingTasks to PendingTasks", 204 | "factory": "./bundles/pending-tasks#migrate", 205 | "package": "@angular/core", 206 | "name": "pending-tasks" 207 | }, 208 | { 209 | "version": "19.0.0", 210 | "description": "Replaces `APP_INITIALIZER`, `ENVIRONMENT_INITIALIZER` & `PLATFORM_INITIALIZER` respectively with `provideAppInitializer`, `provideEnvironmentInitializer` & `providePlatformInitializer`.", 211 | "factory": "./bundles/provide-initializer#migrate", 212 | "optional": true, 213 | "package": "@angular/core", 214 | "name": "provide-initializer" 215 | }, 216 | { 217 | "version": "19.0.0-0", 218 | "description": "Updates the Angular CDK to v19", 219 | "factory": "./ng-update/index#updateToV19", 220 | "package": "@angular/cdk", 221 | "name": "migration-v19" 222 | } 223 | ] 224 | } 225 | -------------------------------------------------------------------------------- /nx.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/nx/schemas/nx-schema.json", 3 | "defaultBase": "master", 4 | "namedInputs": { 5 | "default": ["{projectRoot}/**/*", "sharedGlobals"], 6 | "production": [ 7 | "default", 8 | "!{projectRoot}/.eslintrc.json", 9 | "!{projectRoot}/eslint.config.js", 10 | "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)", 11 | "!{projectRoot}/tsconfig.spec.json", 12 | "!{projectRoot}/jest.config.[jt]s", 13 | "!{projectRoot}/src/test-setup.[jt]s", 14 | "!{projectRoot}/test-setup.[jt]s", 15 | "!{projectRoot}/**/*.stories.@(js|jsx|ts|tsx|mdx)", 16 | "!{projectRoot}/.storybook/**/*", 17 | "!{projectRoot}/tsconfig.storybook.json" 18 | ], 19 | "sharedGlobals": [] 20 | }, 21 | "targetDefaults": { 22 | "@angular-devkit/build-angular:application": { 23 | "cache": true, 24 | "dependsOn": ["^build"], 25 | "inputs": ["production", "^production"] 26 | }, 27 | "@nx/eslint:lint": { 28 | "cache": true, 29 | "inputs": [ 30 | "default", 31 | "{workspaceRoot}/.eslintrc.json", 32 | "{workspaceRoot}/.eslintignore", 33 | "{workspaceRoot}/eslint.config.js" 34 | ] 35 | }, 36 | "@nx/jest:jest": { 37 | "cache": true, 38 | "inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"], 39 | "options": { 40 | "passWithNoTests": true 41 | }, 42 | "configurations": { 43 | "ci": { 44 | "ci": true, 45 | "codeCoverage": true 46 | } 47 | } 48 | }, 49 | "@nx/angular:package": { 50 | "cache": true, 51 | "dependsOn": ["^build"], 52 | "inputs": ["production", "^production"] 53 | }, 54 | "build-storybook": { 55 | "cache": true 56 | } 57 | }, 58 | "plugins": [ 59 | { 60 | "plugin": "@nx/playwright/plugin", 61 | "options": { 62 | "targetName": "e2e" 63 | } 64 | }, 65 | { 66 | "plugin": "@nx/eslint/plugin", 67 | "options": { 68 | "targetName": "lint" 69 | } 70 | }, 71 | { 72 | "plugin": "@nx/storybook/plugin", 73 | "options": { 74 | "serveStorybookTargetName": "storybook", 75 | "buildStorybookTargetName": "build-storybook", 76 | "testStorybookTargetName": "test-storybook", 77 | "staticStorybookTargetName": "static-storybook" 78 | } 79 | } 80 | ], 81 | "generators": { 82 | "@nx/angular:application": { 83 | "e2eTestRunner": "playwright", 84 | "linter": "eslint", 85 | "style": "scss", 86 | "unitTestRunner": "jest" 87 | }, 88 | "@nx/angular:library": { 89 | "linter": "eslint", 90 | "unitTestRunner": "jest" 91 | }, 92 | "@nx/angular:component": { 93 | "style": "css" 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@perfectmemory/ngx-contextmenu-monorepo", 3 | "version": "19.0.0", 4 | "description": "Context menu for Angular", 5 | "keywords": [ 6 | "angular", 7 | "ngx", 8 | "ng2", 9 | "contextmenu", 10 | "ngx-contextmenu", 11 | "right click", 12 | "contextual", 13 | "shortcut", 14 | "pop-up", 15 | "pop-up menu" 16 | ], 17 | "contributors": [ 18 | "Isaac Mann ", 19 | "Stephane Roucheray " 20 | ], 21 | "license": "MIT", 22 | "repository": { 23 | "type": "git", 24 | "url": "git+ssh://git@github.com:PerfectMemory/ngx-contextmenu.git" 25 | }, 26 | "engines": { 27 | "node": "^18.19.1 || ^20.11.1 || ^22.0.0" 28 | }, 29 | "scripts": { 30 | "nx": "nx", 31 | "start": "nx serve demo", 32 | "build:lib": "nx build @perfectmemory/ngx-contextmenu && copyfiles README.md LICENSE.md dist/libs/ngx-contextmenu/", 33 | "build:doc": "nx build-storybook @perfectmemory/ngx-contextmenu", 34 | "test": "nx test @perfectmemory/ngx-contextmenu --code-coverage", 35 | "ci:test": "nx test @perfectmemory/ngx-contextmenu --watch=false --code-coverage", 36 | "pub": "npm run pub:lib && npm run pub:demo", 37 | "pub:demo": "npm run build:demo -- --base-href /ngx-contextmenu/ && gh-pages -d dist/demo", 38 | "pub:lib": "npm run build:lib && npm publish ./dist/libs/ngx-contextmenu", 39 | "pack:lib": "npm run build:lib && cd dist/libs/ngx-contextmenu && npm pack && copyfiles *.tgz ../../ && cd ../.. && rimraf dist/libs/ngx-contextmenu/*.tgz", 40 | "preversion": "keepachangelog display unreleased && keepachangelog confirm $npm_new_version --current-version $npm_old_version", 41 | "version": "keepachangelog release $npm_new_version && npm --prefix ./libs/ngx-contextmenu pkg set version=$npm_new_version && git add ./CHANGELOG.md ./libs/ngx-contextmenu/package.json", 42 | "postversion": "git push origin HEAD && git push origin v$npm_new_version", 43 | "storybook": "nx storybook @perfectmemory/ngx-contextmenu" 44 | }, 45 | "dependencies": { 46 | "@angular/animations": "19.0.3", 47 | "@angular/cdk": "19.0.2", 48 | "@angular/common": "19.0.3", 49 | "@angular/compiler": "19.0.3", 50 | "@angular/core": "19.0.3", 51 | "@angular/forms": "19.0.3", 52 | "@angular/platform-browser": "19.0.3", 53 | "@angular/platform-browser-dynamic": "19.0.3", 54 | "@angular/router": "19.0.3", 55 | "@nx/angular": "20.2.2", 56 | "rxjs": "~7.8.0", 57 | "tslib": "^2.3.0", 58 | "zone.js": "0.15.0" 59 | }, 60 | "devDependencies": { 61 | "@angular-devkit/build-angular": "19.0.4", 62 | "@angular-devkit/core": "19.0.4", 63 | "@angular-devkit/schematics": "19.0.4", 64 | "@angular-eslint/eslint-plugin": "19.0.2", 65 | "@angular-eslint/eslint-plugin-template": "19.0.2", 66 | "@angular-eslint/template-parser": "19.0.2", 67 | "@angular/cli": "~19.0.0", 68 | "@angular/compiler-cli": "19.0.3", 69 | "@angular/language-service": "19.0.3", 70 | "@ngneat/spectator": "^19.1.2", 71 | "@nx/devkit": "20.2.2", 72 | "@nx/eslint": "20.2.2", 73 | "@nx/eslint-plugin": "20.2.2", 74 | "@nx/jest": "20.2.2", 75 | "@nx/js": "20.2.2", 76 | "@nx/playwright": "20.2.2", 77 | "@nx/storybook": "20.2.2", 78 | "@nx/web": "20.2.2", 79 | "@nx/workspace": "20.2.2", 80 | "@playwright/test": "^1.36.0", 81 | "@schematics/angular": "19.0.4", 82 | "@storybook/addon-docs": "^8.4.7", 83 | "@storybook/addon-essentials": "^8.4.7", 84 | "@storybook/addon-interactions": "^8.4.7", 85 | "@storybook/addon-mdx-gfm": "^8.4.7", 86 | "@storybook/angular": "^8.4.7", 87 | "@storybook/core-server": "^8.4.7", 88 | "@storybook/test": "^8.4.7", 89 | "@storybook/test-runner": "^0.20.1", 90 | "@swc-node/register": "1.9.2", 91 | "@swc/core": "1.5.29", 92 | "@swc/helpers": "0.5.15", 93 | "@types/jest": "29.5.14", 94 | "@types/node": "18.16.9", 95 | "@typescript-eslint/eslint-plugin": "7.18.0", 96 | "@typescript-eslint/parser": "7.18.0", 97 | "@typescript-eslint/utils": "^7.16.0", 98 | "@vtabary/keepachangelog-cli": "^0.4.0", 99 | "autoprefixer": "^10.4.0", 100 | "copyfiles": "^2.4.1", 101 | "eslint": "~8.57.0", 102 | "eslint-config-prettier": "^9.0.0", 103 | "eslint-plugin-playwright": "^0.15.3", 104 | "jest": "29.7.0", 105 | "jest-environment-jsdom": "29.7.0", 106 | "jest-preset-angular": "14.4.2", 107 | "jsonc-eslint-parser": "^2.1.0", 108 | "ng-packagr": "19.0.1", 109 | "nx": "20.2.2", 110 | "postcss": "^8.4.5", 111 | "postcss-url": "~10.1.3", 112 | "prettier": "^2.6.2", 113 | "storybook": "^8.4.7", 114 | "ts-jest": "^29.1.0", 115 | "ts-node": "10.9.1", 116 | "typescript": "5.6.3" 117 | }, 118 | "resolutions": { 119 | "jsdom": "^24.1.0", 120 | "rrweb-cssom": "^0.8.0" 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "sourceMap": true, 6 | "declaration": false, 7 | "moduleResolution": "node", 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "importHelpers": true, 11 | "target": "es2015", 12 | "module": "esnext", 13 | "lib": ["es2020", "dom"], 14 | "skipLibCheck": true, 15 | "skipDefaultLibCheck": true, 16 | "baseUrl": ".", 17 | "paths": { 18 | "@perfectmemory/ngx-contextmenu": ["libs/ngx-contextmenu/src/index.ts"] 19 | } 20 | }, 21 | "exclude": ["node_modules", "tmp"] 22 | } 23 | --------------------------------------------------------------------------------