├── .devcontainer ├── configuration.yaml ├── devcontainer.json └── ui-lovelace.yaml ├── .eslintrc.js ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── .prettierrc.js ├── LICENSE ├── README.md ├── example.gif ├── example2.gif ├── example3.png ├── hacs.json ├── package.json ├── rollup.config.dev.js ├── rollup.config.js ├── src ├── action-handler-directive.ts ├── const.ts ├── radial-menu.ts └── types.ts └── tsconfig.json /.devcontainer/configuration.yaml: -------------------------------------------------------------------------------- 1 | default_config: 2 | lovelace: 3 | mode: yaml 4 | resources: 5 | - url: http://127.0.0.1:5000/radial-menu.js 6 | type: module 7 | demo: 8 | frontend: 9 | themes: 10 | amoled: 11 | accent-color: '#E45E65' 12 | card-background-color: 'var(--paper-card-background-color)' 13 | dark-primary-color: 'var(--accent-color)' 14 | disabled-text-color: '#7F848E' 15 | divider-color: 'rgba(0, 0, 0, .12)' 16 | google-green-500: '#39E949' 17 | google-red-500: '#E45E65' 18 | ha-card-background: '#000000' 19 | label-badge-background-color: '#2E333A' 20 | label-badge-border-color: 'green' 21 | label-badge-red: 'var(--accent-color)' 22 | label-badge-text-color: 'var(--primary-text-color)' 23 | light-primary-color: 'var(--accent-color)' 24 | paper-button-color: '#5294E2' 25 | paper-button-ink-color: '#5294E2' 26 | paper-card-background-color: '#000000' 27 | paper-card-header-color: 'var(--accent-color)' 28 | paper-dialog-background-color: '#000000' 29 | paper-grey-200: '#414A59' 30 | paper-grey-50: 'var(--primary-text-color)' 31 | paper-item-icon-active-color: '#F9C536' 32 | paper-item-icon-color: 'var(--primary-text-color)' 33 | paper-item-icon_-_color: 'green' 34 | paper-item-selected_-_background-color: '#434954' 35 | paper-listbox-background-color: '#000000' 36 | paper-listbox-color: '#FFFFFF)' 37 | paper-slider-active-color: 'var(--accent-color)' 38 | paper-slider-container-color: 'linear-gradient(var(--primary-background-color), var(--secondary-background-color)) no-repeat' 39 | paper-slider-disabled-active-color: 'var(--disabled-text-color)' 40 | paper-slider-disabled-secondary-color: 'var(--disabled-text-color)' 41 | paper-slider-knob-color: 'var(--accent-color)' 42 | paper-slider-knob-start-color: 'var(--accent-color)' 43 | paper-slider-pin-color: 'var(--accent-color)' 44 | paper-slider-secondary-color: 'var(--secondary-background-color)' 45 | paper-tabs-selection-bar-color: 'green' 46 | paper-toggle-button-checked-bar-color: 'var(--accent-color)' 47 | paper-toggle-button-checked-button-color: 'var(--accent-color)' 48 | paper-toggle-button-checked-ink-color: 'var(--accent-color)' 49 | paper-toggle-button-unchecked-bar-color: 'var(--disabled-text-color)' 50 | paper-toggle-button-unchecked-button-color: 'var(--disabled-text-color)' 51 | paper-toggle-button-unchecked-ink-color: 'var(--disabled-text-color)' 52 | primary-background-color: '#000000' 53 | primary-color: '#434954' 54 | primary-text-color: '#FFFFFF' 55 | secondary-background-color: '#383C45' 56 | secondary-text-color: '#5294E2' 57 | sidebar-icon-color: 'var(--primary-color)' 58 | sidebar-selected-icon-color: 'var(--primary-text-color)' 59 | sidebar-selected-text-color: 'var(--primary-text-color)' 60 | sidebar-text-color: '#F1F1F1' 61 | switch-unchecked-button-color: '#333333' 62 | switch-unchecked-track-color: '#333333' 63 | table-row-alternative-background-color: '#222429' 64 | table-row-background-color: '#000000' 65 | text-primary-color: 'var(--primary-text-color)' 66 | radial-icon-size: '48px' 67 | radial-menu-button-color: 'red' 68 | radial-menu-item-color: 'yellow' 69 | day: 70 | ### Main Interface Colors ### 71 | primary-color: '#93abca' 72 | light-primary-color: '#5F81B0' 73 | primary-background-color: '#F0F5FF' 74 | secondary-background-color: var(--primary-background-color) 75 | secondary-background-color-alpha: 'rgba(220, 225, 235, 0.6)' 76 | divider-color: '#D6DFEB' 77 | ### Text ### 78 | primary-text-color: '#395274' 79 | secondary-text-color: '#5294E2' 80 | text-primary-color: '#FFFFFF' 81 | disabled-text-color: '#88A1C4' 82 | ### Sidebar Menu ### 83 | sidebar-icon-color: '#395274' 84 | sidebar-text-color: var(--sidebar-icon-color) 85 | sidebar-selected-background-color: var(--primary-background-color) 86 | sidebar-selected-icon-color: '#FF6262' 87 | sidebar-selected-text-color: var(--sidebar-selected-icon-color) 88 | ### States and Badges ### 89 | state-icon-color: '#395274' 90 | state-icon-active-color: '#ebb307' 91 | state-icon-unavailable-color: var(--disabled-text-color) 92 | ### Sliders ### 93 | paper-slider-knob-color: '#FF6262' 94 | paper-slider-knob-start-color: var(--paper-slider-knob-color) 95 | paper-slider-pin-color: var(--paper-slider-knob-color) 96 | paper-slider-active-color: var(--paper-slider-knob-color) 97 | paper-slider-secondary-color: var(--light-primary-color) 98 | ### Labels ### 99 | label-badge-background-color: '#FFFFFF' 100 | label-badge-text-color: '#395274' 101 | label-badge-red: '#FF8888' 102 | ### Cards ### 103 | paper-card-background-color: 'rgba(255, 255, 255, 0.4)' 104 | paper-listbox-background-color: '#F0F1F3' 105 | card-background-color: 'var(--primary-background-color)' 106 | 107 | ha-card-border-radius: 10px 108 | border-color: 'var(--primary-text-color)' 109 | background-image: 'center / cover no-repeat url("/local/background/day.jpg") fixed' 110 | 111 | ### Toggles ### 112 | paper-toggle-button-checked-button-color: '#FF6262' 113 | paper-toggle-button-checked-bar-color: '#FF6262' 114 | paper-toggle-button-unchecked-button-color: '#395274' 115 | paper-toggle-button-unchecked-bar-color: '#9CB2CE' 116 | switch-checked-color: 'var(--paper-toggle-button-checked-button-color)' 117 | switch-unchecked-button-color: 'var(--paper-toggle-button-unchecked-button-color)' 118 | switch-unchecked-color: 'var(--paper-toggle-button-unchecked-bar-color)' 119 | switch-unchecked-track-color: 'var(--paper-toggle-button-unchecked-bar-color)' 120 | ### Table row ### 121 | table-row-background-color: var(--primary-background-color) 122 | table-row-alternative-background-color: var(--secondary-background-color) 123 | 124 | radial-icon-size: '72px' 125 | radial-menu-button-color: 'yellow' 126 | radial-menu-item-color: 'red' 127 | 128 | sc-background-filter: none 129 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // See https://aka.ms/vscode-remote/devcontainer.json for format details. 2 | { 3 | "name": "Radial Menu Element Development", 4 | "image": "ludeeus/container:monster", 5 | "context": "..", 6 | "appPort": ["5000:5000", "9123:8123"], 7 | "postCreateCommand": "npm install", 8 | "runArgs": [ 9 | "-v", 10 | "${env:HOME}${env:USERPROFILE}/.ssh:/tmp/.ssh" // This is added so you can push from inside the container 11 | ], 12 | "extensions": [ 13 | "github.vscode-pull-request-github", 14 | "eamodio.gitlens", 15 | "dbaeumer.vscode-eslint", 16 | "esbenp.prettier-vscode", 17 | "bierner.lit-html", 18 | "runem.lit-plugin", 19 | "auchenberg.vscode-browser-preview", 20 | "davidanson.vscode-markdownlint", 21 | "redhat.vscode-yaml" 22 | ], 23 | "settings": { 24 | "files.eol": "\n", 25 | "editor.tabSize": 4, 26 | "terminal.integrated.shell.linux": "/bin/bash", 27 | "editor.formatOnPaste": false, 28 | "editor.formatOnSave": true, 29 | "editor.formatOnType": true, 30 | "files.trimTrailingWhitespace": true 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.devcontainer/ui-lovelace.yaml: -------------------------------------------------------------------------------- 1 | views: 2 | - cards: 3 | - type: custom:radial-menu 4 | icon: mdi:home 5 | name: Home 6 | default_open: true 7 | default_dismiss: false 8 | hold_action: 9 | action: url 10 | url: https://www.home-assistant.io 11 | items: 12 | - entity: light.bed_light 13 | icon: mdi:flash 14 | name: Bedroom Light 15 | tap_action: 16 | action: toggle 17 | haptic: true 18 | hold_action: 19 | action: more-info 20 | - entity: alarm_control_panel.ha_alarm 21 | icon: mdi:alarm-light 22 | name: Alarm Panel 23 | tap_action: 24 | action: more-info 25 | - icon: mdi:alarm 26 | name: Timer 27 | tap_action: 28 | action: call-service 29 | service: timer.start 30 | service_data: 31 | entity_id: timer.laundry 32 | haptic: true 33 | hold_action: 34 | action: call-service 35 | service: timer.pause 36 | service_data: 37 | entity_id: timer.laundry 38 | haptic: true 39 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', // Specifies the ESLint parser 3 | extends: [ 4 | 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin 5 | 'prettier/@typescript-eslint', // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier 6 | 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and displays prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. 7 | ], 8 | parserOptions: { 9 | ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features 10 | sourceType: 'module', // Allows for the use of imports 11 | experimentalDecorators: true, 12 | }, 13 | rules: { 14 | "@typescript-eslint/camelcase": 0 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [iantrich] 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | 14 | 15 | **Checklist:** 16 | 17 | - [ ] I updated to the latest version available 18 | - [ ] I cleared the cache of my browser 19 | 20 | **Release with the issue:** 21 | 22 | **Last working release (if known):** 23 | 24 | **Browser and Operating System:** 25 | 26 | 29 | 30 | **Description of problem:** 31 | 32 | 35 | 36 | **Javascript errors shown in the web inspector (if applicable):** 37 | 38 | ``` 39 | 40 | ``` 41 | 42 | **Additional information:** 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: feature request 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: "Build" 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | name: Test build 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v1 17 | - name: Build 18 | run: | 19 | npm install 20 | npm run build 21 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | release: 9 | name: Prepare release 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v1 13 | 14 | # Build 15 | - name: Build the file 16 | run: | 17 | cd /home/runner/work/radial-menu/radial-menu 18 | npm install 19 | npm run build 20 | 21 | # Upload build file to the releas as an asset. 22 | - name: Upload zip to release 23 | uses: svenstaro/upload-release-action@v1-release 24 | 25 | with: 26 | repo_token: ${{ secrets.GITHUB_TOKEN }} 27 | file: /home/runner/work/radial-menu/radial-menu/dist/radial-menu.js 28 | asset_name: radial-menu.js 29 | tag: ${{ github.ref }} 30 | overwrite: true 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /.rpt2_cache/ 3 | package-lock.json 4 | /dist 5 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 120, 6 | tabWidth: 2, 7 | }; 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Ian Richardson 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 | # ⭕ Lovelace Radial Menu Element 2 | 3 | [![GitHub Release][releases-shield]][releases] 4 | [![License][license-shield]](LICENSE.md) 5 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge)](https://github.com/custom-components/hacs) 6 | 7 | ![Project Maintenance][maintenance-shield] 8 | [![GitHub Activity][commits-shield]][commits] 9 | 10 | [![Discord][discord-shield]][discord] 11 | [![Community Forum][forum-shield]][forum] 12 | 13 | [![Twitter][twitter]][twitter] 14 | [![Github][github]][github] 15 | 16 | This element is for [Lovelace](https://www.home-assistant.io/lovelace) on [Home Assistant](https://www.home-assistant.io/) that provides a radial menu on click for quick/space saving access to commands. Designed for picture-elements, but can be used anywhere. 17 | 18 | ## Minimum Home Assistant Version 19 | 20 | Home Assistant version 0.110.0 or higher is required as of release 1.2.0 of restriction-card 21 | 22 | ## Support 23 | 24 | Hey dude! Help me out for a couple of :beers: or a :coffee:! 25 | 26 | [![coffee](https://www.buymeacoffee.com/assets/img/custom_images/black_img.png)](https://www.buymeacoffee.com/zJtVxUAgH) 27 | 28 | ![example](example.gif) 29 | 30 | ## Installation 31 | 32 | Use [HACS](https://hacs.xyz) or follow this [guide](https://github.com/thomasloven/hass-config/wiki/Lovelace-Plugins) 33 | 34 | ```yaml 35 | resources: 36 | url: /local/radial-menu.js 37 | type: module 38 | ``` 39 | 40 | ## Options 41 | 42 | | Name | Type | Requirement | Description | Default | 43 | | ------------------- | --------- | ------------ | ------------------------------------------------------------------------- | --------------------- | 44 | | `type` | `string` | **Required** | `custom:radial-menu` | `none` | 45 | | `items` | `list` | **Required** | List of items to display in the radial. See [item options](#item-options) | `none` | 46 | | `name` | `string` | **Optional** | Tooltip for main menu | `Menu` | 47 | | `icon` | `string` | **Optional** | mdi icon for main menu | `mdi:menu` | 48 | | `entity_picture` | `string` | **Optional** | picture to display | `none` | 49 | | `default_open` | `boolean` | **Optional** | Should the radial be expanded on first load | `false` | 50 | | `default_dismiss` | `boolean` | **Optional** | Should the radial be dismissed on click | `true` | 51 | | `entity` | `string` | **Optional** | Home Assistant entity ID (used for `more-info` action) | `none` | 52 | | `tap_action` | `map` | **Optional** | Action to take on tap. See [action options](#action-options) | `action: toggle-menu` | 53 | | `hold_action` | `map` | **Optional** | Action to take on hold. See [action options](#action-options) | `none` | 54 | | `double_tap_action` | `map` | **Optional** | Action to take on double tap. See [action options](#action-options) | `action: none` | 55 | | `theme` | `string` | **Optional** | Card theme | | 56 | | `items_offset` | `number` | **Optional** | Distance of items from menu center | `35` | 57 | 58 | ## Item Options 59 | 60 | | Name | Type | Requirement | Description | Default | 61 | | ------------------- | -------- | ------------ | ------------------------------------------------------------------- | ------------------- | 62 | | `card` | `string` | **Optional** | A whole other Lovelace card configuration to build. | 63 | | `entity` | `string` | **Optional** | Home Assistant entity ID. | `none` | 64 | | `name` | `string` | **Optional** | Tooltip for main menu | `Menu` | 65 | | `icon` | `string` | **Optional** | mdi icon for main menu | `none` | 66 | | `entity_picture` | `string` | **Optional** | picture to display | `none` | 67 | | `tap_action` | `map` | **Optional** | Action to take on tap. See [action options](#action-options) | `action: more-info` | 68 | | `hold_action` | `map` | **Optional** | Action to take on hold. See [action options](#action-options) | `none` | 69 | | `double_tap_action` | `map` | **Optional** | Action to take on double tap. See [action options](#action-options) | `action: none` | 70 | 71 | ## Action Options 72 | 73 | | Name | Type | Default | Supported options | Description | 74 | | ----------------- | -------- | -------- | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | 75 | | `action` | `string` | `toggle` | `more-info`, `toggle`, `call-service`, `none`, `navigate`, `url` | Action to perform | 76 | | `entity` | `string` | none | Any entity id | **Only valid for `action: more-info`** to override the entity on which you want to call `more-info` | 77 | | `navigation_path` | `string` | none | Eg: `/lovelace/0/` | Path to navigate to (e.g. `/lovelace/0/`) when action defined as navigate | 78 | | `url_path` | `string` | none | Eg: `https://www.google.com` | URL to open on click when action is `url`. | 79 | | `service` | `string` | none | Any service | Service to call (e.g. `media_player.media_play_pause`) when `action` defined as `call-service` | 80 | | `service_data` | `map` | none | Any service data | Service data to include (e.g. `entity_id: media_player.bedroom`) when `action` defined as `call-service`. | 81 | | `haptic` | `string` | none | `success`, `warning`, `failure`, `light`, `medium`, `heavy`, `selection` | Haptic feedback for the [Beta IOS App](http://home-assistant.io/ios/beta) | 82 | 83 | ## Theme Variables 84 | 85 | The following variables are available and can be set in your theme to change the appearance of the radial menu. 86 | 87 | Can be specified by color name, hexadecimal, rgb, rgba, hsl, hsla, basically anything supported by CSS. 88 | 89 | | name | Default | Description | 90 | | -------------------------- | --------------- | ----------- | 91 | | `radial-icon-size` | `24px` | icon size | 92 | | `radial-menu-button-color` | `primary-color` | Menu color | 93 | | `radial-menu-item-color` | `primary-color` | Item color | 94 | 95 | ## Usage 96 | 97 | ```yaml 98 | type: 'custom:radial-menu' 99 | icon: 'mdi:home' 100 | name: 'Home' 101 | default_open: true 102 | default_dismiss: false 103 | hold_action: 104 | action: url 105 | url: https://www.home-assistant.io 106 | items: 107 | - entity: light.bed_light 108 | icon: 'mdi:flash' 109 | name: Bedroom Light 110 | tap_action: 111 | action: toggle 112 | haptic: true 113 | hold_action: 114 | action: more-info 115 | - entity: alarm_control_panel.ha_alarm 116 | icon: 'mdi:alarm-light' 117 | name: Alarm Panel 118 | tap_action: 119 | action: more-info 120 | - icon: 'mdi:alarm' 121 | name: Timer 122 | tap_action: 123 | action: call-service 124 | service: timer.start 125 | service_data: 126 | entity_id: timer.laundry 127 | haptic: true 128 | hold_action: 129 | action: call-service 130 | service: timer.pause 131 | service_data: 132 | entity_id: timer.laundry 133 | haptic: true 134 | - entity_picture: '/local/headphones.png' 135 | name: Podcasts 136 | tap_action: 137 | action: navigate 138 | navigation_path: /lovelace/1 139 | - card: 140 | type: 'custom:button-card' 141 | entity: light.kitchen 142 | show_name: false 143 | styles: 144 | card: 145 | - background-color: 'rgba(0, 0, 0, 0)' 146 | - box-shadow: 0px 0px 0px 0px black 147 | ``` 148 | 149 | ![example3](example3.png) 150 | 151 | [Troubleshooting](https://github.com/thomasloven/hass-config/wiki/Lovelace-Plugins) 152 | 153 | Inspiration taken from [Creative Punch](https://codepen.io/CreativePunch/pen/lAHiu) 154 | 155 | [commits-shield]: https://img.shields.io/github/commit-activity/y/iantrich/radial-menu.svg?style=for-the-badge 156 | [commits]: https://github.com/iantrich/radial-menu/commits/master 157 | [discord]: https://discord.gg/Qa5fW2R 158 | [discord-shield]: https://img.shields.io/discord/330944238910963714.svg?style=for-the-badge 159 | [forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg?style=for-the-badge 160 | [forum]: https://community.home-assistant.io/t/lovelace-radial-menu-element/111210 161 | [license-shield]: https://img.shields.io/github/license/iantrich/radial-menu.svg?style=for-the-badge 162 | [maintenance-shield]: https://img.shields.io/badge/maintainer-Ian%20Richardson%20%40iantrich-blue.svg?style=for-the-badge 163 | [releases-shield]: https://img.shields.io/github/release/iantrich/radial-menu.svg?style=for-the-badge 164 | [releases]: https://github.com/iantrich/radial-menu/releases 165 | [twitter]: https://img.shields.io/twitter/follow/iantrich.svg?style=social 166 | [github]: https://img.shields.io/github/followers/iantrich.svg?style=social 167 | -------------------------------------------------------------------------------- /example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantrich/radial-menu/c5dea6bca44042bee018de190cdda8844b498453/example.gif -------------------------------------------------------------------------------- /example2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantrich/radial-menu/c5dea6bca44042bee018de190cdda8844b498453/example2.gif -------------------------------------------------------------------------------- /example3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iantrich/radial-menu/c5dea6bca44042bee018de190cdda8844b498453/example3.png -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Radial Menu Element", 3 | "render_readme": true, 4 | "homeassistant": "0.110.0" 5 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "radial-menu", 3 | "version": "1.6.0", 4 | "description": "Lovelace radial-menu", 5 | "keywords": [ 6 | "home-assistant", 7 | "homeassistant", 8 | "hass", 9 | "automation", 10 | "lovelace", 11 | "custom-cards" 12 | ], 13 | "module": "radial-menu.js", 14 | "repository": "git@github.com:iantrich/radial-menu.git", 15 | "author": "Ian Richardson ", 16 | "license": "MIT", 17 | "dependencies": { 18 | "custom-card-helpers": "^1.6.4", 19 | "home-assistant-js-websocket": "^4.5.0", 20 | "lit-element": "^2.3.1", 21 | "lit-html": "^1.2.1" 22 | }, 23 | "devDependencies": { 24 | "@babel/core": "^7.10.3", 25 | "@babel/plugin-proposal-class-properties": "^7.10.1", 26 | "@babel/plugin-proposal-decorators": "^7.10.3", 27 | "@typescript-eslint/eslint-plugin": "^2.34.0", 28 | "@typescript-eslint/parser": "^2.34.0", 29 | "eslint": "^6.8.0", 30 | "eslint-config-airbnb-base": "^14.2.0", 31 | "eslint-config-prettier": "^6.11.0", 32 | "eslint-plugin-import": "^2.22.0", 33 | "eslint-plugin-prettier": "^3.1.4", 34 | "prettier": "^1.19.1", 35 | "rollup": "^1.32.1", 36 | "rollup-plugin-babel": "^4.4.0", 37 | "rollup-plugin-node-resolve": "^5.2.0", 38 | "rollup-plugin-serve": "^1.0.1", 39 | "rollup-plugin-terser": "^5.3.0", 40 | "rollup-plugin-typescript2": "^0.24.3", 41 | "rollup-plugin-uglify": "^6.0.4", 42 | "typescript": "^3.9.5" 43 | }, 44 | "scripts": { 45 | "start": "rollup -c rollup.config.dev.js --watch", 46 | "build": "npm run lint && npm run rollup", 47 | "lint": "eslint src/*.ts", 48 | "rollup": "rollup -c" 49 | } 50 | } -------------------------------------------------------------------------------- /rollup.config.dev.js: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve'; 2 | import typescript from 'rollup-plugin-typescript2'; 3 | import babel from 'rollup-plugin-babel'; 4 | import serve from 'rollup-plugin-serve'; 5 | import { terser } from 'rollup-plugin-terser'; 6 | 7 | export default { 8 | input: ['src/radial-menu.ts'], 9 | output: { 10 | dir: './dist', 11 | format: 'es', 12 | }, 13 | plugins: [ 14 | resolve(), 15 | typescript(), 16 | babel({ 17 | exclude: 'node_modules/**', 18 | }), 19 | terser(), 20 | serve({ 21 | contentBase: './dist', 22 | host: '0.0.0.0', 23 | port: 5000, 24 | allowCrossOrigin: true, 25 | headers: { 26 | 'Access-Control-Allow-Origin': '*', 27 | }, 28 | }), 29 | ], 30 | }; 31 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from "rollup-plugin-node-resolve"; 2 | import typescript from "rollup-plugin-typescript2"; 3 | import babel from "rollup-plugin-babel"; 4 | import { terser } from "rollup-plugin-terser"; 5 | 6 | export default { 7 | input: ["src/radial-menu.ts"], 8 | output: { 9 | dir: "./dist", 10 | format: "es" 11 | }, 12 | plugins: [ 13 | resolve(), 14 | typescript(), 15 | babel({ 16 | exclude: "node_modules/**" 17 | }), 18 | terser(), 19 | ] 20 | }; 21 | -------------------------------------------------------------------------------- /src/action-handler-directive.ts: -------------------------------------------------------------------------------- 1 | import { directive, PropertyPart } from 'lit-html'; 2 | 3 | import { fireEvent, ActionHandlerDetail, ActionHandlerOptions } from 'custom-card-helpers'; 4 | 5 | const isTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0; 6 | 7 | interface ActionHandler extends HTMLElement { 8 | holdTime: number; 9 | bind(element: Element, options): void; 10 | } 11 | interface ActionHandlerElement extends HTMLElement { 12 | actionHandler?: boolean; 13 | } 14 | 15 | declare global { 16 | interface HASSDomEvents { 17 | action: ActionHandlerDetail; 18 | } 19 | } 20 | 21 | class ActionHandler extends HTMLElement implements ActionHandler { 22 | public holdTime = 500; 23 | 24 | public ripple: any; 25 | 26 | protected timer?: number; 27 | 28 | protected held = false; 29 | 30 | private dblClickTimeout?: number; 31 | 32 | constructor() { 33 | super(); 34 | this.ripple = document.createElement('mwc-ripple'); 35 | } 36 | 37 | public connectedCallback(): void { 38 | Object.assign(this.style, { 39 | position: 'absolute', 40 | width: isTouch ? '100px' : '50px', 41 | height: isTouch ? '100px' : '50px', 42 | transform: 'translate(-50%, -50%)', 43 | pointerEvents: 'none', 44 | zIndex: '999', 45 | }); 46 | 47 | this.appendChild(this.ripple); 48 | this.ripple.primary = true; 49 | 50 | ['touchcancel', 'mouseout', 'mouseup', 'touchmove', 'mousewheel', 'wheel', 'scroll'].forEach(ev => { 51 | document.addEventListener( 52 | ev, 53 | () => { 54 | clearTimeout(this.timer); 55 | this.stopAnimation(); 56 | this.timer = undefined; 57 | }, 58 | { passive: true }, 59 | ); 60 | }); 61 | } 62 | 63 | public bind(element: ActionHandlerElement, options): void { 64 | if (element.actionHandler) { 65 | return; 66 | } 67 | element.actionHandler = true; 68 | 69 | element.addEventListener('contextmenu', (ev: Event) => { 70 | const e = ev || window.event; 71 | if (e.preventDefault) { 72 | e.preventDefault(); 73 | } 74 | if (e.stopPropagation) { 75 | e.stopPropagation(); 76 | } 77 | e.cancelBubble = true; 78 | e.returnValue = false; 79 | return false; 80 | }); 81 | 82 | const start = (ev: Event): void => { 83 | this.held = false; 84 | let x; 85 | let y; 86 | if ((ev as TouchEvent).touches) { 87 | x = (ev as TouchEvent).touches[0].pageX; 88 | y = (ev as TouchEvent).touches[0].pageY; 89 | } else { 90 | x = (ev as MouseEvent).pageX; 91 | y = (ev as MouseEvent).pageY; 92 | } 93 | 94 | this.timer = window.setTimeout(() => { 95 | this.startAnimation(x, y); 96 | this.held = true; 97 | }, this.holdTime); 98 | }; 99 | 100 | const end = (ev: Event): void => { 101 | // Prevent mouse event if touch event 102 | ev.preventDefault(); 103 | if (['touchend', 'touchcancel'].includes(ev.type) && this.timer === undefined) { 104 | return; 105 | } 106 | clearTimeout(this.timer); 107 | this.stopAnimation(); 108 | this.timer = undefined; 109 | if (this.held) { 110 | fireEvent(element, 'action', { action: 'hold' }); 111 | } else if (options.hasDoubleClick) { 112 | if ((ev.type === 'click' && (ev as MouseEvent).detail < 2) || !this.dblClickTimeout) { 113 | this.dblClickTimeout = window.setTimeout(() => { 114 | this.dblClickTimeout = undefined; 115 | fireEvent(element, 'action', { action: 'tap' }); 116 | }, 250); 117 | } else { 118 | clearTimeout(this.dblClickTimeout); 119 | this.dblClickTimeout = undefined; 120 | fireEvent(element, 'action', { action: 'double_tap' }); 121 | } 122 | } else { 123 | fireEvent(element, 'action', { action: 'tap' }); 124 | } 125 | }; 126 | 127 | const handleEnter = (ev: KeyboardEvent): void => { 128 | if (ev.keyCode !== 13) { 129 | return; 130 | } 131 | end(ev); 132 | }; 133 | 134 | element.addEventListener('touchstart', start, { passive: true }); 135 | element.addEventListener('touchend', end); 136 | element.addEventListener('touchcancel', end); 137 | 138 | element.addEventListener('mousedown', start, { passive: true }); 139 | element.addEventListener('click', end); 140 | 141 | element.addEventListener('keyup', handleEnter); 142 | } 143 | 144 | private startAnimation(x: number, y: number): void { 145 | Object.assign(this.style, { 146 | left: `${x}px`, 147 | top: `${y}px`, 148 | display: null, 149 | }); 150 | this.ripple.disabled = false; 151 | this.ripple.active = true; 152 | this.ripple.unbounded = true; 153 | } 154 | 155 | private stopAnimation(): void { 156 | this.ripple.active = false; 157 | this.ripple.disabled = true; 158 | this.style.display = 'none'; 159 | } 160 | } 161 | 162 | customElements.define('action-handler-radial', ActionHandler); 163 | 164 | const getActionHandler = (): ActionHandler => { 165 | const body = document.body; 166 | if (body.querySelector('action-handler-radial')) { 167 | return body.querySelector('action-handler-radial') as ActionHandler; 168 | } 169 | 170 | const actionhandler = document.createElement('action-handler-radial'); 171 | body.appendChild(actionhandler); 172 | 173 | return actionhandler as ActionHandler; 174 | }; 175 | 176 | export const actionHandlerBind = (element: ActionHandlerElement, options: ActionHandlerOptions): void => { 177 | const actionhandler: ActionHandler = getActionHandler(); 178 | if (!actionhandler) { 179 | return; 180 | } 181 | actionhandler.bind(element, options); 182 | }; 183 | 184 | export const actionHandler = directive((options: ActionHandlerOptions = {}) => (part: PropertyPart): void => { 185 | actionHandlerBind(part.committer.element as ActionHandlerElement, options); 186 | }); 187 | -------------------------------------------------------------------------------- /src/const.ts: -------------------------------------------------------------------------------- 1 | export const CARD_VERSION = '1.6.0'; 2 | -------------------------------------------------------------------------------- /src/radial-menu.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, customElement, property, TemplateResult, css, CSSResult, PropertyValues } from 'lit-element'; 2 | import { HomeAssistant, handleAction, hasAction, createThing, applyThemesOnElement } from 'custom-card-helpers'; 3 | 4 | import { RadialMenuConfig } from './types'; 5 | import { CARD_VERSION } from './const'; 6 | import { actionHandler } from './action-handler-directive'; 7 | 8 | /* eslint no-console: 0 */ 9 | console.info( 10 | `%c RADIAL-MENU \n%c Version ${CARD_VERSION} `, 11 | 'color: orange; font-weight: bold; background: black', 12 | 'color: white; font-weight: bold; background: dimgray', 13 | ); 14 | 15 | @customElement('radial-menu') 16 | export class RadialMenu extends LitElement { 17 | @property() public hass?: HomeAssistant; 18 | @property() private _config?: RadialMenuConfig; 19 | @property() private _helpers?: any; 20 | private _initialized = false; 21 | 22 | public setConfig(config: RadialMenuConfig): void { 23 | if (!config) { 24 | throw new Error('Invalid configuration'); 25 | } 26 | 27 | if (!config.items) { 28 | throw new Error('Invalid configuration: No items defined'); 29 | } 30 | 31 | this._config = { 32 | icon: 'mdi:menu', 33 | name: 'menu', 34 | tap_action: { 35 | action: 'toggle-menu', 36 | }, 37 | hold_action: { 38 | action: 'none', 39 | }, 40 | double_tap_action: { 41 | action: 'none', 42 | }, 43 | default_dismiss: true, 44 | items_offset: 35, 45 | ...config, 46 | }; 47 | 48 | this.loadCardHelpers(); 49 | } 50 | 51 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 52 | protected shouldUpdate(changedProps: PropertyValues): boolean { 53 | if (changedProps && !this._initialized) { 54 | this._initialize(); 55 | } 56 | 57 | return true; 58 | } 59 | 60 | public getCardSize(): number { 61 | return 1; 62 | } 63 | 64 | protected render(): TemplateResult | void { 65 | if (!this._config || !this.hass || !this._helpers) { 66 | return html``; 67 | } 68 | 69 | this._config.items.forEach(item => { 70 | if (item.card) { 71 | item.element = this._helpers.createCardElement(item.card); 72 | 73 | if (item.element) { 74 | item.element.hass = this.hass; 75 | item.element.classList.add('custom'); 76 | } 77 | } 78 | }); 79 | 80 | return html` 81 | 176 | `; 177 | } 178 | 179 | protected firstUpdated(): void { 180 | if (this._config && this._config.default_open) { 181 | this._toggleMenu(); 182 | } 183 | } 184 | 185 | private _toggleMenu(): void { 186 | if (this.shadowRoot) { 187 | const element = this.shadowRoot.querySelector('.circle'); 188 | 189 | if (element) { 190 | element.classList.toggle('open'); 191 | } 192 | } 193 | } 194 | 195 | private _initialize(): void { 196 | if (this.hass === undefined) return; 197 | if (this._config === undefined) return; 198 | if (this._helpers === undefined) return; 199 | this._initialized = true; 200 | } 201 | 202 | private async loadCardHelpers(): Promise { 203 | this._helpers = await (window as any).loadCardHelpers(); 204 | } 205 | 206 | private _handleAction(ev): void { 207 | const config = ev.target.config; 208 | 209 | if ( 210 | config && 211 | ev.detail.action && 212 | ((ev.detail.action === 'tap' && config.tap_action && config.tap_action.action === 'toggle-menu') || 213 | (ev.detail.action === 'hold' && config.hold_action && config.hold_action.action === 'toggle-menu') || 214 | (ev.detail.action === 'double_tap' && 215 | config.double_tap_action && 216 | config.double_tap_action.action === 'toggle-menu')) 217 | ) { 218 | this._toggleMenu(); 219 | } else { 220 | if (this.hass && ev.detail.action) { 221 | handleAction(this, this.hass, config, ev.detail.action); 222 | } 223 | if (this._config && this._config.default_dismiss && !ev.target.menu) { 224 | this._toggleMenu(); 225 | } 226 | } 227 | } 228 | 229 | protected updated(changedProps): void { 230 | if (!this._config) { 231 | return; 232 | } 233 | 234 | if (this.hass) { 235 | const oldHass = changedProps.get('hass'); 236 | if (!oldHass || oldHass.themes !== this.hass.themes) { 237 | applyThemesOnElement(this, this.hass.themes, this._config.theme); 238 | } 239 | } 240 | } 241 | 242 | static get styles(): CSSResult { 243 | return css` 244 | :host { 245 | --icon-size: var(--radial-icon-size, var(--mdc-icon-size, 24px)); 246 | --menu-button-color: var(--radial-menu-button-color, var(--primary-color, #212121)); 247 | --menu-item-color: var(--radial-menu-item-color, var(--primary-color, #212121)); 248 | } 249 | 250 | ha-icon { 251 | --mdc-icon-size: var(--icon-size); 252 | } 253 | 254 | .circular-menu { 255 | width: 250px; 256 | height: 250px; 257 | margin: 0 auto; 258 | position: relative; 259 | } 260 | 261 | .circle { 262 | width: 250px; 263 | height: 250px; 264 | opacity: 0; 265 | 266 | -webkit-transform: scale(0); 267 | -moz-transform: scale(0); 268 | transform: scale(0); 269 | 270 | -webkit-transition: all 0.4s ease-out; 271 | -moz-transition: all 0.4s ease-out; 272 | transition: all 0.4s ease-out; 273 | } 274 | 275 | .open.circle { 276 | opacity: 1; 277 | -webkit-transform: scale(1); 278 | -moz-transform: scale(1); 279 | transform: scale(1); 280 | } 281 | 282 | .circle ha-icon, 283 | .circle state-badge, 284 | .custom { 285 | display: block; 286 | position: absolute; 287 | } 288 | 289 | .custom { 290 | height: 100px; 291 | width: 100px; 292 | margin-left: -40px; 293 | margin-top: -25px; 294 | } 295 | 296 | .circle ha-icon, 297 | .circle state-badge { 298 | text-decoration: none; 299 | border-radius: 50%; 300 | text-align: center; 301 | height: 40px; 302 | width: 40px; 303 | line-height: 40px; 304 | margin-left: -20px; 305 | margin-top: -20px; 306 | color: var(--menu-item-color); 307 | } 308 | 309 | .circle ha-icon:hover { 310 | color: var(--accent-color); 311 | } 312 | 313 | .circle state-badge:hover { 314 | background-color: var(--secondary-background-color); 315 | } 316 | 317 | ha-icon, 318 | state-badge { 319 | cursor: pointer; 320 | } 321 | 322 | ha-icon { 323 | cursor: pointer; 324 | } 325 | 326 | .menu-button { 327 | position: absolute; 328 | text-decoration: none; 329 | text-align: center; 330 | border-radius: 50%; 331 | display: block; 332 | height: 40px; 333 | width: 40px; 334 | line-height: 40px; 335 | color: var(--menu-button-color); 336 | } 337 | 338 | state-badge.menu-button { 339 | top: calc(50% - 20px); 340 | left: calc(50% - 20px); 341 | } 342 | 343 | ha-icon.menu-button { 344 | top: calc(50% - 30px); 345 | left: calc(50% - 30px); 346 | padding: 10px; 347 | } 348 | 349 | .menu-button:hover { 350 | background-color: var(--secondary-background-color); 351 | } 352 | `; 353 | } 354 | } 355 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { ActionConfig, LovelaceCardConfig, LovelaceCard } from 'custom-card-helpers'; 2 | 3 | export interface RadialMenuConfig { 4 | type: string; 5 | name?: string; 6 | icon?: string; 7 | entity_picture?: string; 8 | default_open?: boolean; 9 | default_dismiss?: boolean; 10 | tap_action?: ActionConfig; 11 | double_tap_action?: ActionConfig; 12 | hold_action?: ActionConfig; 13 | items: RadialMenuItemConfig[]; 14 | theme?: string; 15 | items_offset?: number; 16 | } 17 | 18 | export interface RadialMenuItemConfig { 19 | icon?: string; 20 | entity_picture?: string; 21 | name?: string; 22 | entity?: string; 23 | tap_action?: ActionConfig; 24 | double_tap_action?: ActionConfig; 25 | hold_action?: ActionConfig; 26 | card?: LovelaceCardConfig; 27 | element?: LovelaceCard; 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "lib": ["es2017", "dom", "dom.iterable"], 7 | "noEmit": true, 8 | "noUnusedParameters": true, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "strict": true, 12 | "noImplicitAny": false, 13 | "skipLibCheck": true, 14 | "resolveJsonModule": true, 15 | "experimentalDecorators": true 16 | } 17 | } 18 | --------------------------------------------------------------------------------