Compare commits

..

2 Commits

Author SHA1 Message Date
stvncode
309d703cfd review + lint 2026-01-21 12:07:15 +01:00
stvncode
c0fd022d7a Add device database labs feature 2026-01-21 11:58:28 +01:00
27 changed files with 520 additions and 1471 deletions

View File

@@ -1,19 +0,0 @@
export const startMediaProgressInterval = (
interval: number | undefined,
callback: () => void,
intervalMs = 1000
): number => {
if (interval) {
return interval;
}
return window.setInterval(callback, intervalMs);
};
export const stopMediaProgressInterval = (
interval: number | undefined
): number | undefined => {
if (interval) {
clearInterval(interval);
}
return undefined;
};

View File

@@ -1,186 +0,0 @@
import type { HaSlider } from "../../components/ha-slider";
interface VolumeSliderControllerOptions {
getSlider: () => HaSlider | undefined;
step: number;
onSetVolume: (value: number) => void;
onSetVolumeDebounced?: (value: number) => void;
onValueUpdated?: (value: number) => void;
}
export class VolumeSliderController {
private _touchStartX = 0;
private _touchStartY = 0;
private _touchStartValue = 0;
private _touchDragging = false;
private _touchScrolling = false;
private _dragging = false;
private _lastValue = 0;
private _options: VolumeSliderControllerOptions;
constructor(options: VolumeSliderControllerOptions) {
this._options = options;
}
public get isInteracting(): boolean {
return this._touchDragging || this._dragging;
}
public setStep(step: number): void {
this._options.step = step;
}
public handleInput = (ev: Event): void => {
ev.stopPropagation();
const value = Number((ev.target as HaSlider).value);
this._dragging = true;
this._updateValue(value);
this._options.onSetVolumeDebounced?.(value);
};
public handleChange = (ev: Event): void => {
ev.stopPropagation();
const value = Number((ev.target as HaSlider).value);
this._dragging = false;
this._updateValue(value);
this._options.onSetVolume(value);
};
public handleTouchStart = (ev: TouchEvent): void => {
ev.stopPropagation();
const touch = ev.touches[0];
this._touchStartX = touch.clientX;
this._touchStartY = touch.clientY;
this._touchStartValue = this._getSliderValue();
this._touchDragging = false;
this._touchScrolling = false;
this._showTooltip();
};
public handleTouchMove = (ev: TouchEvent): void => {
if (this._touchScrolling) {
return;
}
const touch = ev.touches[0];
const deltaX = touch.clientX - this._touchStartX;
const deltaY = touch.clientY - this._touchStartY;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (!this._touchDragging) {
if (absDeltaY > 10 && absDeltaY > absDeltaX * 2) {
this._touchScrolling = true;
return;
}
if (absDeltaX > 8) {
this._touchDragging = true;
}
}
if (this._touchDragging) {
ev.preventDefault();
const newValue = this._getVolumeFromTouch(touch.clientX);
this._updateValue(newValue);
}
};
public handleTouchEnd = (ev: TouchEvent): void => {
if (this._touchScrolling) {
this._touchScrolling = false;
this._hideTooltip();
return;
}
const touch = ev.changedTouches[0];
if (!this._touchDragging) {
const tapValue = this._getVolumeFromTouch(touch.clientX);
const delta =
tapValue > this._touchStartValue
? this._options.step
: -this._options.step;
const newValue = this._roundVolumeValue(this._touchStartValue + delta);
this._updateValue(newValue);
this._options.onSetVolume(newValue);
} else {
const finalValue = this._getVolumeFromTouch(touch.clientX);
this._updateValue(finalValue);
this._options.onSetVolume(finalValue);
}
this._touchDragging = false;
this._dragging = false;
this._hideTooltip();
};
public handleTouchCancel = (): void => {
this._touchDragging = false;
this._touchScrolling = false;
this._dragging = false;
this._updateValue(this._touchStartValue);
this._hideTooltip();
};
public handleWheel = (ev: WheelEvent): void => {
ev.preventDefault();
ev.stopPropagation();
const direction = ev.deltaY > 0 ? -1 : 1;
const currentValue = this._getSliderValue();
const newValue = this._roundVolumeValue(
currentValue + direction * this._options.step
);
this._updateValue(newValue);
this._options.onSetVolume(newValue);
};
private _getVolumeFromTouch(clientX: number): number {
const slider = this._options.getSlider();
if (!slider) {
return 0;
}
const rect = slider.getBoundingClientRect();
const x = Math.min(Math.max(clientX - rect.left, 0), rect.width);
const percentage = (x / rect.width) * 100;
return this._roundVolumeValue(percentage);
}
private _roundVolumeValue(value: number): number {
return Math.min(
Math.max(Math.round(value / this._options.step) * this._options.step, 0),
100
);
}
private _getSliderValue(): number {
const slider = this._options.getSlider();
if (slider) {
return Number(slider.value);
}
return this._lastValue;
}
private _updateValue(value: number): void {
this._lastValue = value;
this._options.onValueUpdated?.(value);
const slider = this._options.getSlider();
if (slider) {
slider.value = value;
}
}
private _showTooltip(): void {
const slider = this._options.getSlider() as any;
slider?.showTooltip?.();
}
private _hideTooltip(): void {
const slider = this._options.getSlider() as any;
slider?.hideTooltip?.();
}
}

View File

