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 Live viewing +## Configurable Submenus + +This card supports fully configurable submenus. + +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;