diff --git a/README.md b/README.md
index b10c0ac2..febc27fe 100644
--- a/README.md
+++ b/README.md
@@ -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-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-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).
+
#### `custom:frigate-card-conditional`
@@ -538,6 +552,12 @@ This card supports full editing via the Lovelace card editor. Additional arbitra
+## Configurable Submenus
+
+This card supports fully configurable submenus.
+
+
+
## Examples
### WebRTC
@@ -614,7 +634,7 @@ elements:
### 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.
Expand: State badge
@@ -810,6 +830,42 @@ menu:
+
+
+### Configuring a submenu
+
+You can add submenus to the menu -- buttons that when pressed reveal a dropdown submenu of configurable options.
+
+
+ Expand: Adding a submenu
+
+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
+```
+
+
+
## Card Refreshes / Updates
diff --git a/images/submenu.gif b/images/submenu.gif
new file mode 100644
index 00000000..b0e3c532
Binary files /dev/null and b/images/submenu.gif differ
diff --git a/package.json b/package.json
index 1b2e1a84..5a077cf0 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"dependencies": {
"@cycjimmy/jsmpeg-player": "^5.0.1",
"@material/image-list": "^12.0.0",
+ "@material/rtl": "^13.0.0",
"custom-card-helpers": "^1.8.0",
"dayjs": "^1.10.7",
"dlv": "github:developit/dlv",
diff --git a/src/components/elements.ts b/src/components/elements.ts
index 4422b4dd..7d336989 100644
--- a/src/components/elements.ts
+++ b/src/components/elements.ts
@@ -9,6 +9,7 @@ import {
MenuIcon,
MenuStateIcon,
PictureElements,
+ MenuSubmenu,
} from '../types.js';
import {
dispatchErrorMessageEvent,
@@ -339,3 +340,6 @@ export class FrigateCardElementsMenuIcon extends FrigateCardElementsBaseMenuIcon
@customElement('frigate-card-menu-state-icon')
export class FrigateCardElementsMenuStateIcon extends FrigateCardElementsBaseMenuIcon {}
+
+@customElement('frigate-card-menu-submenu')
+export class FrigateCardElementsMenuSubmenu extends FrigateCardElementsBaseMenuIcon {}
\ No newline at end of file
diff --git a/src/components/menu.ts b/src/components/menu.ts
index 0b8bd3ef..d3398512 100644
--- a/src/components/menu.ts
+++ b/src/components/menu.ts
@@ -14,7 +14,10 @@ import { StyleInfo, styleMap } from 'lit/directives/style-map.js';
import { actionHandler } from '../action-handler-directive.js';
+import './submenu.js';
+
import type {
+ Actions,
ExtendedHomeAssistant,
MenuButton,
MenuConfig,
@@ -61,18 +64,29 @@ export class FrigateCardMenu extends LitElement {
* @param ev The action event.
* @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) {
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
// upstream has the user-provided configuration.
ev.stopPropagation();
const interaction: string = ev.detail.action;
- const action = getActionConfigGivenAction(interaction, button);
- if (!action || !interaction) {
+ const action = getActionConfigGivenAction(interaction, config);
+ if (!config || !action || !interaction) {
return;
}
@@ -92,7 +106,7 @@ export class FrigateCardMenu extends LitElement {
// Collapse menu after the user clicks on something.
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.
*/
protected _renderButton(button: MenuButton): TemplateResult | void {
+ if (button.type == 'custom:frigate-card-menu-submenu') {
+ return html`
+ `;
+ }
+
let state: HassEntity | null = null;
let title = button.title;
let icon = button.icon;
- let style = ('style' in button ? button.style : {}) || {};
+ let style = button.style || {};
if (icon == FRIGATE_BUTTON_MENU_ICON) {
icon =
@@ -199,7 +221,10 @@ export class FrigateCardMenu extends LitElement {
}
const mode = this._menuConfig.mode;
- if (mode == 'none' || !evaluateCondition(this._menuConfig.conditions, this.conditionState)) {
+ if (
+ mode == 'none' ||
+ !evaluateCondition(this._menuConfig.conditions, this.conditionState)
+ ) {
return;
}
@@ -232,7 +257,7 @@ export class FrigateCardMenu extends LitElement {
/**
* Get styles.
*/
- static get styles(): CSSResultGroup {
+ static get styles(): CSSResultGroup {
return unsafeCSS(menuStyle);
}
}
diff --git a/src/components/submenu.ts b/src/components/submenu.ts
new file mode 100644
index 00000000..58293024
--- /dev/null
+++ b/src/components/submenu.ts
@@ -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`
+
+
+
+
+ ${this.submenu.items.map(
+ (item) => html`
+ {
+ // 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`
+ `
+ : ``}
+
+ `,
+ )}
+
+ `;
+ }
+
+ static get styles(): CSSResultGroup {
+ return unsafeCSS(submenuStyle);
+ }
+}
diff --git a/src/scss/card.scss b/src/scss/card.scss
index 8bf73f30..4ef20feb 100644
--- a/src/scss/card.scss
+++ b/src/scss/card.scss
@@ -61,7 +61,9 @@ ha-card {
display: flex;
flex-direction: column;
margin: auto;
- overflow: hidden;
+
+ // Some elements (such as menus) may need to extend beyond the card boundary.
+ overflow: visible;
width: 100%;
height: 100%;
position: relative;
diff --git a/src/scss/menu.scss b/src/scss/menu.scss
index 19bce486..0a76fc14 100644
--- a/src/scss/menu.scss
+++ b/src/scss/menu.scss
@@ -8,35 +8,71 @@
.frigate-card-menu {
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
mode, the menu div prevents clicking on gallery items 'behind' the overlay.
*/
pointer-events: none;
+
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
}
.frigate-card-menu.overlay-hidden {
position: absolute;
+ overflow: hidden;
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;
top: 0px;
}
.frigate-card-menu.overlay-hidden.right {
right: 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 {
left: 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 {
width: 100%;
+ height: auto;
+ overflow: visible;
background: linear-gradient(90deg, rgba(0,0,0,0.3), rgba(0,0,0,0));
}
.frigate-card-menu.overlay-hidden.expanded-vertical {
height: 100%;
+ width: auto;
+ overflow: visible;
background: linear-gradient(180deg, rgba(0,0,0,0.3), rgba(0,0,0,0));
}
/* Full above/below menu */
diff --git a/src/scss/submenu.scss b/src/scss/submenu.scss
new file mode 100644
index 00000000..90aee472
--- /dev/null
+++ b/src/scss/submenu.scss
@@ -0,0 +1,6 @@
+@use './button.scss';
+
+:host {
+ z-index: 20;
+ pointer-events: auto;
+}
\ No newline at end of file
diff --git a/src/types.ts b/src/types.ts
index b037ddc8..6355ec65 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -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(
z.object({
type: z.literal('icon'),
@@ -269,6 +269,15 @@ export const menuStateIconSchema = stateIconSchema.merge(
);
export type MenuStateIcon = z.infer;
+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;
+
const frigateCardConditionSchema = z.object({
view: z.string().array().optional(),
fullscreen: z.boolean().optional(),
@@ -285,6 +294,7 @@ export type FrigateConditional = z.infer;
const pictureElementSchema = z.union([
menuStateIconSchema,
menuIconSchema,
+ menuSubmenuSchema,
frigateConditionalSchema,
stateBadgeIconSchema,
stateIconSchema,
@@ -586,7 +596,11 @@ export const frigateCardConfigDefaults = {
event_viewer: viewerConfigDefault,
};
-const menuButtonSchema = z.union([menuIconSchema, menuStateIconSchema]);
+const menuButtonSchema = z.union([
+ menuIconSchema,
+ menuStateIconSchema,
+ menuSubmenuSchema,
+]);
export type MenuButton = z.infer;
export interface ExtendedHomeAssistant {
hassUrl(path?): string;