From ccd47d9a9d199aa49cd14cf54403b920ea8cc90a Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 26 Apr 2022 20:20:32 -0700 Subject: [PATCH] Initial skeleton menu button priority/alignment. --- src/card.ts | 16 +++++- src/components/menu.ts | 91 +++++++++++++++++++++++++++-- src/config-mgmt.ts | 35 +++++++++++ src/const.ts | 45 ++++++++------- src/editor.ts | 57 +++++++++--------- src/scss/menu.scss | 114 +++++++++++++++++++++++++----------- src/types.ts | 128 +++++++++++++++++++++++++++-------------- 7 files changed, 359 insertions(+), 127 deletions(-) diff --git a/src/card.ts b/src/card.ts index 56e0dbc1..e742d60b 100644 --- a/src/card.ts +++ b/src/card.ts @@ -285,6 +285,7 @@ export class FrigateCard extends LitElement { // Use a magic icon value that the menu will use to render the icon as // it deems appropriate (certain menu configurations change the menu // icon for the 'Frigate' button). + priority: this._getConfig().menu.buttons.frigate, icon: FRIGATE_BUTTON_MENU_ICON, tap_action: FrigateCardMenu.isHidingMenu(this._getConfig().menu) ? (createFrigateCardCustomAction('menu_toggle') as FrigateCardCustomAction) @@ -311,6 +312,7 @@ export class FrigateCard extends LitElement { buttons.push({ type: 'custom:frigate-card-menu-submenu', title: localize('config.menu.buttons.cameras'), + priority: this._getConfig().menu.buttons.cameras, icon: 'mdi:video-switch', items: menuItems, }); @@ -320,6 +322,7 @@ export class FrigateCard extends LitElement { buttons.push({ type: 'custom:frigate-card-menu-icon', title: localize('config.view.views.live'), + priority: this._getConfig().menu.buttons.live, icon: 'mdi:cctv', style: this._view?.is('live') ? this._getEmphasizedStyle() : {}, tap_action: createFrigateCardCustomAction('live') as FrigateCardCustomAction, @@ -339,6 +342,7 @@ export class FrigateCard extends LitElement { buttons.push({ type: 'custom:frigate-card-menu-icon', title: localize('config.view.views.clips'), + priority: this._getConfig().menu.buttons.clips, icon: 'mdi:filmstrip', style: this._view?.is('clips') ? this._getEmphasizedStyle() : {}, tap_action: createFrigateCardCustomAction('clips') as FrigateCardCustomAction, @@ -357,6 +361,7 @@ export class FrigateCard extends LitElement { buttons.push({ type: 'custom:frigate-card-menu-icon', title: localize('config.view.views.snapshots'), + priority: this._getConfig().menu.buttons.snapshots, icon: 'mdi:camera', style: this._view?.is('snapshots') ? this._getEmphasizedStyle() : {}, tap_action: createFrigateCardCustomAction( @@ -372,6 +377,7 @@ export class FrigateCard extends LitElement { buttons.push({ type: 'custom:frigate-card-menu-icon', title: localize('config.view.views.image'), + priority: this._getConfig().menu.buttons.image, icon: 'mdi:image', style: this._view?.is('image') ? this._getEmphasizedStyle() : {}, tap_action: createFrigateCardCustomAction('image') as FrigateCardCustomAction, @@ -382,6 +388,7 @@ export class FrigateCard extends LitElement { buttons.push({ type: 'custom:frigate-card-menu-icon', title: localize('config.view.views.timeline'), + priority: this._getConfig().menu.buttons.timeline, icon: 'mdi:chart-gantt', style: this._view?.is('timeline') ? this._getEmphasizedStyle() : {}, tap_action: createFrigateCardCustomAction('timeline') as FrigateCardCustomAction, @@ -395,6 +402,7 @@ export class FrigateCard extends LitElement { buttons.push({ type: 'custom:frigate-card-menu-icon', title: localize('config.menu.buttons.download'), + priority: this._getConfig().menu.buttons.download, icon: 'mdi:download', tap_action: createFrigateCardCustomAction('download') as FrigateCardCustomAction, }); @@ -404,6 +412,7 @@ export class FrigateCard extends LitElement { buttons.push({ type: 'custom:frigate-card-menu-icon', title: localize('config.menu.buttons.frigate_ui'), + priority: this._getConfig().menu.buttons.frigate_ui, icon: 'mdi:web', tap_action: createFrigateCardCustomAction( 'frigate_ui', @@ -415,6 +424,7 @@ export class FrigateCard extends LitElement { buttons.push({ type: 'custom:frigate-card-menu-icon', title: localize('config.menu.buttons.fullscreen'), + priority: this._getConfig().menu.buttons.fullscreen, icon: screenfull.isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen', tap_action: createFrigateCardCustomAction( 'fullscreen', @@ -489,7 +499,11 @@ export class FrigateCard extends LitElement { }; if (this._getConfig().cameras && Array.isArray(this._getConfig().cameras)) { - await Promise.all(this._getConfig().cameras.map(addCameraConfig.bind(this))); + // Cameras are loaded sequentially rather than in parallel to preserve the + // order of the input camera array. + for (const camera of this._getConfig().cameras) { + await addCameraConfig(camera); + } } if (!cameras.size) { diff --git a/src/components/menu.ts b/src/components/menu.ts index 98b4a8d5..e611a3b3 100644 --- a/src/components/menu.ts +++ b/src/components/menu.ts @@ -1,4 +1,11 @@ -import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; +import { + CSSResultGroup, + LitElement, + TemplateResult, + html, + unsafeCSS, + PropertyValues, +} from 'lit'; import { HASSDomEvent, HomeAssistant } from 'custom-card-helpers'; import { customElement, property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; @@ -14,6 +21,7 @@ import type { ActionType, MenuButton, MenuConfig, + MenuItem, StateParameters, } from '../types.js'; import { @@ -198,6 +206,61 @@ export class FrigateCardMenu extends LitElement { } } + /** + * Ensure menu buttons are sorted before the render. + * @param changedProps The changed properties + */ + protected willUpdate(changedProps: PropertyValues): void { + const style = this._menuConfig?.style; + const sortButtons = (a: MenuItem, b: MenuItem): number => { + // If the menu is hidden, the Frigate button must come first. + if (style === 'hidden') { + if (a.icon === FRIGATE_BUTTON_MENU_ICON) { + return -1; + } else if (b.icon === FRIGATE_BUTTON_MENU_ICON) { + return 1; + } + } + + // TODO below might be removable? + + // If the alignments are different, sort 'opposite' alignments later. + if (a.alignment !== b.alignment) { + if ( + (a.alignment === undefined || a.alignment === 'near') && + b.alignment === 'far' + ) { + return -1; + } + if ( + (b.alignment === undefined || b.alignment === 'near') && + a.alignment === 'far' + ) { + return 1; + } + } + + // Otherwise sort by priority. + if ( + a.priority === undefined || + (b.priority !== undefined && b.priority > a.priority) + ) { + return 1; + } + if ( + b.priority === undefined || + (a.priority !== undefined && b.priority < a.priority) + ) { + return -1; + } + return 0; + }; + + if (changedProps.has('_menuConfig') || changedProps.has('buttons')) { + this.buttons.sort(sortButtons); + } + } + /** * Render a button. * @param button The button configuration to render. @@ -274,11 +337,31 @@ export class FrigateCardMenu extends LitElement { } // If the hidden menu isn't expanded, only show the Frigate button. - const buttons = + const nearButtons = style !== 'hidden' || this.expanded - ? this.buttons + ? this.buttons.filter( + (button) => !button.alignment || button.alignment === 'near', + ) : this.buttons.filter((button) => button.icon === FRIGATE_BUTTON_MENU_ICON); - return html` ${buttons.map((button) => this._renderButton(button))} `; + + const farButtons = + style !== 'hidden' || this.expanded + ? this.buttons.filter((button) => button.alignment === 'far') + : []; + + const nearStyle = { + flex: String(nearButtons.length), + }; + const farStyle = { + flex: String(farButtons.length), + }; + + return html`
+ ${nearButtons.map((button) => this._renderButton(button))} +
+
+ ${farButtons.map((button) => this._renderButton(button))} +
`; } /** diff --git a/src/config-mgmt.ts b/src/config-mgmt.ts index e476d7c0..ac681a7d 100644 --- a/src/config-mgmt.ts +++ b/src/config-mgmt.ts @@ -18,6 +18,15 @@ import { CONF_LIVE_PRELOAD, CONF_LIVE_WEBRTC_CARD, CONF_MENU, + CONF_MENU_BUTTONS_FRIGATE, + CONF_MENU_BUTTONS_CAMERAS, + CONF_MENU_BUTTONS_LIVE, + CONF_MENU_BUTTONS_CLIPS, + CONF_MENU_BUTTONS_SNAPSHOTS, + CONF_MENU_BUTTONS_IMAGE, + CONF_MENU_BUTTONS_DOWNLOAD, + CONF_MENU_BUTTONS_FRIGATE_UI, + CONF_MENU_BUTTONS_FULLSCREEN, CONF_MENU_BUTTON_SIZE, CONF_MENU_POSITION, CONF_MENU_STYLE, @@ -28,6 +37,7 @@ import { } from './const'; import { BUTTON_SIZE_MIN, + FRIGATE_MENU_PRIORITY_DEFAULT, RawFrigateCardConfig, RawFrigateCardConfigArray, THUMBNAIL_WIDTH_MAX, @@ -476,6 +486,21 @@ const upgradeMenuConditionToMenuOverride = (): (( }; }; +/** + * Transform a menu button from a boolean to a priority. + * @param value The boolean true/false for show/hide the switch. + * @returns A priority value. + */ +const menuButtonToPriority = function (value: unknown): number | null | undefined { + if (typeof value === 'number') { + return undefined; + } + if (typeof value !== 'boolean') { + return null; + } + return value ? FRIGATE_MENU_PRIORITY_DEFAULT : 0; +}; + const UPGRADES = [ // v1.2.1 -> v2.0.0 upgradeMoveTo('frigate_url', 'frigate.url'), @@ -539,4 +564,14 @@ const UPGRADES = [ ), upgradeWithOverrides('event_gallery.min_columns', deleteProperty), upgradeMenuModeToStyleAndPosition(), + + upgradeWithOverrides(CONF_MENU_BUTTONS_FRIGATE, menuButtonToPriority), + upgradeWithOverrides(CONF_MENU_BUTTONS_CAMERAS, menuButtonToPriority), + upgradeWithOverrides(CONF_MENU_BUTTONS_LIVE, menuButtonToPriority), + upgradeWithOverrides(CONF_MENU_BUTTONS_CLIPS, menuButtonToPriority), + upgradeWithOverrides(CONF_MENU_BUTTONS_SNAPSHOTS, menuButtonToPriority), + upgradeWithOverrides(CONF_MENU_BUTTONS_IMAGE, menuButtonToPriority), + upgradeWithOverrides(CONF_MENU_BUTTONS_DOWNLOAD, menuButtonToPriority), + upgradeWithOverrides(CONF_MENU_BUTTONS_FRIGATE_UI, menuButtonToPriority), + upgradeWithOverrides(CONF_MENU_BUTTONS_FULLSCREEN, menuButtonToPriority), ]; diff --git a/src/const.ts b/src/const.ts index 11ea8e81..9dc952a3 100644 --- a/src/const.ts +++ b/src/const.ts @@ -17,7 +17,8 @@ export const CONF_CAMERAS_ARRAY_TITLE = `${CONF_CAMERAS}.#.title` as const; export const CONF_CAMERAS_ARRAY_ICON = `${CONF_CAMERAS}.#.icon` as const; export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY = `${CONF_CAMERAS}.#.webrtc_card.entity` as const; -export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL = `${CONF_CAMERAS}.#.webrtc_card.url` as const; +export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL = + `${CONF_CAMERAS}.#.webrtc_card.url` as const; export const CONF_CAMERAS_ARRAY_LIVE_PROVIDER = `${CONF_CAMERAS}.#.live_provider` as const; export const CONF_CAMERAS_ARRAY_DEPENDENT_CAMERAS = @@ -35,9 +36,9 @@ export const CONF_VIEW_UPDATE_SECONDS = `${CONF_VIEW}.update_seconds` as const; export const CONF_EVENT_GALLERY = 'event_gallery' as const; export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS = -`${CONF_EVENT_GALLERY}.controls.thumbnails.show_details` as const; + `${CONF_EVENT_GALLERY}.controls.thumbnails.show_details` as const; export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_CONTROLS = -`${CONF_EVENT_GALLERY}.controls.thumbnails.show_controls` as const; + `${CONF_EVENT_GALLERY}.controls.thumbnails.show_controls` as const; export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SIZE = `${CONF_EVENT_GALLERY}.controls.thumbnails.size` as const; @@ -55,9 +56,9 @@ export const CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE = export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_MODE = `${CONF_EVENT_VIEWER}.controls.thumbnails.mode` as const; export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS = -`${CONF_EVENT_VIEWER}.controls.thumbnails.show_details` as const; + `${CONF_EVENT_VIEWER}.controls.thumbnails.show_details` as const; export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SHOW_CONTROLS = -`${CONF_EVENT_VIEWER}.controls.thumbnails.show_controls` as const; + `${CONF_EVENT_VIEWER}.controls.thumbnails.show_controls` as const; export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE = `${CONF_EVENT_VIEWER}.controls.thumbnails.size` as const; export const CONF_EVENT_VIEWER_CONTROLS_TITLE_MODE = @@ -78,9 +79,9 @@ export const CONF_LIVE_CONTROLS_THUMBNAILS_MODE = export const CONF_LIVE_CONTROLS_THUMBNAILS_SIZE = `${CONF_LIVE}.controls.thumbnails.size` as const; export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS = -`${CONF_LIVE}.controls.thumbnails.show_details` as const; + `${CONF_LIVE}.controls.thumbnails.show_details` as const; export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_CONTROLS = -`${CONF_LIVE}.controls.thumbnails.show_controls` as const; + `${CONF_LIVE}.controls.thumbnails.show_controls` as const; export const CONF_LIVE_CONTROLS_TITLE_MODE = `${CONF_LIVE}.controls.title.mode` as const; export const CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS = `${CONF_LIVE}.controls.title.duration_seconds` as const; @@ -99,29 +100,33 @@ export const CONF_IMAGE_URL = `${CONF_IMAGE}.url` as const; export const CONF_TIMELINE = 'timeline' as const; export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as const; -export const CONF_TIMELINE_CLUSTERING_THRESHOLD = `${CONF_TIMELINE}.clustering_threshold` as const; +export const CONF_TIMELINE_CLUSTERING_THRESHOLD = + `${CONF_TIMELINE}.clustering_threshold` as const; export const CONF_TIMELINE_MEDIA = `${CONF_TIMELINE}.media` as const; -export const CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE = `${CONF_TIMELINE}.controls.thumbnails.mode` as const; -export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE = `${CONF_TIMELINE}.controls.thumbnails.size` as const; -export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS = `${CONF_TIMELINE}.controls.thumbnails.show_details` as const; +export const CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE = + `${CONF_TIMELINE}.controls.thumbnails.mode` as const; +export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE = + `${CONF_TIMELINE}.controls.thumbnails.size` as const; +export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS = + `${CONF_TIMELINE}.controls.thumbnails.show_details` as const; export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_CONTROLS = -`${CONF_TIMELINE}.controls.thumbnails.show_controls` as const; + `${CONF_TIMELINE}.controls.thumbnails.show_controls` as const; export const CONF_MENU = 'menu' as const; export const CONF_MENU_ALIGNMENT = `${CONF_MENU}.alignment` as const; export const CONF_MENU_POSITION = `${CONF_MENU}.position` as const; export const CONF_MENU_STYLE = `${CONF_MENU}.style` as const; +export const CONF_MENU_BUTTON_SIZE = `${CONF_MENU}.button_size` as const; +export const CONF_MENU_BUTTONS_CAMERAS = `${CONF_MENU}.buttons.cameras` as const; +export const CONF_MENU_BUTTONS_CLIPS = `${CONF_MENU}.buttons.clips` as const; +export const CONF_MENU_BUTTONS_DOWNLOAD = `${CONF_MENU}.buttons.download` as const; export const CONF_MENU_BUTTONS_FRIGATE = `${CONF_MENU}.buttons.frigate` as const; export const CONF_MENU_BUTTONS_FRIGATE_UI = `${CONF_MENU}.buttons.frigate_ui` as const; -export const CONF_MENU_BUTTONS_FRIGATE_FULLSCREEN = - `${CONF_MENU}.buttons.fullscreen` as const; -export const CONF_MENU_BUTTONS_FRIGATE_DOWNLOAD = - `${CONF_MENU}.buttons.download` as const; -export const CONF_MENU_BUTTONS_LIVE = `${CONF_MENU}.buttons.live` as const; -export const CONF_MENU_BUTTONS_CLIPS = `${CONF_MENU}.buttons.clips` as const; -export const CONF_MENU_BUTTONS_SNAPSHOTS = `${CONF_MENU}.buttons.snapshots` as const; +export const CONF_MENU_BUTTONS_FULLSCREEN = `${CONF_MENU}.buttons.fullscreen` as const; export const CONF_MENU_BUTTONS_IMAGE = `${CONF_MENU}.buttons.image` as const; -export const CONF_MENU_BUTTON_SIZE = `${CONF_MENU}.button_size` as const; +export const CONF_MENU_BUTTONS_LIVE = `${CONF_MENU}.buttons.live` as const; + +export const CONF_MENU_BUTTONS_SNAPSHOTS = `${CONF_MENU}.buttons.snapshots` as const; export const CONF_DIMENSIONS = 'dimensions' as const; export const CONF_DIMENSIONS_ASPECT_RATIO = `${CONF_DIMENSIONS}.aspect_ratio` as const; diff --git a/src/editor.ts b/src/editor.ts index 6fd12978..4ada56eb 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -6,6 +6,7 @@ import { HomeAssistant, LovelaceCardEditor, fireEvent } from 'custom-card-helper import { localize } from './localize/localize.js'; import { BUTTON_SIZE_MIN, + FRIGATE_MENU_PRIORITY_MAX, RawFrigateCardConfig, RawFrigateCardConfigArray, THUMBNAIL_WIDTH_MAX, @@ -67,9 +68,9 @@ import { CONF_MENU_ALIGNMENT, CONF_MENU_BUTTONS_CLIPS, CONF_MENU_BUTTONS_FRIGATE, - CONF_MENU_BUTTONS_FRIGATE_DOWNLOAD, - CONF_MENU_BUTTONS_FRIGATE_FULLSCREEN, + CONF_MENU_BUTTONS_DOWNLOAD, CONF_MENU_BUTTONS_FRIGATE_UI, + CONF_MENU_BUTTONS_FULLSCREEN, CONF_MENU_BUTTONS_IMAGE, CONF_MENU_BUTTONS_LIVE, CONF_MENU_BUTTONS_SNAPSHOTS, @@ -886,45 +887,45 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._renderOptionSelector(CONF_MENU_POSITION, this._menuPositions)} ${this._renderOptionSelector(CONF_MENU_ALIGNMENT, this._menuAlignments)} ${this._renderNumberInput(CONF_MENU_BUTTON_SIZE, BUTTON_SIZE_MIN)} - ${this._renderSwitch( + ${this._renderNumberInput( CONF_MENU_BUTTONS_FRIGATE, - defaults.menu.buttons.frigate, - getShowButtonLabel(CONF_MENU_BUTTONS_FRIGATE), + 0, + FRIGATE_MENU_PRIORITY_MAX, )} - ${this._renderSwitch( + ${this._renderNumberInput( CONF_MENU_BUTTONS_LIVE, - defaults.menu.buttons.live, - getShowButtonLabel('view.views.live'), + 0, + FRIGATE_MENU_PRIORITY_MAX, )} - ${this._renderSwitch( + ${this._renderNumberInput( CONF_MENU_BUTTONS_CLIPS, - defaults.menu.buttons.clips, - getShowButtonLabel('view.views.clips'), + 0, + FRIGATE_MENU_PRIORITY_MAX, )} - ${this._renderSwitch( + ${this._renderNumberInput( CONF_MENU_BUTTONS_SNAPSHOTS, - defaults.menu.buttons.snapshots, - getShowButtonLabel('view.views.snapshots'), + 0, + FRIGATE_MENU_PRIORITY_MAX, )} - ${this._renderSwitch( + ${this._renderNumberInput( CONF_MENU_BUTTONS_IMAGE, - defaults.menu.buttons.image, - getShowButtonLabel('view.views.image'), + 0, + FRIGATE_MENU_PRIORITY_MAX, )} - ${this._renderSwitch( - CONF_MENU_BUTTONS_FRIGATE_DOWNLOAD, - defaults.menu.buttons.download, - getShowButtonLabel(CONF_MENU_BUTTONS_FRIGATE_DOWNLOAD), + ${this._renderNumberInput( + CONF_MENU_BUTTONS_DOWNLOAD, + 0, + FRIGATE_MENU_PRIORITY_MAX, )} - ${this._renderSwitch( + ${this._renderNumberInput( CONF_MENU_BUTTONS_FRIGATE_UI, - defaults.menu.buttons.frigate_ui, - getShowButtonLabel(CONF_MENU_BUTTONS_FRIGATE_UI), + 0, + FRIGATE_MENU_PRIORITY_MAX, )} - ${this._renderSwitch( - CONF_MENU_BUTTONS_FRIGATE_FULLSCREEN, - defaults.menu.buttons.fullscreen, - getShowButtonLabel(CONF_MENU_BUTTONS_FRIGATE_FULLSCREEN), + ${this._renderNumberInput( + CONF_MENU_BUTTONS_FULLSCREEN, + 0, + FRIGATE_MENU_PRIORITY_MAX, )} ` diff --git a/src/scss/menu.scss b/src/scss/menu.scss index 73c5fd09..4a05520f 100644 --- a/src/scss/menu.scss +++ b/src/scss/menu.scss @@ -11,44 +11,77 @@ display: flex; flex-direction: row; - flex-wrap: wrap; + justify-content: space-between; } +/************************** + * Aligned divs: near & far + **************************/ + +div.near, +div.far { + display: flex; + flex-wrap: wrap; + flex-direction: row; + + // Allow the divs to be resized. + min-width: 0px; + min-height: 0px; +} +div.near { + justify-content: flex-start; +} +div.far { + justify-content: flex-end; +} + +/******************** + * Outside menu style + ********************/ + :host([data-style='outside']) { width: 100%; background: var(--secondary-background-color); } -:host([data-mode='outside'][data-position='top']) { +:host([data-style='outside'][data-position='top']) { border-top-left-radius: var(--ha-card-border-radius, 4px); border-top-right-radius: var(--ha-card-border-radius, 4px); } -:host([data-mode='outside'][data-position='below']) { +:host([data-style='outside']:not([data-position='top'])) { border-bottom-left-radius: var(--ha-card-border-radius, 4px); border-bottom-right-radius: var(--ha-card-border-radius, 4px); } -:host([data-position='top']), -:host([data-position='left'][data-alignment='top']), -:host([data-position='right'][data-alignment='top']) { +/************************************** + * Positioning for absolute menu styles + **************************************/ + +:host(:not([data-style='outside'])[data-position='top']), +:host(:not([data-style='outside'])[data-position='left'][data-alignment='top']), +:host(:not([data-style='outside'])[data-position='right'][data-alignment='top']) { top: 0px; } -:host([data-position='bottom']), -:host([data-position='left'][data-alignment='bottom']), -:host([data-position='right'][data-alignment='bottom']) { +:host(:not([data-style='outside'])[data-position='bottom']), +:host(:not([data-style='outside'])[data-position='left'][data-alignment='bottom']), +:host(:not([data-style='outside'])[data-position='right'][data-alignment='bottom']) { bottom: 0px; } -:host([data-position='left']), -:host([data-position='top'][data-alignment='left']), -:host([data-position='bottom'][data-alignment='left']) { +:host(:not([data-style='outside'])[data-position='left']), +:host(:not([data-style='outside'])[data-position='top'][data-alignment='left']), +:host(:not([data-style='outside'])[data-position='bottom'][data-alignment='left']) { left: 0px; } -:host([data-position='right']), -:host([data-position='top'][data-alignment='right']), -:host([data-position='bottom'][data-alignment='right']) { +:host(:not([data-style='outside'])[data-position='right']), +:host(:not([data-style='outside'])[data-position='top'][data-alignment='right']), +:host(:not([data-style='outside'])[data-position='bottom'][data-alignment='right']) { right: 0px; } -:host([data-position='left']) { +/******************************************************** + * Hack: Ensure host & div expand for column flex layouts + ********************************************************/ + +:host(:not([data-style='outside'])[data-position='left']) { // 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 @@ -61,24 +94,51 @@ // - https://bugs.chromium.org/p/chromium/issues/detail?id=507397 writing-mode: vertical-lr; } -:host([data-position='right']) { +:host(:not([data-style='outside'])[data-position='right']) { // See "Awful hack" above. writing-mode: vertical-rl; } +:host(:not([data-style='outside'])[data-style='overlay'][data-position='left']) div > *, +:host(:not([data-style='outside'])[data-style='overlay'][data-position='right']) div > *, +:host(:not([data-style='outside'])[data-style='hover'][data-position='left']) div > *, +:host(:not([data-style='outside'])[data-style='hover'][data-position='right']) div > *, +:host(:not([data-style='outside'])[data-style='hidden'][data-position='left']) div > *, +:host(:not([data-style='outside'])[data-style='hidden'][data-position='right']) div > * { + // See "Awful hack" above. Note that this "cancelation" of writing mode only + // affects from beyond the divs, i.e. the writing mode hack applies to host + // and divs both. + writing-mode: horizontal-tb; +} -:host([data-position='left'][data-alignment='bottom']), -:host([data-position='right'][data-alignment='bottom']), -:host([data-position='top'][data-alignment='right']), -:host([data-position='bottom'][data-alignment='right']) { +/********************** + * "Reverse" alignments + **********************/ + +:host(:not([data-style='outside'])[data-position='left'][data-alignment='bottom']), +:host(:not([data-style='outside'])[data-position='right'][data-alignment='bottom']), +:host(:not([data-style='outside'])[data-position='top'][data-alignment='right']), +:host(:not([data-style='outside'])[data-position='bottom'][data-alignment='right']), +:host(:not([data-style='outside'])[data-position='left'][data-alignment='bottom']) div, +:host(:not([data-style='outside'])[data-position='right'][data-alignment='bottom']) div, +:host(:not([data-style='outside'])[data-position='top'][data-alignment='right']) div, +:host(:not([data-style='outside'])[data-position='bottom'][data-alignment='right']) div { flex-direction: row-reverse; } -:host([data-position='bottom']) { +/**************************** + * Wrap upwards on the bottom + ****************************/ + +:host(:not([data-style='outside'])[data-position='bottom']) div { // If the menu has more content that allows, "wrap upwards" to keep the // Frigate button in the same place. flex-wrap: wrap-reverse; } +/******************************************** + * Positioning for absolute based menu styles + ********************************************/ + :host([data-style='overlay']), :host([data-style='hover']), :host([data-style='hidden']) { @@ -111,13 +171,3 @@ overflow: visible; background: linear-gradient(180deg, rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0)); } - -:host([data-style='overlay'][data-position='left']) > *, -:host([data-style='overlay'][data-position='right']) > *, -:host([data-style='hover'][data-position='left']) > *, -:host([data-style='hover'][data-position='right']) > *, -:host([data-style='hidden'][data-position='left']) > *, -:host([data-style='hidden'][data-position='right']) > * { - // See "Awful hack" above. - writing-mode: horizontal-tb; -} diff --git a/src/types.ts b/src/types.ts index 17e0aa35..eb3b8046 100644 --- a/src/types.ts +++ b/src/types.ts @@ -53,21 +53,13 @@ const FRIGATE_CARD_VIEWS = [ export type FrigateCardView = typeof FRIGATE_CARD_VIEWS[number]; -const FRIGATE_MENU_STYLES = [ - 'none', - 'hidden', - 'overlay', - 'hover', - 'outside', -] as const; -const FRIGATE_MENU_POSITIONS = [ - 'left', - 'right', - 'top', - 'bottom', -] as const; +const FRIGATE_MENU_STYLES = ['none', 'hidden', 'overlay', 'hover', 'outside'] as const; +const FRIGATE_MENU_POSITIONS = ['left', 'right', 'top', 'bottom'] as const; const FRIGATE_MENU_ALIGNMENTS = FRIGATE_MENU_POSITIONS; +export const FRIGATE_MENU_PRIORITY_DEFAULT = 50; +export const FRIGATE_MENU_PRIORITY_MAX = 100; + const LIVE_PROVIDERS = ['auto', 'ha', 'frigate-jsmpeg', 'webrtc-card'] as const; export type LiveProvider = typeof LIVE_PROVIDERS[number]; @@ -336,14 +328,23 @@ export type CameraConfig = z.infer; * Custom Element Types. */ -export const menuIconSchema = iconSchema.extend({ - type: z.literal('custom:frigate-card-menu-icon'), +const menuBaseSchema = z.object({ + priority: z.number().optional(), + alignment: z.enum(["near", "far"]).optional(), }); + +export const menuIconSchema = iconSchema + .extend({ + type: z.literal('custom:frigate-card-menu-icon'), + }) + .merge(menuBaseSchema); export type MenuIcon = z.infer; -export const menuStateIconSchema = stateIconSchema.extend({ - type: z.literal('custom:frigate-card-menu-state-icon'), -}); +export const menuStateIconSchema = stateIconSchema + .extend({ + type: z.literal('custom:frigate-card-menu-state-icon'), + }) + .merge(menuBaseSchema); export type MenuStateIcon = z.infer; const menuSubmenuItemSchema = elementsBaseSchema.extend({ @@ -354,11 +355,14 @@ const menuSubmenuItemSchema = elementsBaseSchema.extend({ }); export type MenuSubmenuItem = z.infer; -export const menuSubmenuSchema = iconSchema.extend({ - type: z.literal('custom:frigate-card-menu-submenu'), - items: menuSubmenuItemSchema.array(), -}); +export const menuSubmenuSchema = iconSchema + .extend({ + type: z.literal('custom:frigate-card-menu-submenu'), + items: menuSubmenuItemSchema.array(), + }) + .merge(menuBaseSchema); export type MenuSubmenu = z.infer; +export type MenuItem = MenuIcon | MenuStateIcon | MenuSubmenu; const frigateCardConditionSchema = z.object({ view: z.string().array().optional(), @@ -617,16 +621,16 @@ const menuConfigDefault = { position: 'top' as const, alignment: 'left' as const, buttons: { - frigate: true, - cameras: true, - live: true, - clips: true, - snapshots: true, - image: false, - timeline: true, - download: true, - frigate_ui: true, - fullscreen: true, + frigate: FRIGATE_MENU_PRIORITY_DEFAULT, + cameras: FRIGATE_MENU_PRIORITY_DEFAULT, + live: FRIGATE_MENU_PRIORITY_DEFAULT, + clips: FRIGATE_MENU_PRIORITY_DEFAULT, + snapshots: FRIGATE_MENU_PRIORITY_DEFAULT, + image: 0, + timeline: FRIGATE_MENU_PRIORITY_DEFAULT, + download: FRIGATE_MENU_PRIORITY_DEFAULT, + frigate_ui: FRIGATE_MENU_PRIORITY_DEFAULT, + fullscreen: FRIGATE_MENU_PRIORITY_DEFAULT, }, button_size: 40, }; @@ -638,16 +642,56 @@ const menuConfigSchema = z alignment: z.enum(FRIGATE_MENU_ALIGNMENTS).default(menuConfigDefault.alignment), buttons: z .object({ - frigate: z.boolean().default(menuConfigDefault.buttons.frigate), - cameras: z.boolean().default(menuConfigDefault.buttons.cameras), - live: z.boolean().default(menuConfigDefault.buttons.live), - clips: z.boolean().default(menuConfigDefault.buttons.clips), - snapshots: z.boolean().default(menuConfigDefault.buttons.snapshots), - image: z.boolean().default(menuConfigDefault.buttons.image), - timeline: z.boolean().default(menuConfigDefault.buttons.timeline), - download: z.boolean().default(menuConfigDefault.buttons.download), - frigate_ui: z.boolean().default(menuConfigDefault.buttons.frigate_ui), - fullscreen: z.boolean().default(menuConfigDefault.buttons.fullscreen), + frigate: z + .number() + .min(0) + .max(FRIGATE_MENU_PRIORITY_MAX) + .default(menuConfigDefault.buttons.frigate), + cameras: z + .number() + .min(0) + .max(FRIGATE_MENU_PRIORITY_MAX) + .default(menuConfigDefault.buttons.cameras), + live: z + .number() + .min(0) + .max(FRIGATE_MENU_PRIORITY_MAX) + .default(menuConfigDefault.buttons.live), + clips: z + .number() + .min(0) + .max(FRIGATE_MENU_PRIORITY_MAX) + .default(menuConfigDefault.buttons.clips), + snapshots: z + .number() + .min(0) + .max(FRIGATE_MENU_PRIORITY_MAX) + .default(menuConfigDefault.buttons.snapshots), + image: z + .number() + .min(0) + .max(FRIGATE_MENU_PRIORITY_MAX) + .default(menuConfigDefault.buttons.image), + timeline: z + .number() + .min(0) + .max(FRIGATE_MENU_PRIORITY_MAX) + .default(menuConfigDefault.buttons.timeline), + download: z + .number() + .min(0) + .max(FRIGATE_MENU_PRIORITY_MAX) + .default(menuConfigDefault.buttons.download), + frigate_ui: z + .number() + .min(0) + .max(FRIGATE_MENU_PRIORITY_MAX) + .default(menuConfigDefault.buttons.frigate_ui), + fullscreen: z + .number() + .min(0) + .max(FRIGATE_MENU_PRIORITY_MAX) + .default(menuConfigDefault.buttons.fullscreen), }) .default(menuConfigDefault.buttons), button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size),