Merge pull request #245 from dermotduffy/submenus

Support configurable submenus in the Frigate Card menu
This commit is contained in:
Dermot Duffy
2021-12-17 21:00:31 -08:00
committed by GitHub
10 changed files with 226 additions and 14 deletions
+57 -1
View File
@@ -402,8 +402,22 @@ This card supports all [Picture Elements](https://www.home-assistant.io/lovelace
| ------------- | --------------------------------------------- | | ------------- | --------------------------------------------- |
| `custom:frigate-card-menu-icon` | Add an arbitrary icon to the Frigate Card menu. Configuration is ~identical to that of the [Picture Elements Icon](https://www.home-assistant.io/lovelace/picture-elements/#icon-element) except with a type name of `custom:frigate-card-menu-icon`.| | `custom:frigate-card-menu-icon` | Add an arbitrary icon to the Frigate Card menu. Configuration is ~identical to that of the [Picture Elements Icon](https://www.home-assistant.io/lovelace/picture-elements/#icon-element) except with a type name of `custom:frigate-card-menu-icon`.|
| `custom:frigate-card-menu-state-icon` | Add a state icon to the Frigate Card menu that represents the state of a Home Assistant entity. Configuration is ~identical to that of the [Picture Elements State Icon](https://www.home-assistant.io/lovelace/picture-elements/#state-icon) except with a type name of `custom:frigate-card-menu-state-icon`.| | `custom:frigate-card-menu-state-icon` | Add a state icon to the Frigate Card menu that represents the state of a Home Assistant entity. Configuration is ~identical to that of the [Picture Elements State Icon](https://www.home-assistant.io/lovelace/picture-elements/#state-icon) except with a type name of `custom:frigate-card-menu-state-icon`.|
| `custom:frigate-card-menu-submenu` | Add a configurable submenu dropdown. See [configuration below](#frigate-card-menu-submenu).|
| `custom:frigate-card-conditional` | Restrict a set of elements to only render when the card is showing particular a particular [view](#views). See [configuration below](#frigate-card-conditional).| | `custom:frigate-card-conditional` | Restrict a set of elements to only render when the card is showing particular a particular [view](#views). See [configuration below](#frigate-card-conditional).|
<a name="frigate-card-submenu"></a>
#### `custom:frigate-card-menu-submenu`
Parameters for the `custom:frigate-card-menu-submenu` element are identical to the parameters of the [stock Home Assistant Icon Element](https://www.home-assistant.io/lovelace/picture-elements/#icon-element) with the exception of these parameters which differ:
| Parameter | Description |
| - | - |
| `type` | Must be `custom:frigate-card-menu-submenu`. |
| `items` | A list of menu items. Each menu item in turn also follows the parameters the [stock Home Assistant Icon Element](https://www.home-assistant.io/lovelace/picture-elements/#icon-element). Typical usage would set the `title` parameter to control the text displayed for the menu item, the `icon` parameter to control the icon displayed for the menu item and one or more actions (e.g. `tap_action`, `double_tap_action` or `hold_action`) to configure the action to take. Unlike the stock Icon Element, the `icon` parameter is optional for individual menu items; if unspecified no icon is displayed for that menu item.|
See the [Configuring a Submenu example](#configuring-a-submenu-example).
<a name="frigate-card-conditional"></a> <a name="frigate-card-conditional"></a>
#### `custom:frigate-card-conditional` #### `custom:frigate-card-conditional`
@@ -538,6 +552,12 @@ This card supports full editing via the Lovelace card editor. Additional arbitra
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/main/images/editor.png" alt="Live viewing" width="400px"> <img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/main/images/editor.png" alt="Live viewing" width="400px">
## Configurable Submenus
This card supports fully configurable submenus.
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/main/images/submenu.gif" alt="Configurable submenus" width="400px">
## Examples ## Examples
### WebRTC ### WebRTC
@@ -614,7 +634,7 @@ elements:
### Adding State Badges ### Adding State Badges
You can adds a state badge to the card showing arbitrary entity states. You can add a state badge to the card showing arbitrary entity states.
<details> <details>
<summary>Expand: State badge</summary> <summary>Expand: State badge</summary>
@@ -810,6 +830,42 @@ menu:
</details> </details>
<a name="configuring-a-submenu-example"></a>
### Configuring a submenu
You can add submenus to the menu -- buttons that when pressed reveal a dropdown submenu of configurable options.
<details>
<summary>Expand: Adding a submenu</summary>
This example shows a submenu that illustrates a variety of actions.
```yaml
[...]
elements:
- type: custom:frigate-card-menu-submenu
icon: mdi:menu
items:
- title: Lights
icon: mdi:lightbulb
entity: light.office_main_lights
tap_action:
action: toggle
- title: Google
icon: mdi:google
tap_action:
action: url
url_path: https://www.google.com
- title: Fullscreen
icon: mdi:fullscreen
tap_action:
action: custom:frigate-card-action
frigate_card_action: fullscreen
```
</details>
<a name="card-updates"></a> <a name="card-updates"></a>
## Card Refreshes / Updates ## Card Refreshes / Updates
Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

+1
View File
@@ -17,6 +17,7 @@
"dependencies": { "dependencies": {
"@cycjimmy/jsmpeg-player": "^5.0.1", "@cycjimmy/jsmpeg-player": "^5.0.1",
"@material/image-list": "^12.0.0", "@material/image-list": "^12.0.0",
"@material/rtl": "^13.0.0",
"custom-card-helpers": "^1.8.0", "custom-card-helpers": "^1.8.0",
"dayjs": "^1.10.7", "dayjs": "^1.10.7",
"dlv": "github:developit/dlv", "dlv": "github:developit/dlv",
+4
View File
@@ -9,6 +9,7 @@ import {
MenuIcon, MenuIcon,
MenuStateIcon, MenuStateIcon,
PictureElements, PictureElements,
MenuSubmenu,
} from '../types.js'; } from '../types.js';
import { import {
dispatchErrorMessageEvent, dispatchErrorMessageEvent,
@@ -339,3 +340,6 @@ export class FrigateCardElementsMenuIcon extends FrigateCardElementsBaseMenuIcon
@customElement('frigate-card-menu-state-icon') @customElement('frigate-card-menu-state-icon')
export class FrigateCardElementsMenuStateIcon extends FrigateCardElementsBaseMenuIcon<MenuStateIcon> {} export class FrigateCardElementsMenuStateIcon extends FrigateCardElementsBaseMenuIcon<MenuStateIcon> {}
@customElement('frigate-card-menu-submenu')
export class FrigateCardElementsMenuSubmenu extends FrigateCardElementsBaseMenuIcon<MenuSubmenu> {}
+32 -7
View File
@@ -14,7 +14,10 @@ import { StyleInfo, styleMap } from 'lit/directives/style-map.js';
import { actionHandler } from '../action-handler-directive.js'; import { actionHandler } from '../action-handler-directive.js';
import './submenu.js';
import type { import type {
Actions,
ExtendedHomeAssistant, ExtendedHomeAssistant,
MenuButton, MenuButton,
MenuConfig, MenuConfig,
@@ -61,18 +64,29 @@ export class FrigateCardMenu extends LitElement {
* @param ev The action event. * @param ev The action event.
* @param button The button configuration. * @param button The button configuration.
*/ */
protected _actionHandler(ev: CustomEvent, button: MenuButton): void { protected _actionHandler(
ev: CustomEvent<{ action: string; config?: Actions }>,
config?: Actions,
): void {
if (!ev) { if (!ev) {
return; return;
} }
// If the event itself contains a configuration then use that. This is
// useful in cases where the registration of the event handler does not have
// access to the actual desired configuration (e.g. action events generated
// by a submenu).
if (ev.detail.config) {
config = ev.detail.config;
}
// These interactions should only be handled by the card, as nothing // These interactions should only be handled by the card, as nothing
// upstream has the user-provided configuration. // upstream has the user-provided configuration.
ev.stopPropagation(); ev.stopPropagation();
const interaction: string = ev.detail.action; const interaction: string = ev.detail.action;
const action = getActionConfigGivenAction(interaction, button); const action = getActionConfigGivenAction(interaction, config);
if (!action || !interaction) { if (!config || !action || !interaction) {
return; return;
} }
@@ -92,7 +106,7 @@ export class FrigateCardMenu extends LitElement {
// Collapse menu after the user clicks on something. // Collapse menu after the user clicks on something.
this.expand = false; this.expand = false;
handleAction(this, this.hass as HomeAssistant, button, interaction); handleAction(this, this.hass as HomeAssistant, config, interaction);
} }
/** /**
@@ -134,10 +148,18 @@ export class FrigateCardMenu extends LitElement {
* @returns A rendered template or void. * @returns A rendered template or void.
*/ */
protected _renderButton(button: MenuButton): TemplateResult | void { protected _renderButton(button: MenuButton): TemplateResult | void {
if (button.type == 'custom:frigate-card-menu-submenu') {
return html` <frigate-card-submenu
.submenu=${button}
@action=${this._actionHandler.bind(this)}
>
</frigate-card-submenu>`;
}
let state: HassEntity | null = null; let state: HassEntity | null = null;
let title = button.title; let title = button.title;
let icon = button.icon; let icon = button.icon;
let style = ('style' in button ? button.style : {}) || {}; let style = button.style || {};
if (icon == FRIGATE_BUTTON_MENU_ICON) { if (icon == FRIGATE_BUTTON_MENU_ICON) {
icon = icon =
@@ -199,7 +221,10 @@ export class FrigateCardMenu extends LitElement {
} }
const mode = this._menuConfig.mode; const mode = this._menuConfig.mode;
if (mode == 'none' || !evaluateCondition(this._menuConfig.conditions, this.conditionState)) { if (
mode == 'none' ||
!evaluateCondition(this._menuConfig.conditions, this.conditionState)
) {
return; return;
} }
@@ -232,7 +257,7 @@ export class FrigateCardMenu extends LitElement {
/** /**
* Get styles. * Get styles.
*/ */
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return unsafeCSS(menuStyle); return unsafeCSS(menuStyle);
} }
} }
+68
View File
@@ -0,0 +1,68 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators';
import { actionHandler } from '../action-handler-directive.js';
import { MenuSubmenu } from '../types.js';
import submenuStyle from '../scss/submenu.scss';
import { hasAction } from 'custom-card-helpers';
import { styleMap } from 'lit/directives/style-map';
@customElement('frigate-card-submenu')
export class FrigateCardSubmenu extends LitElement {
@property({ attribute: false })
public submenu?: MenuSubmenu;
protected render(): TemplateResult {
if (!this.submenu) {
return html``;
}
return html`
<ha-button-menu corner="BOTTOM_LEFT">
<ha-icon-button
style="${styleMap(this.submenu.style || {})}"
class="button"
slot="trigger"
.label=${this.submenu.title || ''}
.actionHandler=${actionHandler({
hasHold: hasAction(this.submenu.hold_action),
hasDoubleClick: hasAction(this.submenu.double_tap_action),
})}
>
<ha-icon icon="${this.submenu.icon}"></ha-icon>
</ha-icon-button>
${this.submenu.items.map(
(item) => html`
<mwc-list-item
style="${styleMap(item.style || {})}"
graphic="icon"
aria-label="${item.title || ''}"
@action=${(ev) => {
// Attach the action config so ascendants have access to it.
ev.detail.config = item;
}}
.actionHandler=${actionHandler({
hasHold: hasAction(item.hold_action),
hasDoubleClick: hasAction(item.double_tap_action),
})}
>
${item.title || ''}
${item.icon
? html` <ha-icon
style="${styleMap(item.style || {})}"
slot="graphic"
icon="${item.icon}"
>
</ha-icon>`
: ``}
</mwc-list-item>
`,
)}
</ha-button-menu>
`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(submenuStyle);
}
}
+3 -1
View File
@@ -61,7 +61,9 @@ ha-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin: auto; margin: auto;
overflow: hidden;
// Some elements (such as menus) may need to extend beyond the card boundary.
overflow: visible;
width: 100%; width: 100%;
height: 100%; height: 100%;
position: relative; position: relative;
+39 -3
View File
@@ -8,35 +8,71 @@
.frigate-card-menu { .frigate-card-menu {
z-index: 1; z-index: 1;
height: calc(var(--frigate-card-menu-button-size) + 6px);
overflow: hidden;
/* Menu div itself does not handle click events. Without this, in overlay /* Menu div itself does not handle click events. Without this, in overlay
mode, the menu div prevents clicking on gallery items 'behind' the overlay. mode, the menu div prevents clicking on gallery items 'behind' the overlay.
*/ */
pointer-events: none; pointer-events: none;
display: flex;
flex-direction: row;
flex-wrap: wrap;
} }
.frigate-card-menu.overlay-hidden { .frigate-card-menu.overlay-hidden {
position: absolute; position: absolute;
overflow: hidden;
width: calc(var(--frigate-card-menu-button-size) + 6px); width: calc(var(--frigate-card-menu-button-size) + 6px);
height: calc(var(--frigate-card-menu-button-size) + 6px);
} }
.frigate-card-menu.overlay-hidden.left, .frigate-card-menu.overlay-hidden.top { .frigate-card-menu.overlay-hidden.left {
left: 0px;
top: 0px;
// Awful hack: Flexbox column wrapping doesn't work properly in most major
// browsers -- the element boundary does not expand to cover the full wrapped
// content as it should. This results in the wrapped 'content' appearing
// outside the element background (the linear gradient in this case). The
// workaround is to use flex row direction for both rows & columns, and use
// vertical-lr/vertical-rl as the writing mode for columns -- resetting
// writing-mode for descendant elements.
//
// For more information see the Chromium bug as an example:
// - https://bugs.chromium.org/p/chromium/issues/detail?id=507397
writing-mode: vertical-lr;
}
.frigate-card-menu.overlay-hidden.top {
left: 0px; left: 0px;
top: 0px; top: 0px;
} }
.frigate-card-menu.overlay-hidden.right { .frigate-card-menu.overlay-hidden.right {
right: 0px; right: 0px;
top: 0px; top: 0px;
// See "Awful hack" above.
writing-mode: vertical-rl;
} }
.frigate-card-menu.overlay-hidden.left > *,.frigate-card-menu.overlay-hidden.right > * {
// See "Awful hack" above.
writing-mode: horizontal-tb;
}
.frigate-card-menu.overlay-hidden.bottom { .frigate-card-menu.overlay-hidden.bottom {
left: 0px; left: 0px;
bottom: 0px; bottom: 0px;
// If the menu has more content that allows, "wrap upwards" to keep the
// Frigate button in the same place.
flex-wrap: wrap-reverse;
} }
.frigate-card-menu.overlay-hidden.expanded-horizontal { .frigate-card-menu.overlay-hidden.expanded-horizontal {
width: 100%; width: 100%;
height: auto;
overflow: visible;
background: linear-gradient(90deg, rgba(0,0,0,0.3), rgba(0,0,0,0)); background: linear-gradient(90deg, rgba(0,0,0,0.3), rgba(0,0,0,0));
} }
.frigate-card-menu.overlay-hidden.expanded-vertical { .frigate-card-menu.overlay-hidden.expanded-vertical {
height: 100%; height: 100%;
width: auto;
overflow: visible;
background: linear-gradient(180deg, rgba(0,0,0,0.3), rgba(0,0,0,0)); background: linear-gradient(180deg, rgba(0,0,0,0.3), rgba(0,0,0,0));
} }
/* Full above/below menu */ /* Full above/below menu */
+6
View File
@@ -0,0 +1,6 @@
@use './button.scss';
:host {
z-index: 20;
pointer-events: auto;
}
+16 -2
View File
@@ -198,7 +198,7 @@ const serviceCallButtonSchema = elementsBaseSchema.merge(
}), }),
); );
// https://www.home-assistant.io/lovelace/picture-elements/#icon // https://www.home-assistant.io/lovelace/picture-elements/#icon-element
const iconSchema = elementsBaseSchema.merge( const iconSchema = elementsBaseSchema.merge(
z.object({ z.object({
type: z.literal('icon'), type: z.literal('icon'),
@@ -269,6 +269,15 @@ export const menuStateIconSchema = stateIconSchema.merge(
); );
export type MenuStateIcon = z.infer<typeof menuStateIconSchema>; export type MenuStateIcon = z.infer<typeof menuStateIconSchema>;
export const menuSubmenuSchema = iconSchema.merge(
z.object({
type: z.literal('custom:frigate-card-menu-submenu'),
// Menu items don't strictly require their own icon.
items: iconSchema.omit({ type: true }).partial({ icon: true }).array(),
}),
);
export type MenuSubmenu = z.infer<typeof menuSubmenuSchema>;
const frigateCardConditionSchema = z.object({ const frigateCardConditionSchema = z.object({
view: z.string().array().optional(), view: z.string().array().optional(),
fullscreen: z.boolean().optional(), fullscreen: z.boolean().optional(),
@@ -285,6 +294,7 @@ export type FrigateConditional = z.infer<typeof frigateConditionalSchema>;
const pictureElementSchema = z.union([ const pictureElementSchema = z.union([
menuStateIconSchema, menuStateIconSchema,
menuIconSchema, menuIconSchema,
menuSubmenuSchema,
frigateConditionalSchema, frigateConditionalSchema,
stateBadgeIconSchema, stateBadgeIconSchema,
stateIconSchema, stateIconSchema,
@@ -586,7 +596,11 @@ export const frigateCardConfigDefaults = {
event_viewer: viewerConfigDefault, event_viewer: viewerConfigDefault,
}; };
const menuButtonSchema = z.union([menuIconSchema, menuStateIconSchema]); const menuButtonSchema = z.union([
menuIconSchema,
menuStateIconSchema,
menuSubmenuSchema,
]);
export type MenuButton = z.infer<typeof menuButtonSchema>; export type MenuButton = z.infer<typeof menuButtonSchema>;
export interface ExtendedHomeAssistant { export interface ExtendedHomeAssistant {
hassUrl(path?): string; hassUrl(path?): string;