diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9e92523 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/node_modules/ +yarn.lock +.rpt2_cache/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6893c34 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Custom cards for Home Assistant + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index b456c85..3e7be45 100644 --- a/README.md +++ b/README.md @@ -1 +1,116 @@ -radial-menu +# 🔘 Lovelace Radial Menu Element + +[![GitHub Release][releases-shield]][releases] +[![GitHub Activity][commits-shield]][commits] +[![custom_updater][customupdaterbadge]][customupdater] +[![License][license-shield]](LICENSE.md) + +![Project Maintenance][maintenance-shield] +[![BuyMeCoffee][buymecoffeebadge]][buymecoffee] + +[![Discord][discord-shield]][discord] +[![Community Forum][forum-shield]][forum] + +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. + +![example](example.gif) + +## Options + +| Name | Type | Requirement | Description +| ---- | ---- | ------- | ----------- +| type | string | **Required** | `custom:radial-menu` +| items | list | **Required** | List of items to display in the radial +| name | string | **Optional** | Tooltip for main menu `Menu` +| icon | string | **Optional** | mdi icon for main menu `mdi:menu` + +## Items Options + +| Name | Type | Requirement | Description | Default +| ---- | ---- | ------- | ----------- | ------- +| entity | string | **Optional** | Home Assistant entity ID. | `none` +| name | string | **Optional** | Tooltip for main menu | `Menu` +| icon | string | **Optional** | mdi icon for main menu | `mdi:menu` +| tap_action | object | **Optional** | Action to take on tap | `action: more-info` + +## Action Options + +| Name | Type | Requirement | Description | Default +| ---- | ---- | ------- | ----------- | ------- +| action | string | **Required** | Action to perform (more-info, toggle, call-service, navigate, none) | `more-info` +| navigation_path | string | **Optional** | Path to navigate to (e.g. /lovelace/0/) when action defined as navigate | `none` +| service | string | **Optional** | Service to call (e.g. media_player.media_play_pause) when action defined as call-service | `none` +| service_data | object | **Optional** | Service data to include (e.g. entity_id: media_player.bedroom) when action defined as call-service | `none` + +## Installation + +### Step 1 + +Save [radial-menu](https://github.com/custom-cards/radial-menu/raw/master/dist/radial-menu.js) to `/www/radial-menu.js` on your Home Assistant instanse. + +**Example:** + +```bash +wget https://raw.githubusercontent.com/custom-cards/radial-menu/master/dist/radial-menu.js +mv radial-menu.js /config/www/ +``` + +### Step 2 + +Link `radial-menu` inside your `ui-lovelace.yaml` or Raw Editor in the UI Editor + +```yaml +resources: + - url: /local/radial-menu.js + type: module +``` + +### Step 3 + +Add a custom element in your `ui-lovelace.yaml` or in the UI Editor as a Manual Card + +```yaml +type: 'custom:radial-menu' +icon: 'mdi:home' +name: 'Home' +items: + - entity: light.bed_light + icon: 'mdi:flash' + name: Bedroom Light + tap_action: + action: toggle + - entity: alarm_control_panel.ha_alarm + icon: 'mdi:alarm-light' + name: Alarm Panel + tap_action: + action: more-info + - icon: 'mdi:alarm' + name: Timer + tap_action: + action: call-service + service: timer.start + service_data: + entity_id: timer.laundry + - icon: 'mdi:headphones' + name: Podcasts + tap_action: + action: navigate + navigation_path: /lovelace/1 +``` + +[Troubleshooting](https://github.com/thomasloven/hass-config/wiki/Lovelace-Plugins) + +[buymecoffee]: https://www.buymeacoffee.com/iantrich +[buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-blue.svg?style=for-the-badge +[commits-shield]: https://img.shields.io/github/commit-activity/y/custom-cards/radial-menu.svg?style=for-the-badge +[commits]: https://github.com/custom-cards/radial-menu/commits/master +[customupdater]: https://github.com/custom-components/custom_updater +[customupdaterbadge]: https://img.shields.io/badge/custom__updater-true-success.svg?style=for-the-badge +[discord]: https://discord.gg/Qa5fW2R +[discord-shield]: https://img.shields.io/discord/330944238910963714.svg?style=for-the-badge +[forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg?style=for-the-badge +[forum]: https://community.home-assistant.io +[license-shield]: https://img.shields.io/github/license/custom-cards/radial-menu.svg?style=for-the-badge +[maintenance-shield]: https://img.shields.io/badge/maintainer-Ian%20Richardson%20%40iantrich-blue.svg?style=for-the-badge +[releases-shield]: https://img.shields.io/github/release/custom-cards/radial-menu.svg?style=for-the-badge +[releases]: https://github.com/custom-cards/radial-menu/releases diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..bc267c0 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,6 @@ +const plugins = [ + '@babel/plugin-proposal-class-properties', + ['@babel/plugin-proposal-decorators', { decoratorsBeforeExport: true }], +]; + +module.exports = { plugins }; \ No newline at end of file diff --git a/dist/radial-menu.js b/dist/radial-menu.js new file mode 100644 index 0000000..831a534 --- /dev/null +++ b/dist/radial-menu.js @@ -0,0 +1,2628 @@ +/*! ***************************************************************************** +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. You may obtain a copy of the +License at http://www.apache.org/licenses/LICENSE-2.0 + +THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED +WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, +MERCHANTABLITY OR NON-INFRINGEMENT. + +See the Apache Version 2.0 License for specific language governing permissions +and limitations under the License. +***************************************************************************** */ + +function __decorate(decorators, target, key, desc) { + var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; + if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); + else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; + return c > 3 && r && Object.defineProperty(target, key, r), r; +} + +/** + * @license + * Copyright (c) 2017 The Polymer Project Authors. All rights reserved. + * This code may only be used under the BSD style license found at + * http://polymer.github.io/LICENSE.txt + * The complete set of authors may be found at + * http://polymer.github.io/AUTHORS.txt + * The complete set of contributors may be found at + * http://polymer.github.io/CONTRIBUTORS.txt + * Code distributed by Google as part of the polymer project is also + * subject to an additional IP rights grant found at + * http://polymer.github.io/PATENTS.txt + */ +const directives = new WeakMap(); +const isDirective = (o) => { + return typeof o === 'function' && directives.has(o); +}; + +/** + * @license + * Copyright (c) 2017 The Polymer Project Authors. All rights reserved. + * This code may only be used under the BSD style license found at + * http://polymer.github.io/LICENSE.txt + * The complete set of authors may be found at + * http://polymer.github.io/AUTHORS.txt + * The complete set of contributors may be found at + * http://polymer.github.io/CONTRIBUTORS.txt + * Code distributed by Google as part of the polymer project is also + * subject to an additional IP rights grant found at + * http://polymer.github.io/PATENTS.txt + */ +/** + * True if the custom elements polyfill is in use. + */ +const isCEPolyfill = window.customElements !== undefined && + window.customElements.polyfillWrapFlushCallback !== + undefined; +/** + * Removes nodes, starting from `startNode` (inclusive) to `endNode` + * (exclusive), from `container`. + */ +const removeNodes = (container, startNode, endNode = null) => { + let node = startNode; + while (node !== endNode) { + const n = node.nextSibling; + container.removeChild(node); + node = n; + } +}; + +/** + * @license + * Copyright (c) 2018 The Polymer Project Authors. All rights reserved. + * This code may only be used under the BSD style license found at + * http://polymer.github.io/LICENSE.txt + * The complete set of authors may be found at + * http://polymer.github.io/AUTHORS.txt + * The complete set of contributors may be found at + * http://polymer.github.io/CONTRIBUTORS.txt + * Code distributed by Google as part of the polymer project is also + * subject to an additional IP rights grant found at + * http://polymer.github.io/PATENTS.txt + */ +/** + * A sentinel value that signals that a value was handled by a directive and + * should not be written to the DOM. + */ +const noChange = {}; +/** + * A sentinel value that signals a NodePart to fully clear its content. + */ +const nothing = {}; + +/** + * @license + * Copyright (c) 2017 The Polymer Project Authors. All rights reserved. + * This code may only be used under the BSD style license found at + * http://polymer.github.io/LICENSE.txt + * The complete set of authors may be found at + * http://polymer.github.io/AUTHORS.txt + * The complete set of contributors may be found at + * http://polymer.github.io/CONTRIBUTORS.txt + * Code distributed by Google as part of the polymer project is also + * subject to an additional IP rights grant found at + * http://polymer.github.io/PATENTS.txt + */ +/** + * An expression marker with embedded unique key to avoid collision with + * possible text in templates. + */ +const marker = `{{lit-${String(Math.random()).slice(2)}}}`; +/** + * An expression marker used text-positions, multi-binding attributes, and + * attributes with markup-like text values. + */ +const nodeMarker = ``; +const markerRegex = new RegExp(`${marker}|${nodeMarker}`); +/** + * Suffix appended to all bound attribute names. + */ +const boundAttributeSuffix = '$lit$'; +/** + * An updateable Template that tracks the location of dynamic parts. + */ +class Template { + constructor(result, element) { + this.parts = []; + this.element = element; + let index = -1; + let partIndex = 0; + const nodesToRemove = []; + const _prepareTemplate = (template) => { + const content = template.content; + // Edge needs all 4 parameters present; IE11 needs 3rd parameter to be + // null + const walker = document.createTreeWalker(content, 133 /* NodeFilter.SHOW_{ELEMENT|COMMENT|TEXT} */, null, false); + // Keeps track of the last index associated with a part. We try to delete + // unnecessary nodes, but we never want to associate two different parts + // to the same index. They must have a constant node between. + let lastPartIndex = 0; + while (walker.nextNode()) { + index++; + const node = walker.currentNode; + if (node.nodeType === 1 /* Node.ELEMENT_NODE */) { + if (node.hasAttributes()) { + const attributes = node.attributes; + // Per + // https://developer.mozilla.org/en-US/docs/Web/API/NamedNodeMap, + // attributes are not guaranteed to be returned in document order. + // In particular, Edge/IE can return them out of order, so we cannot + // assume a correspondance between part index and attribute index. + let count = 0; + for (let i = 0; i < attributes.length; i++) { + if (attributes[i].value.indexOf(marker) >= 0) { + count++; + } + } + while (count-- > 0) { + // Get the template literal section leading up to the first + // expression in this attribute + const stringForPart = result.strings[partIndex]; + // Find the attribute name + const name = lastAttributeNameRegex.exec(stringForPart)[2]; + // Find the corresponding attribute + // All bound attributes have had a suffix added in + // TemplateResult#getHTML to opt out of special attribute + // handling. To look up the attribute value we also need to add + // the suffix. + const attributeLookupName = name.toLowerCase() + boundAttributeSuffix; + const attributeValue = node.getAttribute(attributeLookupName); + const strings = attributeValue.split(markerRegex); + this.parts.push({ type: 'attribute', index, name, strings }); + node.removeAttribute(attributeLookupName); + partIndex += strings.length - 1; + } + } + if (node.tagName === 'TEMPLATE') { + _prepareTemplate(node); + } + } + else if (node.nodeType === 3 /* Node.TEXT_NODE */) { + const data = node.data; + if (data.indexOf(marker) >= 0) { + const parent = node.parentNode; + const strings = data.split(markerRegex); + const lastIndex = strings.length - 1; + // Generate a new text node for each literal section + // These nodes are also used as the markers for node parts + for (let i = 0; i < lastIndex; i++) { + parent.insertBefore((strings[i] === '') ? createMarker() : + document.createTextNode(strings[i]), node); + this.parts.push({ type: 'node', index: ++index }); + } + // If there's no text, we must insert a comment to mark our place. + // Else, we can trust it will stick around after cloning. + if (strings[lastIndex] === '') { + parent.insertBefore(createMarker(), node); + nodesToRemove.push(node); + } + else { + node.data = strings[lastIndex]; + } + // We have a part for each match found + partIndex += lastIndex; + } + } + else if (node.nodeType === 8 /* Node.COMMENT_NODE */) { + if (node.data === marker) { + const parent = node.parentNode; + // Add a new marker node to be the startNode of the Part if any of + // the following are true: + // * We don't have a previousSibling + // * The previousSibling is already the start of a previous part + if (node.previousSibling === null || index === lastPartIndex) { + index++; + parent.insertBefore(createMarker(), node); + } + lastPartIndex = index; + this.parts.push({ type: 'node', index }); + // If we don't have a nextSibling, keep this node so we have an end. + // Else, we can remove it to save future costs. + if (node.nextSibling === null) { + node.data = ''; + } + else { + nodesToRemove.push(node); + index--; + } + partIndex++; + } + else { + let i = -1; + while ((i = node.data.indexOf(marker, i + 1)) !== + -1) { + // Comment node has a binding marker inside, make an inactive part + // The binding won't work, but subsequent bindings will + // TODO (justinfagnani): consider whether it's even worth it to + // make bindings in comments work + this.parts.push({ type: 'node', index: -1 }); + } + } + } + } + }; + _prepareTemplate(element); + // Remove text binding nodes after the walk to not disturb the TreeWalker + for (const n of nodesToRemove) { + n.parentNode.removeChild(n); + } + } +} +const isTemplatePartActive = (part) => part.index !== -1; +// Allows `document.createComment('')` to be renamed for a +// small manual size-savings. +const createMarker = () => document.createComment(''); +/** + * This regex extracts the attribute name preceding an attribute-position + * expression. It does this by matching the syntax allowed for attributes + * against the string literal directly preceding the expression, assuming that + * the expression is in an attribute-value position. + * + * See attributes in the HTML spec: + * https://www.w3.org/TR/html5/syntax.html#attributes-0 + * + * "\0-\x1F\x7F-\x9F" are Unicode control characters + * + * " \x09\x0a\x0c\x0d" are HTML space characters: + * https://www.w3.org/TR/html5/infrastructure.html#space-character + * + * So an attribute is: + * * The name: any character except a control character, space character, ('), + * ("), ">", "=", or "/" + * * Followed by zero or more space characters + * * Followed by "=" + * * Followed by zero or more space characters + * * Followed by: + * * Any character except space, ('), ("), "<", ">", "=", (`), or + * * (") then any non-("), or + * * (') then any non-(') + */ +const lastAttributeNameRegex = /([ \x09\x0a\x0c\x0d])([^\0-\x1F\x7F-\x9F \x09\x0a\x0c\x0d"'>=/]+)([ \x09\x0a\x0c\x0d]*=[ \x09\x0a\x0c\x0d]*(?:[^ \x09\x0a\x0c\x0d"'`<>=]*|"[^"]*|'[^']*))$/; + +/** + * @license + * Copyright (c) 2017 The Polymer Project Authors. All rights reserved. + * This code may only be used under the BSD style license found at + * http://polymer.github.io/LICENSE.txt + * The complete set of authors may be found at + * http://polymer.github.io/AUTHORS.txt + * The complete set of contributors may be found at + * http://polymer.github.io/CONTRIBUTORS.txt + * Code distributed by Google as part of the polymer project is also + * subject to an additional IP rights grant found at + * http://polymer.github.io/PATENTS.txt + */ +/** + * An instance of a `Template` that can be attached to the DOM and updated + * with new values. + */ +class TemplateInstance { + constructor(template, processor, options) { + this._parts = []; + this.template = template; + this.processor = processor; + this.options = options; + } + update(values) { + let i = 0; + for (const part of this._parts) { + if (part !== undefined) { + part.setValue(values[i]); + } + i++; + } + for (const part of this._parts) { + if (part !== undefined) { + part.commit(); + } + } + } + _clone() { + // When using the Custom Elements polyfill, clone the node, rather than + // importing it, to keep the fragment in the template's document. This + // leaves the fragment inert so custom elements won't upgrade and + // potentially modify their contents by creating a polyfilled ShadowRoot + // while we traverse the tree. + const fragment = isCEPolyfill ? + this.template.element.content.cloneNode(true) : + document.importNode(this.template.element.content, true); + const parts = this.template.parts; + let partIndex = 0; + let nodeIndex = 0; + const _prepareInstance = (fragment) => { + // Edge needs all 4 parameters present; IE11 needs 3rd parameter to be + // null + const walker = document.createTreeWalker(fragment, 133 /* NodeFilter.SHOW_{ELEMENT|COMMENT|TEXT} */, null, false); + let node = walker.nextNode(); + // Loop through all the nodes and parts of a template + while (partIndex < parts.length && node !== null) { + const part = parts[partIndex]; + // Consecutive Parts may have the same node index, in the case of + // multiple bound attributes on an element. So each iteration we either + // increment the nodeIndex, if we aren't on a node with a part, or the + // partIndex if we are. By not incrementing the nodeIndex when we find a + // part, we allow for the next part to be associated with the current + // node if neccessasry. + if (!isTemplatePartActive(part)) { + this._parts.push(undefined); + partIndex++; + } + else if (nodeIndex === part.index) { + if (part.type === 'node') { + const part = this.processor.handleTextExpression(this.options); + part.insertAfterNode(node.previousSibling); + this._parts.push(part); + } + else { + this._parts.push(...this.processor.handleAttributeExpressions(node, part.name, part.strings, this.options)); + } + partIndex++; + } + else { + nodeIndex++; + if (node.nodeName === 'TEMPLATE') { + _prepareInstance(node.content); + } + node = walker.nextNode(); + } + } + }; + _prepareInstance(fragment); + if (isCEPolyfill) { + document.adoptNode(fragment); + customElements.upgrade(fragment); + } + return fragment; + } +} + +/** + * @license + * Copyright (c) 2017 The Polymer Project Authors. All rights reserved. + * This code may only be used under the BSD style license found at + * http://polymer.github.io/LICENSE.txt + * The complete set of authors may be found at + * http://polymer.github.io/AUTHORS.txt + * The complete set of contributors may be found at + * http://polymer.github.io/CONTRIBUTORS.txt + * Code distributed by Google as part of the polymer project is also + * subject to an additional IP rights grant found at + * http://polymer.github.io/PATENTS.txt + */ +/** + * The return type of `html`, which holds a Template and the values from + * interpolated expressions. + */ +class TemplateResult { + constructor(strings, values, type, processor) { + this.strings = strings; + this.values = values; + this.type = type; + this.processor = processor; + } + /** + * Returns a string of HTML used to create a `