@@ -423,17 +423,12 @@ export const formatMediaTime = (seconds: number | undefined): string => {
return "";
}
const totalSeconds = Math.max(0, Math.floor(seconds));
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const secs = totalSeconds % 60;
const pad = (value: number) => value.toString().padStart(2, "0");
if (hours > 0) {
return `${pad(hours)}:${pad(minutes)}:${pad(secs)}`;
}
return `${pad(minutes)}:${pad(secs)}`;
let secondsString = new Date(seconds * 1000).toISOString();
secondsString =
seconds > 3600
? secondsString.substring(11, 16)
: secondsString.substring(14, 19);
return secondsString.replace(/^0+/, "").padStart(4, "0");
};
export const cleanupMediaTitle = (title?: string): string | undefined => {

View File

@@ -14,14 +14,9 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { formatDurationDigital } from "../../../common/datetime/format_duration";
import { stateActive } from "../../../common/entity/state_active";
import { supportsFeature } from "../../../common/entity/supports-feature";
import { debounce } from "../../../common/util/debounce";
import {
startMediaProgressInterval,
stopMediaProgressInterval,
} from "../../../common/util/media-progress";
import { VolumeSliderController } from "../../../common/util/volume-slider";
import "../../../components/chips/ha-assist-chip";
import "../../../components/ha-button";
import "../../../components/ha-icon-button";
@@ -43,7 +38,6 @@ import {
cleanupMediaTitle,
computeMediaControls,
computeMediaDescription,
formatMediaTime,
handleMediaControlClick,
MediaPlayerEntityFeature,
mediaPlayerPlayMedia,
@@ -60,34 +54,6 @@ class MoreInfoMediaPlayer extends LitElement {
@query("#position-slider")
private _positionSlider?: HaSlider;
@query(".volume-slider")
private _volumeSlider?: HaSlider;
private _progressInterval?: number;
private _volumeStep = 2;
private _debouncedVolumeSet = debounce((value: number) => {
this._setVolume(value);
}, 100);
private _volumeController = new VolumeSliderController({
getSlider: () => this._volumeSlider,
step: this._volumeStep,
onSetVolume: (value) => this._setVolume(value),
onSetVolumeDebounced: (value) => this._debouncedVolumeSet(value),
});
public connectedCallback(): void {
super.connectedCallback();
this._syncProgressInterval();
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this._clearProgressInterval();
}
protected firstUpdated(_changedProperties: PropertyValues) {
if (this._positionSlider) {
this._positionSlider.valueFormatter = (value: number) =>
@@ -96,7 +62,14 @@ class MoreInfoMediaPlayer extends LitElement {
}
private _formatDuration(duration: number) {
return formatMediaTime(duration);
const hours = Math.floor(duration / 3600);
const minutes = Math.floor((duration % 3600) / 60);
const seconds = Math.floor(duration % 60);
return formatDurationDigital(this.hass.locale, {
hours,
minutes,
seconds,
})!;
}
protected _renderVolumeControl() {
@@ -166,25 +139,13 @@ class MoreInfoMediaPlayer extends LitElement {
${!supportsMute
? html`<ha-svg-icon .path=${mdiVolumeHigh}></ha-svg-icon>`
: nothing}
<div
class="volume-slider-container"
@touchstart=${this._volumeController.handleTouchStart}
@touchmove=${this._volumeController.handleTouchMove}
@touchend=${this._volumeController.handleTouchEnd}
@touchcancel=${this._volumeController.handleTouchCancel}
@wheel=${this._volumeController.handleWheel}
>
<ha-slider
class="volume-slider"
labeled
id="input"
.value=${Number(this.stateObj.attributes.volume_level) *
100}
.step=${this._volumeStep}
@input=${this._volumeController.handleInput}
@change=${this._volumeController.handleChange}
></ha-slider>
</div>
<ha-slider
labeled
id="input"
.value=${Number(this.stateObj.attributes.volume_level) *
100}
@change=${this._selectedValueChanged}
></ha-slider>
`
: nothing}
</div>
@@ -300,17 +261,17 @@ class MoreInfoMediaPlayer extends LitElement {
const stateObj = this.stateObj;
const controls = computeMediaControls(stateObj, true);
const coverUrlRaw =
const coverUrl =
stateObj.attributes.entity_picture_local ||
stateObj.attributes.entity_picture ||
"";
const coverUrl = coverUrlRaw ? this.hass.hassUrl(coverUrlRaw) : "";
const playerObj = new HassMediaPlayerEntity(this.hass, this.stateObj);
const position = Math.max(Math.floor(playerObj.currentProgress || 0), 0);
const duration = Math.max(stateObj.attributes.media_duration || 0, 0);
const remaining = Math.max(duration - position, 0);
const remainingFormatted = this._formatDuration(remaining);
const positionFormatted = this._formatDuration(position);
const durationFormatted = this._formatDuration(duration);
const primaryTitle = cleanupMediaTitle(stateObj.attributes.media_title);
const secondaryTitle = computeMediaDescription(stateObj);
const turnOn = controls?.find((c) => c.action === "turn_on");
@@ -370,12 +331,8 @@ class MoreInfoMediaPlayer extends LitElement {
?disabled=${!stateActive(stateObj) ||
!supportsFeature(stateObj, MediaPlayerEntityFeature.SEEK)}
>
<span class="position-time" slot="reference"
>${positionFormatted}</span
>
<span class="position-time" slot="reference"
>${durationFormatted}</span
>
<span slot="reference">${positionFormatted}</span>
<span slot="reference">${remainingFormatted}</span>
</ha-slider>
</div>
`
@@ -574,16 +531,6 @@ class MoreInfoMediaPlayer extends LitElement {
margin-left: var(--ha-space-2);
}
.volume-slider-container {
width: 100%;
}
@media (pointer: coarse) {
.volume-slider {
pointer-events: none;
}
}
.volume ha-svg-icon {
padding: var(--ha-space-1);
height: 16px;
@@ -621,10 +568,6 @@ class MoreInfoMediaPlayer extends LitElement {
color: var(--secondary-text-color);
}
.position-time {
margin-top: var(--ha-space-2);
}
.media-info-row {
display: flex;
flex-direction: column;
@@ -679,39 +622,6 @@ class MoreInfoMediaPlayer extends LitElement {
);
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (changedProps.has("stateObj")) {
this._syncProgressInterval();
}
}
private _syncProgressInterval(): void {
if (this._shouldUpdateProgress()) {
this._progressInterval = startMediaProgressInterval(
this._progressInterval,
() => this.requestUpdate()
);
return;
}
this._clearProgressInterval();
}
private _clearProgressInterval(): void {
this._progressInterval = stopMediaProgressInterval(this._progressInterval);
}
private _shouldUpdateProgress(): boolean {
const stateObj = this.stateObj;
return (
!!stateObj &&
stateObj.state === "playing" &&
Number(stateObj.attributes.media_duration) > 0 &&
"media_position" in stateObj.attributes &&
"media_position_updated_at" in stateObj.attributes
);
}
private _toggleMute() {
this.hass!.callService("media_player", "volume_mute", {
entity_id: this.stateObj!.entity_id,
@@ -719,10 +629,10 @@ class MoreInfoMediaPlayer extends LitElement {
});
}
private _setVolume(value: number) {
private _selectedValueChanged(e: Event): void {
this.hass!.callService("media_player", "volume_set", {
entity_id: this.stateObj!.entity_id,
volume_level: value / 100,
volume_level: (e.target as any).value / 100,
});
}

View File

@@ -5,25 +5,29 @@ import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import "../../../components/ha-analytics";
import "../../../components/ha-card";
import "../../../components/ha-settings-row";
import type { HaSwitch } from "../../../components/ha-switch";
import type { Analytics } from "../../../data/analytics";
import {
getAnalyticsDetails,
setAnalyticsPreferences,
} from "../../../data/analytics";
import type { LabPreviewFeature } from "../../../data/labs";
import { subscribeLabFeature } from "../../../data/labs";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import type { HaSwitch } from "../../../components/ha-switch";
import "../../../components/ha-alert";
@customElement("ha-config-analytics")
class ConfigAnalytics extends LitElement {
class ConfigAnalytics extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _analyticsDetails?: Analytics;
@state() private _error?: string;
@state() private _snapshotsLabEnabled = false;
protected render(): TemplateResult {
const error = this._error
? this._error
@@ -56,8 +60,7 @@ class ConfigAnalytics extends LitElement {
></ha-analytics>
</div>
</ha-card>
${this._analyticsDetails &&
"snapshots" in this._analyticsDetails.preferences
${this._snapshotsLabEnabled
? html`<ha-card
outlined
.header=${this.hass.localize(
@@ -70,22 +73,16 @@ class ConfigAnalytics extends LitElement {
"ui.panel.config.analytics.preferences.snapshots.info"
)}
<a
href=${documentationUrl(this.hass, "/device-database/")}
href="https://www.openhomefoundation.org/device-database-data-use-statement"
target="_blank"
rel="noreferrer"
>${this.hass.localize(
"ui.panel.config.analytics.preferences.snapshots.learn_more"
"ui.panel.config.analytics.preferences.snapshots.data_use_statement"
)}</a
>.
</p>
<ha-alert
.title=${this.hass.localize(
"ui.panel.config.analytics.preferences.snapshots.alert.title"
)}
>${this.hass.localize(
"ui.panel.config.analytics.preferences.snapshots.alert.content"
)}</ha-alert
>
"ui.panel.config.analytics.preferences.snapshots.data_use_statement_suffix"
)}
</p>
<ha-settings-row>
<span slot="heading" data-for="snapshots">
${this.hass.localize(
@@ -111,6 +108,19 @@ class ConfigAnalytics extends LitElement {
`;
}
public hassSubscribe() {
return [
subscribeLabFeature(
this.hass.connection,
"analytics",
"snapshots",
(feature: LabPreviewFeature) => {
this._snapshotsLabEnabled = feature.enabled;
}
),
];
}
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
if (isComponentLoaded(this.hass, "analytics")) {

View File

@@ -105,19 +105,10 @@ class AddIntegrationDialog extends LitElement {
const loadPromise = this._load();
if (params?.domain) {
// If we get here we clicked the button to add an entry for a specific integration
// If there is discovery in process, show this dialog to select a new flow
// or continue an existing flow.
// If no flow in process, just open the config flow dialog directly
// Just open the config flow dialog, do not show this dialog
await loadPromise;
const flowsInProgress = this._getFlowsInProgressForDomains([
params.domain,
]);
if (!flowsInProgress.length) {
await this._createFlow(params.domain);
return;
}
await this._createFlow(params.domain);
return;
}
if (params?.brand === "_discovered") {
@@ -126,12 +117,10 @@ class AddIntegrationDialog extends LitElement {
this._showDiscovered = true;
}
// Only open the dialog if no domain is provided or we need to select a flow
// Only open the dialog if no domain is provided
this._open = true;
this._pickedBrand =
params?.brand === "_discovered"
? undefined
: params?.domain || params?.brand;
params?.brand === "_discovered" ? undefined : params?.brand;
this._initialFilter = params?.initialFilter;
this._navigateToResult = params?.navigateToResult ?? false;
this._narrow = matchMedia(

View File

@@ -5,31 +5,31 @@ import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { extractSearchParam } from "../../../common/url/search-params";
import { domainToName } from "../../../data/integration";
import {
labsUpdatePreviewFeature,
subscribeLabFeatures,
} from "../../../data/labs";
import type { LabPreviewFeature } from "../../../data/labs";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../types";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { brandsUrl } from "../../../util/brands-url";
import { showToast } from "../../../util/toast";
import { documentationUrl } from "../../../util/documentation-url";
import { haStyle } from "../../../resources/styles";
import { showLabsPreviewFeatureEnableDialog } from "./show-dialog-labs-preview-feature-enable";
import {
showLabsProgressDialog,
closeLabsProgressDialog,
} from "./show-dialog-labs-progress";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-icon-button";
import "../../../components/ha-markdown";
import "../../../components/ha-switch";
import { domainToName } from "../../../data/integration";
import type { LabPreviewFeature } from "../../../data/labs";
import {
labsUpdatePreviewFeature,
subscribeLabFeatures,
} from "../../../data/labs";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import { documentationUrl } from "../../../util/documentation-url";
import { showToast } from "../../../util/toast";
import { showLabsPreviewFeatureEnableDialog } from "./show-dialog-labs-preview-feature-enable";
import {
closeLabsProgressDialog,
showLabsProgressDialog,
} from "./show-dialog-labs-progress";
@customElement("ha-config-labs")
class HaConfigLabs extends SubscribeMixin(LitElement) {
@@ -42,21 +42,31 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
@state() private _highlightedPreviewFeature?: string;
private _sortedPreviewFeatures = memoizeOne(
(localize: LocalizeFunc, features: LabPreviewFeature[]) =>
// Sort by localized integration name alphabetically
[...features].sort((a, b) =>
domainToName(localize, a.domain).localeCompare(
(localize: LocalizeFunc, features: LabPreviewFeature[]) => {
const featuresToSort = [...features];
return featuresToSort.sort((a, b) => {
// Place frontend.winter_mode at the bottom
if (a.domain === "frontend" && a.preview_feature === "winter_mode")
return 1;
if (b.domain === "frontend" && b.preview_feature === "winter_mode")
return -1;
// Sort everything else alphabetically
return domainToName(localize, a.domain).localeCompare(
domainToName(localize, b.domain)
)
)
);
});
}
);
public hassSubscribe() {
return [
subscribeLabFeatures(this.hass.connection, (features) => {
// Load title translations for integrations with preview features
// Load title and preview_features translations for integrations with preview features
const domains = [...new Set(features.map((f) => f.domain))];
this.hass.loadBackendTranslation("title", domains);
this.hass.loadBackendTranslation("preview_features", domains);
this._preview_features = features;
}),
@@ -165,6 +175,8 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
private _renderPreviewFeature(
preview_feature: LabPreviewFeature
): TemplateResult {
const previewFeatureId = `${preview_feature.domain}.${preview_feature.preview_feature}`;
const featureName = this.hass.localize(
`component.${preview_feature.domain}.preview_features.${preview_feature.preview_feature}.name`
);
@@ -182,7 +194,6 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
? `${integrationName}${this.hass.localize("ui.panel.config.labs.custom_integration")}`
: integrationName;
const previewFeatureId = `${preview_feature.domain}.${preview_feature.preview_feature}`;
const isHighlighted = this._highlightedPreviewFeature === previewFeatureId;
// Build description with learn more link if available

View File

@@ -3,7 +3,6 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import { updateDeviceRegistryEntry } from "../../data/device/device_registry";
import {
fetchFrontendSystemData,
saveFrontendSystemData,
@@ -15,7 +14,6 @@ import { showToast } from "../../util/toast";
import "../lovelace/hui-root";
import { expandLovelaceConfigStrategies } from "../lovelace/strategies/get-strategy";
import type { Lovelace } from "../lovelace/types";
import { showDeviceRegistryDetailDialog } from "../config/devices/device-registry-detail/show-dialog-device-registry-detail";
import { showEditHomeDialog } from "./dialogs/show-dialog-edit-home";
@customElement("ha-panel-home")
@@ -97,29 +95,6 @@ class PanelHome extends LitElement {
this._setLovelace();
};
private _handleLLCustomEvent = (ev: Event) => {
const detail = (ev as CustomEvent).detail;
if (detail.home_panel) {
const { type, device_id } = detail.home_panel;
if (type === "assign_area") {
this._showAssignAreaDialog(device_id);
}
}
};
private _showAssignAreaDialog(deviceId: string) {
const device = this.hass.devices[deviceId];
if (!device) {
return;
}
showDeviceRegistryDetailDialog(this, {
device,
updateEntry: async (updates) => {
await updateDeviceRegistryEntry(this.hass, deviceId, updates);
},
});
}
protected render() {
if (!this._lovelace) {
return nothing;
@@ -132,7 +107,6 @@ class PanelHome extends LitElement {
.lovelace=${this._lovelace}
.route=${this.route}
.panel=${this.panel}
@ll-custom=${this._handleLLCustomEvent}
></hui-root>
`;
}
@@ -142,7 +116,6 @@ class PanelHome extends LitElement {
strategy: {
type: "home",
favorite_entities: this._config.favorite_entities,
home_panel: true,
},
};

View File

@@ -153,7 +153,7 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard {
flex-direction: row;
justify-content: space-between;
align-items: center;
overflow: visible;
overflow: hidden;
gap: var(--ha-space-2);
}
.content:hover ha-icon-next {

View File

@@ -1,4 +1,3 @@
import "../heading-badges/hui-button-heading-badge";
import "../heading-badges/hui-entity-heading-badge";
import {
@@ -7,7 +6,7 @@ import {
} from "./create-element-base";
import type { LovelaceHeadingBadgeConfig } from "../heading-badges/types";
const ALWAYS_LOADED_TYPES = new Set(["error", "entity", "button"]);
const ALWAYS_LOADED_TYPES = new Set(["error", "entity"]);
export const createHeadingBadgeElement = (config: LovelaceHeadingBadgeConfig) =>
createLovelaceElement(

View File

@@ -1,3 +1,4 @@
import type { ActionDetail } from "@material/mwc-list";
import { mdiDotsVertical, mdiPlaylistEdit } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
@@ -5,12 +6,13 @@ import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import { preventDefault } from "../../../../common/dom/prevent_default";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import "../../../../components/ha-button";
import "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import type { HaDropdownItem } from "../../../../components/ha-dropdown-item";
import "../../../../components/ha-button-menu";
import "../../../../components/ha-grid-size-picker";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-list-item";
import "../../../../components/ha-settings-row";
import "../../../../components/ha-slider";
import "../../../../components/ha-svg-icon";
@@ -92,10 +94,14 @@ export class HuiCardLayoutEditor extends LitElement {
"ui.panel.lovelace.editor.edit_card.layout.explanation"
)}
</p>
<ha-dropdown
<ha-button-menu
slot="icons"
@wa-select=${this._handleAction}
placement="bottom-end"
@action=${this._handleAction}
@click=${preventDefault}
@closed=${stopPropagation}
fixed
.corner=${"BOTTOM_END"}
menu-corner="END"
>
<ha-icon-button
slot="trigger"
@@ -104,13 +110,13 @@ export class HuiCardLayoutEditor extends LitElement {
>
</ha-icon-button>
<ha-dropdown-item value="toggle_yaml" .disabled=${!this._uiAvailable}>
<ha-list-item graphic="icon" .disabled=${!this._uiAvailable}>
${this.hass.localize(
`ui.panel.lovelace.editor.edit_view.edit_${!this._yamlMode ? "yaml" : "ui"}`
)}
<ha-svg-icon slot="icon" .path=${mdiPlaylistEdit}></ha-svg-icon>
</ha-dropdown-item>
</ha-dropdown>
<ha-svg-icon slot="graphic" .path=${mdiPlaylistEdit}></ha-svg-icon>
</ha-list-item>
</ha-button-menu>
</div>
${this._yamlMode
? html`
@@ -238,11 +244,11 @@ export class HuiCardLayoutEditor extends LitElement {
}
}
private async _handleAction(ev: CustomEvent<{ item: HaDropdownItem }>) {
const action = ev.detail.item.value;
if (action === "toggle_yaml") {
this._yamlMode = !this._yamlMode;
private async _handleAction(ev: CustomEvent<ActionDetail>) {
switch (ev.detail.index) {
case 0:
this._yamlMode = !this._yamlMode;
break;
}
}
@@ -325,7 +331,7 @@ export class HuiCardLayoutEditor extends LitElement {
margin: 0;
color: var(--secondary-text-color);
}
.header ha-dropdown {
.header ha-button-menu {
--mdc-theme-text-primary-on-background: var(--primary-text-color);
margin-top: -8px;
}

View File

@@ -1,4 +1,4 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import type { ActionDetail } from "@material/mwc-list";
import {
mdiContentCopy,
mdiContentCut,
@@ -16,15 +16,15 @@ import { classMap } from "lit/directives/class-map";
import { storage } from "../../../../common/decorators/storage";
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../../common/dom/fire_event";
import { preventDefault } from "../../../../common/dom/prevent_default";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { handleStructError } from "../../../../common/structs/handle-errors";
import "../../../../components/ha-alert";
import "../../../../components/ha-button-menu";
import "../../../../components/ha-card";
import "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import type { HaDropdownItem } from "../../../../components/ha-dropdown-item";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-list-item";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-yaml-editor";
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
@@ -126,11 +126,14 @@ export class HaCardConditionEditor extends LitElement {
`ui.panel.lovelace.editor.condition-editor.condition.${condition.condition}.label`
) || condition.condition}
</h3>
<ha-dropdown
<ha-button-menu
slot="icons"
@wa-select=${this._handleAction}
@click=${stopPropagation}
placement="bottom-end"
@action=${this._handleAction}
@click=${preventDefault}
@closed=${stopPropagation}
fixed
.corner=${"BOTTOM_END"}
menu-corner="END"
>
<ha-icon-button
slot="trigger"
@@ -139,50 +142,54 @@ export class HaCardConditionEditor extends LitElement {
>
</ha-icon-button>
<ha-dropdown-item value="test">
<ha-list-item graphic="icon">
${this.hass.localize(
"ui.panel.lovelace.editor.condition-editor.test"
)}
<ha-svg-icon slot="icon" .path=${mdiFlask}></ha-svg-icon>
</ha-dropdown-item>
<ha-svg-icon slot="graphic" .path=${mdiFlask}></ha-svg-icon>
</ha-list-item>
<ha-dropdown-item value="duplicate">
<ha-list-item graphic="icon">
${this.hass.localize(
"ui.panel.lovelace.editor.edit_card.duplicate"
)}
<ha-svg-icon
slot="icon"
slot="graphic"
.path=${mdiContentDuplicate}
></ha-svg-icon>
</ha-dropdown-item>
</ha-list-item>
<ha-dropdown-item value="copy">
<ha-list-item graphic="icon">
${this.hass.localize("ui.panel.lovelace.editor.edit_card.copy")}
<ha-svg-icon slot="icon" .path=${mdiContentCopy}></ha-svg-icon>
</ha-dropdown-item>
<ha-svg-icon slot="graphic" .path=${mdiContentCopy}></ha-svg-icon>
</ha-list-item>
<ha-dropdown-item value="cut">
<ha-list-item graphic="icon">
${this.hass.localize("ui.panel.lovelace.editor.edit_card.cut")}
<ha-svg-icon slot="icon" .path=${mdiContentCut}></ha-svg-icon>
</ha-dropdown-item>
<ha-svg-icon slot="graphic" .path=${mdiContentCut}></ha-svg-icon>
</ha-list-item>
<ha-dropdown-item
value="toggle_yaml"
.disabled=${!this._uiAvailable}
>
<ha-list-item graphic="icon" .disabled=${!this._uiAvailable}>
${this.hass.localize(
`ui.panel.lovelace.editor.edit_view.edit_${!this._yamlMode ? "yaml" : "ui"}`
)}
<ha-svg-icon slot="icon" .path=${mdiPlaylistEdit}></ha-svg-icon>
</ha-dropdown-item>
<ha-svg-icon
slot="graphic"
.path=${mdiPlaylistEdit}
></ha-svg-icon>
</ha-list-item>
<wa-divider></wa-divider>
<li divider role="separator"></li>
<ha-dropdown-item variant="danger" value="delete">
<ha-list-item class="warning" graphic="icon">
${this.hass!.localize("ui.common.delete")}
<ha-svg-icon slot="icon" .path=${mdiDelete}></ha-svg-icon>
</ha-dropdown-item>
</ha-dropdown>
<ha-svg-icon
class="warning"
slot="graphic"
.path=${mdiDelete}
></ha-svg-icon>
</ha-list-item>
</ha-button-menu>
${!this._uiAvailable
? html`
<ha-alert
@@ -245,31 +252,26 @@ export class HaCardConditionEditor extends LitElement {
`;
}
private async _handleAction(ev: CustomEvent<{ item: HaDropdownItem }>) {
const action = ev.detail.item.value;
if (action === undefined) {
return;
}
switch (action) {
case "test":
private async _handleAction(ev: CustomEvent<ActionDetail>) {
switch (ev.detail.index) {
case 0:
await this._testCondition();
return;
case "duplicate":
break;
case 1:
this._duplicateCondition();
return;
case "copy":
break;
case 2:
this._copyCondition();
return;
case "cut":
break;
case 3:
this._cutCondition();
return;
case "toggle_yaml":
break;
case 4:
this._yamlMode = !this._yamlMode;
return;
case "delete":
break;
case 5:
this._delete();
break;
}
}
@@ -319,9 +321,9 @@ export class HaCardConditionEditor extends LitElement {
this._delete();
}
private _delete = () => {
private _delete() {
fireEvent(this, "value-changed", { value: null });
};
}
private _onYamlChange(ev: CustomEvent) {
ev.stopPropagation();
@@ -335,7 +337,7 @@ export class HaCardConditionEditor extends LitElement {
static styles = [
haStyle,
css`
ha-dropdown {
ha-button-menu {
--mdc-theme-text-primary-on-background: var(--primary-text-color);
}
ha-expansion-panel {

View File

@@ -3,12 +3,11 @@ import deepClone from "deep-clone-simple";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { storage } from "../../../../common/decorators/storage";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import "../../../../components/ha-button";
import "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import type { HaDropdownItem } from "../../../../components/ha-dropdown-item";
import "../../../../components/ha-list-item";
import type { HaSelect } from "../../../../components/ha-select";
import "../../../../components/ha-svg-icon";
import type { HomeAssistant } from "../../../../types";
import { ICON_CONDITION } from "../../common/icon-condition";
@@ -28,6 +27,7 @@ import "./types/ha-card-condition-screen";
import "./types/ha-card-condition-state";
import "./types/ha-card-condition-time";
import "./types/ha-card-condition-user";
import { storage } from "../../../../common/decorators/storage";
const UI_CONDITION = [
"location",
@@ -41,6 +41,8 @@ const UI_CONDITION = [
"or",
] as const satisfies readonly Condition["condition"][];
export const PASTE_VALUE = "__paste__" as const;
@customElement("ha-card-conditions-editor")
export class HaCardConditionsEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -105,7 +107,11 @@ export class HaCardConditionsEditor extends LitElement {
`
)}
<div>
<ha-dropdown @wa-select=${this._addCondition}>
<ha-button-menu
@action=${this._addCondition}
fixed
@closed=${stopPropagation}
>
<ha-button slot="trigger" appearance="filled">
<ha-svg-icon .path=${mdiPlus} slot="start"></ha-svg-icon>
${this.hass.localize(
@@ -114,57 +120,59 @@ export class HaCardConditionsEditor extends LitElement {
</ha-button>
${this._clipboard
? html`
<ha-dropdown-item value="paste">
<ha-list-item .value=${PASTE_VALUE} graphic="icon">
${this.hass.localize(
"ui.panel.lovelace.editor.edit_card.paste_condition"
)}
<ha-svg-icon
slot="icon"
slot="graphic"
.path=${mdiContentPaste}
></ha-svg-icon>
</ha-dropdown-item>
</ha-list-item>
`
: nothing}
${UI_CONDITION.map(
(condition) => html`
<ha-dropdown-item .value=${condition}>
<ha-list-item .value=${condition} graphic="icon">
${this.hass!.localize(
`ui.panel.lovelace.editor.condition-editor.condition.${condition}.label`
) || condition}
<ha-svg-icon
slot="icon"
slot="graphic"
.path=${ICON_CONDITION[condition]}
></ha-svg-icon>
</ha-dropdown-item>
</ha-list-item>
`
)}
</ha-dropdown>
</ha-button-menu>
</div>
</div>
`;
}
private _addCondition(ev: CustomEvent<{ item: HaDropdownItem }>) {
const condition = ev.detail.item.value as "paste" | Condition["condition"];
private _addCondition(ev: CustomEvent): void {
const conditions = [...this.conditions];
if (!condition || (condition === "paste" && !this._clipboard)) {
const item = (ev.currentTarget as HaSelect).items[ev.detail.index];
if (item.value === PASTE_VALUE && this._clipboard) {
const condition = deepClone(this._clipboard);
conditions.push(condition);
fireEvent(this, "value-changed", { value: conditions });
return;
}
if (condition === "paste") {
const newCondition = deepClone(this._clipboard);
conditions.push(newCondition);
} else {
const elClass = customElements.get(`ha-card-condition-${condition}`) as
| LovelaceConditionEditorConstructor
| undefined;
const condition = item.value as Condition["condition"];
conditions.push(
elClass?.defaultConfig ? { ...elClass.defaultConfig } : { condition }
);
}
const elClass = customElements.get(`ha-card-condition-${condition}`) as
| LovelaceConditionEditorConstructor
| undefined;
conditions.push(
elClass?.defaultConfig
? { ...elClass.defaultConfig }
: { condition: condition }
);
this._focusLastConditionOnChange = true;
fireEvent(this, "value-changed", { value: conditions });
}
@@ -202,9 +210,8 @@ export class HaCardConditionsEditor extends LitElement {
margin-top: 12px;
scroll-margin-top: 48px;
}
ha-dropdown {
display: inline-block;
margin-top: var(--ha-space-3);
ha-button-menu {
margin-top: 12px;
}
`,
];

View File

@@ -1,4 +1,3 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import {
mdiDelete,
mdiDragHorizontalVariant,
@@ -9,11 +8,10 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import "../../../../components/ha-button";
import "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import type { HaDropdownItem } from "../../../../components/ha-dropdown-item";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-list-item";
import "../../../../components/ha-sortable";
import "../../../../components/ha-svg-icon";
import type { CustomCardFeatureEntry } from "../../../../data/lovelace_custom_cards";
@@ -26,7 +24,6 @@ import {
import type { HomeAssistant } from "../../../../types";
import { supportsAlarmModesCardFeature } from "../../card-features/hui-alarm-modes-card-feature";
import { supportsAreaControlsCardFeature } from "../../card-features/hui-area-controls-card-feature";
import { supportsBarGaugeCardFeature } from "../../card-features/hui-bar-gauge-card-feature";
import { supportsButtonCardFeature } from "../../card-features/hui-button-card-feature";
import { supportsClimateFanModesCardFeature } from "../../card-features/hui-climate-fan-modes-card-feature";
import { supportsClimateHvacModesCardFeature } from "../../card-features/hui-climate-hvac-modes-card-feature";
@@ -55,14 +52,15 @@ import { supportsMediaPlayerVolumeButtonsCardFeature } from "../../card-features
import { supportsMediaPlayerVolumeSliderCardFeature } from "../../card-features/hui-media-player-volume-slider-card-feature";
import { supportsNumericInputCardFeature } from "../../card-features/hui-numeric-input-card-feature";
import { supportsSelectOptionsCardFeature } from "../../card-features/hui-select-options-card-feature";
import { supportsTrendGraphCardFeature } from "../../card-features/hui-trend-graph-card-feature";
import { supportsTargetHumidityCardFeature } from "../../card-features/hui-target-humidity-card-feature";
import { supportsTargetTemperatureCardFeature } from "../../card-features/hui-target-temperature-card-feature";
import { supportsToggleCardFeature } from "../../card-features/hui-toggle-card-feature";
import { supportsTrendGraphCardFeature } from "../../card-features/hui-trend-graph-card-feature";
import { supportsUpdateActionsCardFeature } from "../../card-features/hui-update-actions-card-feature";
import { supportsVacuumCommandsCardFeature } from "../../card-features/hui-vacuum-commands-card-feature";
import { supportsValveOpenCloseCardFeature } from "../../card-features/hui-valve-open-close-card-feature";
import { supportsValvePositionCardFeature } from "../../card-features/hui-valve-position-card-feature";
import { supportsBarGaugeCardFeature } from "../../card-features/hui-bar-gauge-card-feature";
import { supportsWaterHeaterOperationModesCardFeature } from "../../card-features/hui-water-heater-operation-modes-card-feature";
import type {
LovelaceCardFeatureConfig,
@@ -406,39 +404,45 @@ export class HuiCardFeaturesEditor extends LitElement {
</ha-sortable>
${supportedFeaturesType.length > 0
? html`
<ha-dropdown @wa-select=${this._addFeature}>
<ha-button-menu
fixed
@action=${this._addFeature}
@closed=${stopPropagation}
>
<ha-button slot="trigger" appearance="filled" size="small">
<ha-svg-icon .path=${mdiPlus} slot="start"></ha-svg-icon>
${this.hass!.localize(`ui.panel.lovelace.editor.features.add`)}
</ha-button>
${types.map(
(type) => html`
<ha-dropdown-item .value=${type}>
<ha-list-item .value=${type}>
${this._getFeatureTypeLabel(type)}
</ha-dropdown-item>
</ha-list-item>
`
)}
${types.length > 0 && customTypes.length > 0
? html`<wa-divider></wa-divider>`
? html`<li divider role="separator"></li>`
: nothing}
${customTypes.map(
(type) => html`
<ha-dropdown-item .value=${type}>
<ha-list-item .value=${type}>
${this._getFeatureTypeLabel(type)}
</ha-dropdown-item>
</ha-list-item>
`
)}
</ha-dropdown>
</ha-button-menu>
`
: nothing}
`;
}
private async _addFeature(ev: CustomEvent<{ item: HaDropdownItem }>) {
const value = ev.detail.item.value as FeatureType;
if (!value) {
return;
}
private async _addFeature(ev: CustomEvent): Promise<void> {
const index = ev.detail.index as number;
if (index == null) return;
const value = this._getSupportedFeaturesType()[index];
if (!value) return;
const elClass = await getCardFeatureElementClass(value);
@@ -495,9 +499,7 @@ export class HuiCardFeaturesEditor extends LitElement {
display: flex !important;
flex-direction: column;
}
ha-dropdown {
display: inline-block;
align-self: flex-start;
ha-button-menu {
margin-top: var(--ha-space-2);
}
.feature {

View File

@@ -1,33 +1,21 @@
import {
mdiDelete,
mdiDragHorizontalVariant,
mdiPencil,
mdiPlus,
} from "@mdi/js";
import "@material/mwc-menu/mwc-menu-surface";
import { mdiDelete, mdiDragHorizontalVariant, mdiPencil } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { computeEntityNameList } from "../../../../common/entity/compute_entity_name_display";
import { computeRTL } from "../../../../common/util/compute_rtl";
import { preventDefault } from "../../../../common/dom/prevent_default";
import "../../../../components/entity/ha-entity-picker";
import type { HaEntityPicker } from "../../../../components/entity/ha-entity-picker";
import "../../../../components/ha-button";
import "../../../../components/ha-button-menu";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-list-item";
import "../../../../components/ha-sortable";
import "../../../../components/ha-svg-icon";
import type { HomeAssistant } from "../../../../types";
import { getHeadingBadgeElementClass } from "../../create-element/create-heading-badge-element";
import type {
ButtonHeadingBadgeConfig,
EntityHeadingBadgeConfig,
LovelaceHeadingBadgeConfig,
} from "../../heading-badges/types";
import { nextRender } from "../../../../common/util/render-status";
const UI_BADGE_TYPES = ["entity", "button"] as const;
declare global {
interface HASSDomEvents {
@@ -53,12 +41,8 @@ export class HuiHeadingBadgesEditor extends LitElement {
return this._badgesKeys.get(badge)!;
}
private _getBadgeTypeLabel(type: string): string {
return (
this.hass.localize(
`ui.panel.lovelace.editor.heading-badges.types.${type}.label`
) || type
);
private _createValueChangedHandler(index: number) {
return (ev: CustomEvent) => this._valueChanged(ev, index);
}
protected render() {
@@ -67,186 +51,120 @@ export class HuiHeadingBadgesEditor extends LitElement {
}
return html`
${this.badges?.length
${this.badges
? html`
<ha-sortable
handle-selector=".handle"
@item-moved=${this._badgeMoved}
>
<div class="badges">
<div class="entities">
${repeat(
this.badges.filter(Boolean),
this.badges,
(badge) => this._getKey(badge),
(badge, index) => this._renderBadgeItem(badge, index)
(badge, index) => {
const type = badge.type ?? "entity";
const isEntityBadge =
type === "entity" && "entity" in badge;
const entityBadge = isEntityBadge
? (badge as EntityHeadingBadgeConfig)
: undefined;
return html`
<div class="badge">
<div class="handle">
<ha-svg-icon
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</div>
${isEntityBadge && entityBadge
? html`
<ha-entity-picker
hide-clear-icon
.hass=${this.hass}
.value=${entityBadge.entity ?? ""}
@value-changed=${this._createValueChangedHandler(
index
)}
></ha-entity-picker>
`
: html`
<div class="badge-content">
<span>${type}</span>
</div>
`}
<ha-icon-button
.label=${this.hass!.localize(
`ui.panel.lovelace.editor.entities.edit`
)}
.path=${mdiPencil}
class="edit-icon"
.index=${index}
@click=${this._editBadge}
></ha-icon-button>
<ha-icon-button
.label=${this.hass!.localize(
`ui.panel.lovelace.editor.entities.remove`
)}
.path=${mdiDelete}
class="remove-icon"
.index=${index}
@click=${this._removeEntity}
></ha-icon-button>
</div>
`;
}
)}
</div>
</ha-sortable>
`
: nothing}
<ha-button-menu
fixed
@action=${this._addBadge}
@closed=${stopPropagation}
>
<ha-button slot="trigger" appearance="filled" size="small">
<ha-svg-icon .path=${mdiPlus} slot="start"></ha-svg-icon>
${this.hass.localize(`ui.panel.lovelace.editor.heading-badges.add`)}
</ha-button>
${UI_BADGE_TYPES.map(
(type) => html`
<ha-list-item .value=${type}>
${this._getBadgeTypeLabel(type)}
</ha-list-item>
`
)}
</ha-button-menu>
`;
}
private _renderBadgeItem(badge: LovelaceHeadingBadgeConfig, index: number) {
const type = badge.type ?? "entity";
const entityBadge = badge as EntityHeadingBadgeConfig;
const isWarning =
type === "entity" &&
(!entityBadge.entity || !this.hass.states[entityBadge.entity]);
return html`
<div class=${classMap({ badge: true, warning: isWarning })}>
<div class="handle">
<ha-svg-icon .path=${mdiDragHorizontalVariant}></ha-svg-icon>
</div>
${type === "entity"
? this._renderEntityBadge(entityBadge)
: type === "button"
? this._renderButtonBadge(badge as ButtonHeadingBadgeConfig)
: this._renderUnknownBadge(type)}
<ha-icon-button
.label=${this.hass.localize(`ui.panel.lovelace.editor.badges.edit`)}
.path=${mdiPencil}
class="edit-icon"
.index=${index}
@click=${this._editBadge}
></ha-icon-button>
<ha-icon-button
.label=${this.hass.localize(`ui.panel.lovelace.editor.badges.remove`)}
.path=${mdiDelete}
class="remove-icon"
.index=${index}
@click=${this._removeBadge}
></ha-icon-button>
<div class="add-container">
<ha-entity-picker
.hass=${this.hass}
id="input"
.placeholder=${this.hass.localize(
"ui.components.entity.entity-picker.choose_entity"
)}
.searchLabel=${this.hass.localize(
"ui.components.entity.entity-picker.choose_entity"
)}
@value-changed=${this._entityPicked}
.value=${undefined}
@click=${preventDefault}
add-button
></ha-entity-picker>
</div>
`;
}
private _renderEntityBadge(badge: EntityHeadingBadgeConfig) {
const entityId = badge.entity;
const stateObj = entityId ? this.hass.states[entityId] : undefined;
if (!entityId) {
return html`
<div class="badge-content">
<div>
<span>${this._getBadgeTypeLabel("entity")}</span>
<span class="secondary"
>${this.hass.localize(
"ui.panel.lovelace.editor.heading-badges.no_entity"
)}</span
>
</div>
</div>
`;
private _entityPicked(ev: CustomEvent): void {
ev.stopPropagation();
if (!ev.detail.value) {
return;
}
if (!stateObj) {
return html`
<div class="badge-content">
<div>
<span>${entityId}</span>
<span class="secondary"
>${this.hass.localize(
"ui.panel.lovelace.editor.heading-badges.entity_not_found"
)}</span
>
</div>
</div>
`;
}
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const isRTL = computeRTL(this.hass);
const primary = entityName || deviceName || entityId;
const secondary = [entityName ? deviceName : undefined, areaName]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
return html`
<div class="badge-content">
<div>
<span>${primary}</span>
${secondary
? html`<span class="secondary">${secondary}</span>`
: nothing}
</div>
</div>
`;
const newEntity: LovelaceHeadingBadgeConfig = {
type: "entity",
entity: ev.detail.value,
};
const newBadges = [...(this.badges || []), newEntity];
(ev.target as HaEntityPicker).value = undefined;
fireEvent(this, "heading-badges-changed", { badges: newBadges });
}
private _renderButtonBadge(badge: ButtonHeadingBadgeConfig) {
return html`
<div class="badge-content">
<div>
<span>${this._getBadgeTypeLabel("button")}</span>
${badge.text
? html`<span class="secondary">${badge.text}</span>`
: nothing}
</div>
</div>
`;
}
private _valueChanged(ev: CustomEvent, index: number): void {
ev.stopPropagation();
const value = ev.detail.value;
const newBadges = [...(this.badges || [])];
private _renderUnknownBadge(type: string) {
return html`
<div class="badge-content">
<div>
<span>${type}</span>
</div>
</div>
`;
}
private async _addBadge(ev: CustomEvent): Promise<void> {
const index = ev.detail.index as number;
if (index == null) return;
const type = UI_BADGE_TYPES[index];
if (!type) return;
const elClass = await getHeadingBadgeElementClass(type);
let newBadge: LovelaceHeadingBadgeConfig;
if (elClass && elClass.getStubConfig) {
newBadge = elClass.getStubConfig(this.hass);
if (!value) {
newBadges.splice(index, 1);
} else {
newBadge = { type } as LovelaceHeadingBadgeConfig;
newBadges[index] = {
...newBadges[index],
entity: value,
};
}
const newBadges = [...(this.badges || []), newBadge];
fireEvent(this, "heading-badges-changed", { badges: newBadges });
await nextRender();
// Open the editor for the new badge
fireEvent(this, "edit-heading-badge", { index: newBadges.length - 1 });
}
private _badgeMoved(ev: CustomEvent): void {
@@ -259,7 +177,7 @@ export class HuiHeadingBadgesEditor extends LitElement {
fireEvent(this, "heading-badges-changed", { badges: newBadges });
}
private _removeBadge(ev: CustomEvent): void {
private _removeEntity(ev: CustomEvent): void {
const index = (ev.currentTarget as any).index;
const newBadges = [...(this.badges || [])];
@@ -280,12 +198,11 @@ export class HuiHeadingBadgesEditor extends LitElement {
display: flex !important;
flex-direction: column;
}
ha-button-menu {
ha-button {
margin-top: var(--ha-space-2);
}
.badges {
.entities {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
@@ -295,7 +212,6 @@ export class HuiHeadingBadgesEditor extends LitElement {
display: flex;
align-items: center;
}
.badge .handle {
cursor: move; /* fallback if grab cursor is unsupported */
cursor: grab;
@@ -304,14 +220,13 @@ export class HuiHeadingBadgesEditor extends LitElement {
padding-inline-start: initial;
direction: var(--direction);
}
.badge .handle > * {
pointer-events: none;
}
.badge-content {
height: var(--ha-space-12);
font-size: var(--ha-font-size-m);
height: 60px;
font-size: var(--ha-font-size-l);
display: flex;
align-items: center;
justify-content: space-between;
@@ -323,9 +238,15 @@ export class HuiHeadingBadgesEditor extends LitElement {
flex-direction: column;
}
.badge ha-entity-picker {
flex-grow: 1;
min-width: 0;
margin-top: 0;
}
.remove-icon,
.edit-icon {
--mdc-icon-button-size: var(--ha-space-9);
--mdc-icon-button-size: 36px;
color: var(--secondary-text-color);
}
@@ -334,19 +255,24 @@ export class HuiHeadingBadgesEditor extends LitElement {
color: var(--secondary-text-color);
}
.badge.warning {
background-color: var(--ha-color-fill-warning-quiet-resting);
border-radius: var(--ha-border-radius-sm);
overflow: hidden;
}
.badge.warning .secondary {
color: var(--ha-color-on-warning-normal);
}
li[divider] {
border-bottom-color: var(--divider-color);
}
.add-container {
position: relative;
width: 100%;
margin-top: var(--ha-space-2);
}
mwc-menu-surface {
--mdc-menu-min-width: 100%;
}
ha-entity-picker {
display: block;
width: 100%;
}
`;
}

View File

@@ -139,7 +139,9 @@ export class HuiHeadingCardEditor
<ha-expansion-panel outlined>
<ha-svg-icon slot="leading-icon" .path=${mdiListBox}></ha-svg-icon>
<h3 slot="header">
${this.hass!.localize("ui.panel.lovelace.editor.card.heading.badges")}
${this.hass!.localize(
"ui.panel.lovelace.editor.card.heading.entities"
)}
</h3>
<div class="content">
<hui-heading-badges-editor

View File

@@ -9,13 +9,13 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import type { HASSDomEvent } from "../../../../../common/dom/fire_event";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../../common/dom/stop_propagation";
import "../../../../../components/ha-button";
import "../../../../../components/ha-button-menu";
import "../../../../../components/ha-dialog";
import "../../../../../components/ha-dialog-header";
import "../../../../../components/ha-dropdown";
import "../../../../../components/ha-dropdown-item";
import type { HaDropdownItem } from "../../../../../components/ha-dropdown-item";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-list-item";
import type { LovelaceStrategyConfig } from "../../../../../data/lovelace/config/strategy";
import {
haStyleDialog,
@@ -98,18 +98,13 @@ class DialogDashboardStrategyEditor extends LitElement {
this.closeDialog();
}
private _handleAction(ev: CustomEvent<{ item: HaDropdownItem }>) {
const action = ev.detail.item.value;
if (!action) {
return;
}
switch (action) {
case "toggle-mode":
private _handleAction(ev) {
ev.stopPropagation();
switch (ev.detail.index) {
case 0:
this._toggleMode();
break;
case "take-control":
case 1:
this._takeControl();
break;
}
@@ -155,32 +150,41 @@ class DialogDashboardStrategyEditor extends LitElement {
${this._params.title
? html`<span slot="subtitle">${this._params.title}</span>`
: nothing}
<ha-dropdown
placement="bottom-end"
<ha-button-menu
corner="BOTTOM_END"
menu-corner="END"
slot="actionItems"
@wa-select=${this._handleAction}
@closed=${stopPropagation}
fixed
@action=${this._handleAction}
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-dropdown-item
value="toggle-mode"
<ha-list-item
graphic="icon"
.disabled=${!this._guiModeAvailable && !this._GUImode}
>
${this.hass!.localize(
`ui.panel.lovelace.editor.edit_view.edit_${!this._GUImode ? "ui" : "yaml"}`
)}
<ha-svg-icon slot="icon" .path=${mdiPlaylistEdit}></ha-svg-icon>
</ha-dropdown-item>
<ha-dropdown-item value="take-control">
<ha-svg-icon
slot="graphic"
.path=${mdiPlaylistEdit}
></ha-svg-icon>
</ha-list-item>
<ha-list-item graphic="icon">
${this.hass.localize(
"ui.panel.lovelace.editor.strategy-editor.take_control"
)}
<ha-svg-icon slot="icon" .path=${mdiAccountHardHat}></ha-svg-icon>
</ha-dropdown-item>
</ha-dropdown>
<ha-svg-icon
slot="graphic"
.path=${mdiAccountHardHat}
></ha-svg-icon>
</ha-list-item>
</ha-button-menu>
</ha-dialog-header>
<div class="content">
<hui-dashboard-strategy-element-editor

View File

@@ -1,218 +0,0 @@
import { mdiEye, mdiGestureTap } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { any, array, assert, object, optional, string } from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-form/ha-form";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import type { Condition } from "../../common/validate-condition";
import type { ButtonHeadingBadgeConfig } from "../../heading-badges/types";
import type { LovelaceGenericElementEditor } from "../../types";
import "../conditions/ha-card-conditions-editor";
import { configElementStyle } from "../config-elements/config-elements-style";
import { actionConfigStruct } from "../structs/action-struct";
const buttonConfigStruct = object({
type: optional(string()),
text: optional(string()),
icon: optional(string()),
color: optional(string()),
tap_action: optional(actionConfigStruct),
hold_action: optional(actionConfigStruct),
double_tap_action: optional(actionConfigStruct),
visibility: optional(array(any())),
});
@customElement("hui-button-heading-badge-editor")
export class HuiButtonHeadingBadgeEditor
extends LitElement
implements LovelaceGenericElementEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ type: Boolean }) public preview = false;
@state() private _config?: ButtonHeadingBadgeConfig;
public setConfig(config: ButtonHeadingBadgeConfig): void {
assert(config, buttonConfigStruct);
this._config = config;
}
private _schema = memoizeOne(
() =>
[
{
name: "text",
selector: { text: {} },
},
{
name: "",
type: "grid",
schema: [
{
name: "icon",
selector: { icon: {} },
},
{
name: "color",
selector: {
ui_color: {},
},
},
],
},
{
name: "interactions",
type: "expandable",
flatten: true,
iconPath: mdiGestureTap,
schema: [
{
name: "tap_action",
selector: {
ui_action: {
default_action: "none",
},
},
},
{
name: "",
type: "optional_actions",
flatten: true,
schema: (["hold_action", "double_tap_action"] as const).map(
(action) => ({
name: action,
selector: {
ui_action: {
default_action: "none" as const,
},
},
})
),
},
],
},
] as const satisfies readonly HaFormSchema[]
);
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const schema = this._schema();
const conditions = this._config.visibility ?? [];
return html`
<ha-form
.hass=${this.hass}
.data=${this._config}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
<ha-expansion-panel outlined>
<ha-svg-icon slot="leading-icon" .path=${mdiEye}></ha-svg-icon>
<h3 slot="header">
${this.hass!.localize(
"ui.panel.lovelace.editor.card.heading.button_config.visibility"
)}
</h3>
<div class="content">
<p class="intro">
${this.hass.localize(
"ui.panel.lovelace.editor.card.heading.button_config.visibility_explanation"
)}
</p>
<ha-card-conditions-editor
.hass=${this.hass}
.conditions=${conditions}
@value-changed=${this._conditionChanged}
>
</ha-card-conditions-editor>
</div>
</ha-expansion-panel>
`;
}
private _valueChanged(ev: CustomEvent): void {
ev.stopPropagation();
if (!this._config || !this.hass) {
return;
}
const config = ev.detail.value as ButtonHeadingBadgeConfig;
fireEvent(this, "config-changed", { config });
}
private _conditionChanged(ev: CustomEvent): void {
ev.stopPropagation();
if (!this._config || !this.hass) {
return;
}
const conditions = ev.detail.value as Condition[];
const newConfig: ButtonHeadingBadgeConfig = {
...this._config,
visibility: conditions,
};
if (newConfig.visibility?.length === 0) {
delete newConfig.visibility;
}
fireEvent(this, "config-changed", { config: newConfig });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "text":
case "color":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.heading.button_config.${schema.name}`
);
default:
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
}
};
static get styles() {
return [
configElementStyle,
css`
.container {
display: flex;
flex-direction: column;
}
ha-form {
display: block;
margin-bottom: 24px;
}
.intro {
margin: 0;
color: var(--secondary-text-color);
margin-bottom: 8px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-button-heading-badge-editor": HuiButtonHeadingBadgeEditor;
}
}

View File

@@ -1,3 +1,4 @@
import type { ActionDetail } from "@material/mwc-list";
import {
mdiClose,
mdiDotsVertical,
@@ -10,15 +11,14 @@ import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { navigate } from "../../../../common/navigate";
import { deepEqual } from "../../../../common/util/deep-equal";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import type { HaDropdownItem } from "../../../../components/ha-dropdown-item";
import "../../../../components/ha-list-item";
import "../../../../components/ha-spinner";
import "../../../../components/ha-tab-group";
import "../../../../components/ha-tab-group-tab";
@@ -218,32 +218,38 @@ export class HuiDialogEditView extends LitElement {
.path=${mdiClose}
></ha-icon-button>
<h2 slot="title">${this._viewConfigTitle}</h2>
<ha-dropdown
<ha-button-menu
slot="actionItems"
placement="bottom-end"
@wa-select=${this._handleAction}
fixed
corner="BOTTOM_END"
menu-corner="END"
@action=${this._handleAction}
@closed=${stopPropagation}
>
<ha-icon-button
slot="trigger"
.label=${this.hass!.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-dropdown-item value="toggle-mode">
<ha-list-item graphic="icon">
${this.hass!.localize(
`ui.panel.lovelace.editor.edit_view.edit_${!this._yamlMode ? "yaml" : "ui"}`
)}
<ha-svg-icon slot="icon" .path=${mdiPlaylistEdit}></ha-svg-icon>
</ha-dropdown-item>
<ha-dropdown-item value="move-to-dashboard">
<ha-svg-icon
slot="graphic"
.path=${mdiPlaylistEdit}
></ha-svg-icon>
</ha-list-item>
<ha-list-item graphic="icon">
${this.hass!.localize(
"ui.panel.lovelace.editor.edit_view.move_to_dashboard"
)}
<ha-svg-icon
slot="icon"
slot="graphic"
.path=${mdiFileMoveOutline}
></ha-svg-icon>
</ha-dropdown-item>
</ha-dropdown>
</ha-list-item>
</ha-button-menu>
${convertToSection
? html`
<ha-alert alert-type="info">
@@ -324,18 +330,14 @@ export class HuiDialogEditView extends LitElement {
`;
}
private async _handleAction(ev: CustomEvent<{ item: HaDropdownItem }>) {
const action = ev.detail.item.value;
if (!action) {
return;
}
switch (action) {
case "toggle-mode":
private async _handleAction(ev: CustomEvent<ActionDetail>) {
ev.stopPropagation();
ev.preventDefault();
switch (ev.detail.index) {
case 0:
this._yamlMode = !this._yamlMode;
break;
case "move-to-dashboard":
case 1:
this._openSelectDashboard();
break;
}

View File

@@ -1,16 +1,16 @@
import type { ActionDetail } from "@material/mwc-list";
import { mdiClose, mdiDotsVertical, mdiPlaylistEdit } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { deepEqual } from "../../../../common/util/deep-equal";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import type { HaDropdownItem } from "../../../../components/ha-dropdown-item";
import "../../../../components/ha-list-item";
import "../../../../components/ha-spinner";
import "../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
@@ -113,23 +113,29 @@ export class HuiDialogEditViewHeader extends LitElement {
.path=${mdiClose}
></ha-icon-button>
<h2 slot="title">${title}</h2>
<ha-dropdown
<ha-button-menu
slot="actionItems"
placement="bottom-end"
@wa-select=${this._handleAction}
fixed
corner="BOTTOM_END"
menu-corner="END"
@action=${this._handleAction}
@closed=${stopPropagation}
>
<ha-icon-button
slot="trigger"
.label=${this.hass!.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-dropdown-item value="toggle-mode">
<ha-list-item graphic="icon">
${this.hass!.localize(
`ui.panel.lovelace.editor.edit_view_header.edit_${!this._yamlMode ? "yaml" : "ui"}`
)}
<ha-svg-icon slot="icon" .path=${mdiPlaylistEdit}></ha-svg-icon>
</ha-dropdown-item>
</ha-dropdown>
<ha-svg-icon
slot="graphic"
.path=${mdiPlaylistEdit}
></ha-svg-icon>
</ha-list-item>
</ha-button-menu>
</ha-dialog-header>
${content}
<ha-button
@@ -144,11 +150,13 @@ export class HuiDialogEditViewHeader extends LitElement {
`;
}
private async _handleAction(ev: CustomEvent<{ item: HaDropdownItem }>) {
const action = ev.detail.item.value;
if (action === "toggle-mode") {
this._yamlMode = !this._yamlMode;
private async _handleAction(ev: CustomEvent<ActionDetail>) {
ev.stopPropagation();
ev.preventDefault();
switch (ev.detail.index) {
case 0:
this._yamlMode = !this._yamlMode;
break;
}
}

View File

@@ -1,143 +0,0 @@
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { classMap } from "lit/directives/class-map";
import { computeCssColor } from "../../../common/color/compute-color";
import "../../../components/ha-control-button";
import "../../../components/ha-icon";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import type { HomeAssistant } from "../../../types";
import { actionHandler } from "../common/directives/action-handler-directive";
import { handleAction } from "../common/handle-action";
import { hasAction } from "../common/has-action";
import type {
LovelaceHeadingBadge,
LovelaceHeadingBadgeEditor,
} from "../types";
import type { ButtonHeadingBadgeConfig } from "./types";
const DEFAULT_ACTIONS: Pick<
ButtonHeadingBadgeConfig,
"tap_action" | "hold_action" | "double_tap_action"
> = {
tap_action: { action: "none" },
hold_action: { action: "none" },
double_tap_action: { action: "none" },
};
@customElement("hui-button-heading-badge")
export class HuiButtonHeadingBadge
extends LitElement
implements LovelaceHeadingBadge
{
public static async getConfigElement(): Promise<LovelaceHeadingBadgeEditor> {
await import("../editor/heading-badge-editor/hui-button-heading-badge-editor");
return document.createElement("hui-button-heading-badge-editor");
}
public static getStubConfig(): ButtonHeadingBadgeConfig {
return {
type: "button",
icon: "mdi:gesture-tap-button",
};
}
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _config?: ButtonHeadingBadgeConfig;
@property({ type: Boolean }) public preview = false;
public setConfig(config: ButtonHeadingBadgeConfig): void {
this._config = {
...DEFAULT_ACTIONS,
...config,
};
}
get hasAction() {
return (
hasAction(this._config?.tap_action) ||
hasAction(this._config?.hold_action) ||
hasAction(this._config?.double_tap_action)
);
}
private _handleAction(ev: ActionHandlerEvent) {
handleAction(this, this.hass!, this._config!, ev.detail.action!);
}
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const config = this._config;
const color = config.color ? computeCssColor(config.color) : undefined;
const style = { "--color": color };
return html`
<ha-control-button
@action=${this._handleAction}
.actionHandler=${actionHandler({
hasHold: hasAction(this._config!.hold_action),
hasDoubleClick: hasAction(this._config!.double_tap_action),
})}
style=${styleMap(style)}
.label=${config.text}
class=${classMap({ colored: !!color, "with-text": !!config.text })}
>
<span class="content">
${config.icon
? html`<ha-icon .icon=${config.icon}></ha-icon>`
: nothing}
${config.text
? html`<span class="text">${config.text}</span>`
: nothing}
</span>
</ha-control-button>
`;
}
static styles = css`
ha-control-button {
--control-button-border-radius: var(
--ha-heading-badge-border-radius,
var(--ha-border-radius-pill)
);
--control-button-padding: 0;
--mdc-icon-size: var(--ha-heading-badge-icon-size, 14px);
width: auto;
height: var(--ha-heading-badge-size, 26px);
min-width: var(--ha-heading-badge-size, 26px);
font-size: var(--ha-font-size-s);
}
ha-control-button.with-text {
--control-button-padding: 0 var(--ha-space-2);
}
ha-control-button.colored {
--control-button-icon-color: var(--color);
--control-button-background-color: var(--color);
--control-button-focus-color: var(--color);
--ha-ripple-color: var(--color);
}
.content {
display: flex;
flex-direction: row;
align-items: center;
white-space: nowrap;
}
.text {
padding: 0 var(--ha-space-1);
line-height: 1;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-button-heading-badge": HuiButtonHeadingBadge;
}
}

View File

@@ -16,7 +16,6 @@ import "../../../state-display/state-display";
import type { HomeAssistant } from "../../../types";
import { actionHandler } from "../common/directives/action-handler-directive";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import { findEntities } from "../common/find-entities";
import { handleAction } from "../common/handle-action";
import { hasAction } from "../common/has-action";
import { DEFAULT_CONFIG } from "../editor/heading-badge-editor/hui-entity-heading-badge-editor";
@@ -44,24 +43,6 @@ export class HuiEntityHeadingBadge
return document.createElement("hui-heading-entity-editor");
}
public static getStubConfig(hass: HomeAssistant): EntityHeadingBadgeConfig {
const includeDomains = ["sensor", "light", "switch"];
const maxEntities = 1;
const entities = Object.keys(hass.states);
const foundEntities = findEntities(
hass,
maxEntities,
entities,
[],
includeDomains
);
return {
type: "entity",
entity: foundEntities[0] || "",
};
}
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _config?: EntityHeadingBadgeConfig;

View File

@@ -26,13 +26,3 @@ export interface EntityHeadingBadgeConfig extends LovelaceHeadingBadgeConfig {
hold_action?: ActionConfig;
double_tap_action?: ActionConfig;
}
export interface ButtonHeadingBadgeConfig extends LovelaceHeadingBadgeConfig {
type: "button";
text?: string;
icon?: string;
color?: string;
tap_action?: ActionConfig;
hold_action?: ActionConfig;
double_tap_action?: ActionConfig;
}

View File

@@ -10,13 +10,11 @@ import {
HOME_SUMMARIES_ICONS,
} from "./helpers/home-summaries";
import type { HomeAreaViewStrategyConfig } from "./home-area-view-strategy";
import type { HomeOtherDevicesViewStrategyConfig } from "./home-other-devices-view-strategy";
import type { HomeOverviewViewStrategyConfig } from "./home-overview-view-strategy";
export interface HomeDashboardStrategyConfig {
type: "home";
favorite_entities?: string[];
home_panel?: boolean;
}
@customElement("home-dashboard-strategy")
@@ -79,8 +77,7 @@ export class HomeDashboardStrategy extends ReactiveElement {
subview: true,
strategy: {
type: "home-other-devices",
home_panel: config.home_panel,
} satisfies HomeOtherDevicesViewStrategyConfig,
},
icon: "mdi:devices",
} satisfies LovelaceViewRawConfig;

View File

@@ -12,21 +12,17 @@ import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../../types";
import { isHelperDomain } from "../../../config/helpers/const";
import type {
EmptyStateCardConfig,
HeadingCardConfig,
} from "../../cards/types";
import type { HeadingCardConfig } from "../../cards/types";
import { OTHER_DEVICES_FILTERS } from "./helpers/other-devices-filters";
export interface HomeOtherDevicesViewStrategyConfig {
type: "home-other-devices";
home_panel?: boolean;
}
@customElement("home-other-devices-view-strategy")
export class HomeOtherDevicesViewStrategy extends ReactiveElement {
static async generate(
config: HomeOtherDevicesViewStrategyConfig,
_config: HomeOtherDevicesViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const allEntities = Object.keys(hass.states);
@@ -136,24 +132,6 @@ export class HomeOtherDevicesViewStrategy extends ReactiveElement {
action: "more-info",
},
})),
...(config.home_panel && device && hass.user?.is_admin
? [
{
type: "button",
icon: "mdi:home-plus",
text: hass.localize(
"ui.panel.lovelace.strategy.other_devices.assign_area"
),
tap_action: {
action: "fire-dom-event",
home_panel: {
type: "assign_area",
device_id: device.id,
},
},
},
]
: []),
],
} satisfies HeadingCardConfig,
...entities.map((e) => ({
@@ -208,26 +186,6 @@ export class HomeOtherDevicesViewStrategy extends ReactiveElement {
});
}
// No sections, show empty state
if (sections.length === 0) {
return {
type: "panel",
cards: [
{
type: "empty-state",
icon: "mdi:check-all",
content_only: true,
title: hass.localize(
"ui.panel.lovelace.strategy.other_devices.empty_state_title"
),
content: hass.localize(
"ui.panel.lovelace.strategy.other_devices.empty_state_content"
),
} as EmptyStateCardConfig,
],
};
}
// Take the full width if there is only one section to avoid narrow header on desktop
if (sections.length === 1) {
sections[0].column_span = 2;

View File

@@ -1,3 +1,5 @@
import "@material/mwc-linear-progress/mwc-linear-progress";
import type { LinearProgress } from "@material/mwc-linear-progress/mwc-linear-progress";
import {
mdiChevronDown,
mdiMonitor,
@@ -18,16 +20,11 @@ import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { supportsFeature } from "../../common/entity/supports-feature";
import { debounce } from "../../common/util/debounce";
import {
startMediaProgressInterval,
stopMediaProgressInterval,
} from "../../common/util/media-progress";
import { VolumeSliderController } from "../../common/util/volume-slider";
import "../../components/ha-button";
import "../../components/ha-button-menu";
import "../../components/ha-domain-icon";
import "../../components/ha-dropdown";
import "../../components/ha-dropdown-item";
import "../../components/ha-icon-button";
import "../../components/ha-list-item";
import "../../components/ha-slider";
import "../../components/ha-spinner";
import "../../components/ha-state-icon";
@@ -53,7 +50,6 @@ import type { ResolvedMediaSource } from "../../data/media_source";
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../types";
import type { HaSlider } from "../../components/ha-slider";
import "../lovelace/components/hui-marquee";
import {
BrowserMediaPlayer,
@@ -74,40 +70,20 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
@property({ type: Boolean, reflect: true }) public narrow = false;
@query(".progress-slider") private _progressBar?: HaSlider;
@query("mwc-linear-progress") private _progressBar?: LinearProgress;
@query("#CurrentProgress") private _currentProgress?: HTMLElement;
@query(".volume-slider") private _volumeSlider?: HaSlider;
@state() private _marqueeActive = false;
@state() private _newMediaExpected = false;
@state() private _browserPlayer?: BrowserMediaPlayer;
private _volumeValue = 0;
private _progressInterval?: number;
private _browserPlayerVolume = 0.8;
private _volumeStep = 2;
private _debouncedVolumeSet = debounce((value: number) => {
this._setVolume(value);
}, 100);
private _volumeController = new VolumeSliderController({
getSlider: () => this._volumeSlider,
step: this._volumeStep,
onSetVolume: (value) => this._setVolume(value),
onSetVolumeDebounced: (value) => this._debouncedVolumeSet(value),
onValueUpdated: (value) => {
this._volumeValue = value;
},
});
public connectedCallback(): void {
super.connectedCallback();
@@ -118,20 +94,23 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
}
if (
!this._progressInterval &&
this._showProgressBar &&
stateObj.state === "playing" &&
!this._progressInterval
stateObj.state === "playing"
) {
this._progressInterval = startMediaProgressInterval(
this._progressInterval,
() => this._updateProgressBar()
this._progressInterval = window.setInterval(
() => this._updateProgressBar(),
1000
);
}
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this._progressInterval = stopMediaProgressInterval(this._progressInterval);
if (this._progressInterval) {
clearInterval(this._progressInterval);
this._progressInterval = undefined;
}
this._tearDownBrowserPlayer();
}
@@ -195,7 +174,7 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
const stateObj = this._stateObj;
if (!stateObj) {
return this._renderChoosePlayer(stateObj, this._volumeValue);
return this._renderChoosePlayer(stateObj);
}
const controls: ControlButton[] | undefined = !this.narrow
@@ -235,6 +214,7 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
const mediaArt =
stateObj.attributes.entity_picture_local ||
stateObj.attributes.entity_picture;
return html`
<div
class=${classMap({
@@ -291,55 +271,21 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
${stateObj.attributes.media_duration === Infinity
? nothing
: this.narrow
? html`<ha-slider
class="progress-slider"
min="0"
max=${stateObj.attributes.media_duration || 0}
step="1"
.value=${getCurrentProgress(stateObj)}
.withTooltip=${false}
size="small"
aria-label=${this.hass.localize(
"ui.card.media_player.track_position"
)}
?disabled=${isBrowser ||
!supportsFeature(stateObj, MediaPlayerEntityFeature.SEEK)}
@change=${this._handleMediaSeekChanged}
></ha-slider>`
? html`<mwc-linear-progress></mwc-linear-progress>`
: html`
<div class="progress">
<div id="CurrentProgress"></div>
<ha-slider
class="progress-slider"
min="0"
max=${stateObj.attributes.media_duration || 0}
step="1"
.value=${getCurrentProgress(stateObj)}
.withTooltip=${false}
size="small"
aria-label=${this.hass.localize(
"ui.card.media_player.track_position"
)}
?disabled=${isBrowser ||
!supportsFeature(
stateObj,
MediaPlayerEntityFeature.SEEK
)}
@change=${this._handleMediaSeekChanged}
></ha-slider>
<mwc-linear-progress wide></mwc-linear-progress>
<div>${mediaDuration}</div>
</div>
`}
`}
</div>
${this._renderChoosePlayer(stateObj, this._volumeValue)}
${this._renderChoosePlayer(stateObj)}
`;
}
private _renderChoosePlayer(
stateObj: MediaPlayerEntity | undefined,
volumeValue: number
) {
private _renderChoosePlayer(stateObj: MediaPlayerEntity | undefined) {
const isBrowser = this.entityId === BROWSER_PLAYER;
return html`
<div class="choose-player ${isBrowser ? "browser" : ""}">
@@ -348,42 +294,26 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
stateObj &&
supportsFeature(stateObj, MediaPlayerEntityFeature.VOLUME_SET)
? html`
<ha-dropdown class="volume-menu" placement="top" .distance=${8}>
<ha-button-menu y="0" x="76">
<ha-icon-button
slot="trigger"
.path=${mdiVolumeHigh}
></ha-icon-button>
<div
class="volume-slider-container"
@touchstart=${this._volumeController.handleTouchStart}
@touchmove=${this._volumeController.handleTouchMove}
@touchend=${this._volumeController.handleTouchEnd}
@touchcancel=${this._volumeController.handleTouchCancel}
@wheel=${this._volumeController.handleWheel}
<ha-slider
labeled
min="0"
max="100"
step="1"
.value=${stateObj.attributes.volume_level! * 100}
@change=${this._handleVolumeChange}
>
<ha-slider
class="volume-slider"
labeled
min="0"
max="100"
.step=${this._volumeStep}
.value=${volumeValue}
@input=${this._volumeController.handleInput}
@change=${this._volumeController.handleChange}
>
</ha-slider>
</div>
</ha-dropdown>
</ha-slider>
</ha-button-menu>
`
: ""
}
<ha-dropdown
class="player-menu"
placement="top-end"
.distance=${8}
@wa-select=${this._handlePlayerSelect}
>
<ha-button-menu>
${
this.narrow
? html`
@@ -412,24 +342,26 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
</ha-button>
`
}
<ha-dropdown-item
class=${isBrowser ? "selected" : ""}
.value=${BROWSER_PLAYER}
<ha-list-item
.player=${BROWSER_PLAYER}
?selected=${isBrowser}
@click=${this._selectPlayer}
>
${this.hass.localize("ui.components.media-browser.web-browser")}
</ha-dropdown-item>
</ha-list-item>
${this._mediaPlayerEntities.map(
(source) => html`
<ha-dropdown-item
class=${source.entity_id === this.entityId ? "selected" : ""}
<ha-list-item
?selected=${source.entity_id === this.entityId}
.disabled=${source.state === UNAVAILABLE}
.value=${source.entity_id}
.player=${source.entity_id}
@click=${this._selectPlayer}
>
${computeStateName(source)}
</ha-dropdown-item>
</ha-list-item>
`
)}
</ha-dropdown>
</ha-button-menu>
</div>
</div>
@@ -469,9 +401,6 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
) {
this._newMediaExpected = false;
}
if (changedProps.has("hass")) {
this._updateVolumeValueFromState(this._stateObj);
}
}
protected updated(changedProps: PropertyValues) {
@@ -490,25 +419,23 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
const stateObj = this._stateObj;
if (this.entityId === BROWSER_PLAYER) {
this._updateVolumeValueFromState(stateObj);
}
this._updateProgressBar();
this._syncVolumeSlider();
if (this._showProgressBar && stateObj?.state === "playing") {
this._progressInterval = startMediaProgressInterval(
this._progressInterval,
() => this._updateProgressBar()
if (
!this._progressInterval &&
this._showProgressBar &&
stateObj?.state === "playing"
) {
this._progressInterval = window.setInterval(
() => this._updateProgressBar(),
1000
);
} else if (
this._progressInterval &&
(!this._showProgressBar || stateObj?.state !== "playing")
) {
this._progressInterval = stopMediaProgressInterval(
this._progressInterval
);
clearInterval(this._progressInterval);
this._progressInterval = undefined;
}
}
@@ -562,45 +489,25 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
private _updateProgressBar(): void {
const stateObj = this._stateObj;
if (!this._progressBar || !stateObj) {
if (!this._progressBar || !this._currentProgress || !stateObj) {
return;
}
if (!stateObj.attributes.media_duration) {
this._progressBar.value = 0;
if (this._currentProgress) {
this._currentProgress.innerHTML = "";
}
this._progressBar.progress = 0;
this._currentProgress.innerHTML = "";
return;
}
const currentProgress = getCurrentProgress(stateObj);
this._progressBar.max = stateObj.attributes.media_duration;
this._progressBar.value = currentProgress;
this._progressBar.progress =
currentProgress / stateObj.attributes.media_duration;
if (this._currentProgress) {
this._currentProgress.innerHTML = formatMediaTime(currentProgress);
}
}
private _updateVolumeValueFromState(stateObj?: MediaPlayerEntity): void {
if (!stateObj) {
return;
}
const volumeLevel = stateObj.attributes.volume_level;
if (typeof volumeLevel !== "number" || !Number.isFinite(volumeLevel)) {
return;
}
this._volumeValue = Math.round(volumeLevel * 100);
}
private _syncVolumeSlider(): void {
if (!this._volumeSlider || this._volumeController.isInteracting) {
return;
}
this._volumeSlider.value = this._volumeValue;
}
private _handleControlClick(e: MouseEvent): void {
const action = (e.currentTarget! as HTMLElement).getAttribute("action")!;
@@ -619,18 +526,6 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
}
}
private _handleMediaSeekChanged(e: Event): void {
if (this.entityId === BROWSER_PLAYER || !this._stateObj) {
return;
}
const newValue = (e.target as HaSlider).value;
this.hass.callService("media_player", "media_seek", {
entity_id: this._stateObj.entity_id,
seek_position: newValue,
});
}
private _marqueeMouseOver(): void {
if (!this._marqueeActive) {
this._marqueeActive = true;
@@ -643,19 +538,20 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
}
}
private _handlePlayerSelect(ev: CustomEvent): void {
const entityId = (ev.detail.item as any).value;
private _selectPlayer(ev: CustomEvent): void {
const entityId = (ev.currentTarget as any).player;
fireEvent(this, "player-picked", { entityId });
}
private _setVolume(value: number) {
const volume = value / 100;
private async _handleVolumeChange(ev) {
ev.stopPropagation();
const value = Number(ev.target.value) / 100;
if (this._browserPlayer) {
this._browserPlayerVolume = volume;
this._browserPlayer.setVolume(volume);
return;
this._browserPlayerVolume = value;
this._browserPlayer.setVolume(value);
} else {
await setMediaPlayerVolume(this.hass, this.entityId, value);
}
setMediaPlayerVolume(this.hass, this.entityId, volume);
}
static styles = css`
@@ -674,11 +570,10 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
margin-left: var(--safe-area-inset-left);
}
ha-slider {
mwc-linear-progress {
width: 100%;
min-width: 100%;
--ha-slider-thumb-color: var(--primary-color);
--ha-slider-indicator-color: var(--primary-color);
padding: 0 4px;
--mdc-theme-primary: var(--secondary-text-color);
}
ha-button-menu ha-button[slot="trigger"] {
@@ -716,7 +611,6 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
justify-content: flex-end;
align-items: center;
padding: 16px;
gap: var(--ha-space-2);
}
.controls {
@@ -739,35 +633,10 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
align-items: center;
}
.progress > div:first-child {
margin-right: var(--ha-space-2);
}
.progress > div:last-child {
margin-left: var(--ha-space-2);
}
.progress ha-slider {
mwc-linear-progress[wide] {
margin: 0 4px;
}
ha-dropdown.volume-menu::part(menu) {
width: 220px;
max-width: 220px;
overflow: visible;
padding: 15px 15px;
}
.volume-slider-container {
width: 100%;
}
@media (pointer: coarse) {
.volume-slider {
pointer-events: none;
}
}
.media-info {
text-overflow: ellipsis;
white-space: nowrap;
@@ -831,14 +700,14 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
justify-content: flex-end;
}
:host([narrow]) ha-slider {
:host([narrow]) mwc-linear-progress {
padding: 0;
position: absolute;
top: -6px;
top: -4px;
left: 0;
right: 0;
}
ha-dropdown-item.selected {
ha-list-item[selected] {
font-weight: var(--ha-font-weight-bold);
}
`;

View File

@@ -7278,8 +7278,9 @@
"title": "Devices",
"description": "Generic information about your devices.",
"header": "Device analytics",
"info": "Anonymously share data about your devices to help build the Open Home Foundations device database. This free, open source resource helps users find useful information about smart home devices. Only device-specific details (like model or manufacturer) are shared — never personally identifying information (like the names you assign).",
"learn_more": "Learn more about the device database and how we process your data",
"info": "Anonymously share data about your devices to help build the Open Home Foundation's device database. This free, open source resource helps users find useful information about smart home devices. Only device-specific details (like model or manufacturer) are shared — never personally identifying information (like the names you assign). Learn more about the device database and how we process your data in our",
"data_use_statement": "Data Use Statement",
"data_use_statement_suffix": ", which you accept by opting in.",
"alert": {
"title": "Important",
"content": "Only enable this option if you understand that your device information will be shared."
@@ -7582,10 +7583,7 @@
},
"other_devices": {
"helpers": "Helpers",
"entities": "Entities",
"assign_area": "Assign area",
"empty_state_title": "All devices are organized",
"empty_state_content": "There are no unassigned devices left. All devices are organized into areas."
"entities": "Entities"
}
},
"cards": {
@@ -8483,7 +8481,7 @@
"title": "Title",
"subtitle": "Subtitle"
},
"badges": "Badges",
"entities": "Entities",
"entity_config": {
"color": "[%key:ui::panel::lovelace::editor::card::tile::color%]",
"color_helper": "[%key:ui::panel::lovelace::editor::card::tile::color_helper%]",
@@ -8498,12 +8496,6 @@
"state": "[%key:ui::panel::lovelace::editor::badge::entity::displayed_elements_options::state%]"
}
},
"button_config": {
"text": "Text",
"color": "Color",
"visibility": "Visibility",
"visibility_explanation": "The button will be shown when ALL conditions below are fulfilled. If no conditions are set, the button will always be shown."
},
"default_heading": "Kitchen"
},
"map": {
@@ -8781,11 +8773,6 @@
"remove": "Remove entity",
"form-label": "Edit entity"
},
"badges": {
"name": "Badges",
"edit": "Edit badge",
"remove": "Remove badge"
},
"features": {
"name": "Features",
"not_compatible": "Not compatible",
@@ -9040,19 +9027,6 @@
}
}
},
"heading-badges": {
"add": "Add badge",
"no_entity": "No entity selected",
"entity_not_found": "Entity not found",
"types": {
"entity": {
"label": "Entity"
},
"button": {
"label": "Button"
}
}
},
"strategy": {
"original-states": {
"areas": "Areas to display",