Compare commits

..

29 Commits

Author SHA1 Message Date
Aidan Timson
074fb1424a Disable coderabbit auto enrichment 2025-12-19 13:44:01 +00:00
Paul Bottein
23e394fec9 Always add favorite heading for home overview (#28629) 2025-12-19 11:35:44 +00:00
Wendelin
4b02a11634 Migrate ha-selector-select to use ha-generic-picker component (#28614) 2025-12-19 10:06:20 +00:00
Aidan Timson
ed9c00cab5 Migrate entity state picker to generic picker (#28613)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2025-12-19 10:52:39 +01:00
Aidan Timson
eaa1fb4107 Add space tokens to developer tools (#28626) 2025-12-19 09:33:53 +00:00
Aidan Timson
c0a49b3d0b Migrate floor dialog to webawesome (#28606) 2025-12-19 10:21:55 +01:00
karwosts
a7a00228a2 Add a debug tool to capture an entity diagnostic details (#28615) 2025-12-19 09:18:13 +00:00
renovate[bot]
c85f7a71b2 Update dependency @rsdoctor/rspack-plugin to v1.3.16 (#28623)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-19 09:11:31 +00:00
renovate[bot]
c95e914219 Update dependency @types/chromecast-caf-receiver to v6.0.25 (#27189)
* Update dependency @types/chromecast-caf-receiver to v6.0.25

* Fix typings

* Fix messageType type json

* Fix FMP4

* Add doc link

* Fix FMP4

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2025-12-19 10:53:48 +02:00
ildar170975
362a0b96ab ha-labels-picker: remove margin-bottom for ha-chip-set (#28559) 2025-12-19 08:55:21 +01:00
Jan Layola
ef984fc438 Trim whitespace from 2FA input before validation (#28616) 2025-12-19 08:48:11 +01:00
renovate[bot]
ce9bbc9972 Update dependency typescript-eslint to v8.50.0 (#28618)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-18 19:19:20 +01:00
Aidan Timson
a3921f0559 Migrate entity state content picker to generic picker (#28612)
* Migrate entity state content picker to generic picker

* Use similar primary/secondary as name picker

* Remove redundant func

* Memoize func

* Add custom value label

* Format

* Remove

* Remove renderer, use better translation

* Format

* Cleanup import

* Remove search labels where unused

* Merge
2025-12-18 19:12:03 +01:00
Aidan Timson
f8ec5d27a4 Migrate entity attribute picker to generic picker (#28611)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2025-12-18 17:03:21 +01:00
Wendelin
d679916fa5 Migrate domain selection to use generic picker component (#28605)
* Migrate domain selection to use generic picker component

* Remove unused CSS, add margins

* Space tokens

* Fix validation

---------

Co-authored-by: Aidan Timson <aidan@timmo.dev>
2025-12-18 13:39:16 +00:00
Aidan Timson
96be4768d3 Migrate entity name picker to generic picker (#28604)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2025-12-18 14:31:29 +01:00
Aidan Timson
6ff3b9f761 Migrate new label dialog to ha-wa-dialog (#28586) 2025-12-18 13:22:07 +01:00
Joakim Plate
9026009842 Let event domain expose attributes in gui (#28486) 2025-12-18 12:43:48 +01:00
Aidan Timson
54398a4784 Fix entity settings row sizing (#28585) 2025-12-18 12:42:29 +01:00
Wendelin
fa3cc970ec Migrate Z-Wave JS node configuration to use generic picker component (#28603) 2025-12-18 13:30:24 +02:00
Aidan Timson
1cf0560003 Migrate color picker to generic picker (#28598) 2025-12-18 11:23:25 +01:00
Wendelin
2a4ac15987 Generic-picker: Implement allowCustomValue (#28572)
* Introduce allowCustomValue and remove usage

* Review

* Fix secondary title
2025-12-18 12:19:57 +02:00
Aidan Timson
f264eebe49 Remove unused prop in target picker (#28601) 2025-12-18 10:14:42 +00:00
Aidan Timson
dae27e091f Migrate config entry picker to new picker syntax (#28600) 2025-12-18 10:10:55 +00:00
Aidan Timson
7ca681e417 Refactor generic pickers (#28570) 2025-12-18 10:53:50 +01:00
ndrwrbgs
1adfe63322 Add media query for prefers-reduced-motion in dialog styles (#28593)
Co-authored-by: ndrwrbgs <10776890+ndrwrbgs@users.noreply.github.com>
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2025-12-18 08:18:19 +00:00
Wendelin
119a505a0d Add iOS focus element messaging for ha-generic-picker component (#28569) 2025-12-18 09:18:01 +01:00
renovate[bot]
1f8403f6c1 Update dependency vite-tsconfig-paths to v6 (#28596)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-18 09:19:09 +02:00
Wendelin
7dd7309a47 Migrate addon-picker to generic-picker (#28567) 2025-12-17 16:33:41 +00:00
76 changed files with 1699 additions and 2943 deletions

3
.coderabbit.yaml Normal file
View File

@@ -0,0 +1,3 @@
issue_enrichment:
auto_enrich:
enabled: false

View File

@@ -3,9 +3,6 @@ import { glob } from "glob";
import gulp from "gulp"; import gulp from "gulp";
import yaml from "js-yaml"; import yaml from "js-yaml";
import { marked } from "marked"; import { marked } from "marked";
import ts from "typescript";
import { create } from "@custom-elements-manifest/analyzer";
import { litPlugin } from "@custom-elements-manifest/analyzer/src/features/framework-plugins/lit/lit.js";
import path from "path"; import path from "path";
import paths from "../paths.cjs"; import paths from "../paths.cjs";
import "./clean.js"; import "./clean.js";
@@ -16,28 +13,6 @@ import "./service-worker.js";
import "./translations.js"; import "./translations.js";
import "./rspack.js"; import "./rspack.js";
gulp.task("generate-component-docs", async function generateComponentDocs() {
const filePaths = ["src/components/ha-alert.ts"];
const modules = await Promise.all(
filePaths.map(async (file) => {
const filePath = path.resolve(file);
console.log(`Reading ${file} -> ${filePath}`);
const source = fs.readFileSync(filePath).toString();
return ts.createSourceFile(file, source, ts.ScriptTarget.ES2015, true);
})
);
const manifest = create({
modules,
plugins: litPlugin(),
context: { dev: true },
});
console.log(manifest);
});
gulp.task("gather-gallery-pages", async function gatherPages() { gulp.task("gather-gallery-pages", async function gatherPages() {
const pageDir = path.resolve(paths.gallery_dir, "src/pages"); const pageDir = path.resolve(paths.gallery_dir, "src/pages");
const files = await glob(path.resolve(pageDir, "**/*")); const files = await glob(path.resolve(pageDir, "**/*"));

View File

@@ -5,17 +5,19 @@ const castContext = framework.CastReceiverContext.getInstance();
const playerManager = castContext.getPlayerManager(); const playerManager = castContext.getPlayerManager();
playerManager.setMessageInterceptor( playerManager.setMessageInterceptor(
framework.messages.MessageType.LOAD, "LOAD" as framework.messages.MessageType.LOAD,
(loadRequestData) => { (loadRequestData) => {
const media = loadRequestData.media; const media = loadRequestData.media;
// Special handling if it came from Google Assistant // Special handling if it came from Google Assistant
if (media.entity) { if (media.entity) {
media.contentId = media.entity; media.contentId = media.entity;
media.streamType = framework.messages.StreamType.LIVE; media.streamType = "LIVE" as framework.messages.StreamType.LIVE;
media.contentType = "application/vnd.apple.mpegurl"; media.contentType = "application/vnd.apple.mpegurl";
// @ts-ignore // @ts-ignore
// type definition is wrong, should be "FMP4" instead of "fmp4"
// https://developers.google.com/cast/docs/reference/web_receiver/cast.framework.messages#.HlsVideoSegmentFormat
media.hlsVideoSegmentFormat = media.hlsVideoSegmentFormat =
framework.messages.HlsVideoSegmentFormat.FMP4; "FMP4" as framework.messages.HlsVideoSegmentFormat.FMP4;
} }
return loadRequestData; return loadRequestData;
} }

View File

@@ -1,10 +1,9 @@
import { framework } from "./cast_framework";
import { CAST_NS } from "../../../src/cast/const"; import { CAST_NS } from "../../../src/cast/const";
import type { HassMessage } from "../../../src/cast/receiver_messages"; import type { HassMessage } from "../../../src/cast/receiver_messages";
import "../../../src/resources/custom-card-support"; import "../../../src/resources/custom-card-support";
import { castContext } from "./cast_context"; import { castContext } from "./cast_context";
import { framework } from "./cast_framework";
import { HcMain } from "./layout/hc-main"; import { HcMain } from "./layout/hc-main";
import type { ReceivedMessage } from "./types";
const lovelaceController = new HcMain(); const lovelaceController = new HcMain();
document.body.append(lovelaceController); document.body.append(lovelaceController);
@@ -40,7 +39,8 @@ const playDummyMedia = (viewTitle?: string) => {
loadRequestData.media.contentId = loadRequestData.media.contentId =
"https://cast.home-assistant.io/images/google-nest-hub.png"; "https://cast.home-assistant.io/images/google-nest-hub.png";
loadRequestData.media.contentType = "image/jpeg"; loadRequestData.media.contentType = "image/jpeg";
loadRequestData.media.streamType = framework.messages.StreamType.NONE; loadRequestData.media.streamType =
"NONE" as framework.messages.StreamType.NONE;
const metadata = new framework.messages.GenericMediaMetadata(); const metadata = new framework.messages.GenericMediaMetadata();
metadata.title = viewTitle; metadata.title = viewTitle;
loadRequestData.media.metadata = metadata; loadRequestData.media.metadata = metadata;
@@ -89,31 +89,30 @@ const showMediaPlayer = () => {
const options = new framework.CastReceiverOptions(); const options = new framework.CastReceiverOptions();
options.disableIdleTimeout = true; options.disableIdleTimeout = true;
options.customNamespaces = { options.customNamespaces = {
[CAST_NS]: framework.system.MessageType.JSON, // type definition is wrong, should be "JSON" instead of "json"
// https://developers.google.com/cast/docs/reference/web_receiver/cast.framework.system#.MessageType
[CAST_NS]: "JSON" as framework.system.MessageType.JSON,
}; };
castContext.addCustomMessageListener( castContext.addCustomMessageListener(CAST_NS, (ev) => {
CAST_NS,
// @ts-ignore
(ev: ReceivedMessage<HassMessage>) => {
// We received a show Lovelace command, stop media from playing, hide media player and show Lovelace controller // We received a show Lovelace command, stop media from playing, hide media player and show Lovelace controller
if ( if (
playerManager.getPlayerState() !== framework.messages.PlayerState.IDLE playerManager.getPlayerState() !==
("IDLE" as framework.messages.PlayerState.IDLE)
) { ) {
playerManager.stop(); playerManager.stop();
} else { } else {
showLovelaceController(); showLovelaceController();
} }
const msg = ev.data; const msg = ev.data as HassMessage;
msg.senderId = ev.senderId; msg.senderId = ev.senderId;
lovelaceController.processIncomingMessage(msg); lovelaceController.processIncomingMessage(msg);
} });
);
const playerManager = castContext.getPlayerManager(); const playerManager = castContext.getPlayerManager();
playerManager.setMessageInterceptor( playerManager.setMessageInterceptor(
framework.messages.MessageType.LOAD, "LOAD" as framework.messages.MessageType.LOAD,
(loadRequestData) => { (loadRequestData) => {
if ( if (
loadRequestData.media.contentId === loadRequestData.media.contentId ===
@@ -127,24 +126,26 @@ playerManager.setMessageInterceptor(
// Special handling if it came from Google Assistant // Special handling if it came from Google Assistant
if (media.entity) { if (media.entity) {
media.contentId = media.entity; media.contentId = media.entity;
media.streamType = framework.messages.StreamType.LIVE; media.streamType = "LIVE" as framework.messages.StreamType.LIVE;
media.contentType = "application/vnd.apple.mpegurl"; media.contentType = "application/vnd.apple.mpegurl";
// @ts-ignore // type definition is wrong, should be "FMP4" instead of "fmp4"
// https://developers.google.com/cast/docs/reference/web_receiver/cast.framework.messages#.HlsVideoSegmentFormat
media.hlsVideoSegmentFormat = media.hlsVideoSegmentFormat =
framework.messages.HlsVideoSegmentFormat.FMP4; "FMP4" as framework.messages.HlsVideoSegmentFormat.FMP4;
} }
return loadRequestData; return loadRequestData;
} }
); );
playerManager.addEventListener( playerManager.addEventListener(
framework.events.EventType.MEDIA_STATUS, "MEDIA_STATUS" as framework.events.EventType.MEDIA_STATUS,
(event) => { (event) => {
if ( if (
event.mediaStatus?.playerState === framework.messages.PlayerState.IDLE && event.mediaStatus?.playerState ===
("IDLE" as framework.messages.PlayerState.IDLE) &&
event.mediaStatus?.idleReason && event.mediaStatus?.idleReason &&
event.mediaStatus?.idleReason !== event.mediaStatus?.idleReason !==
framework.messages.IdleReason.INTERRUPTED ("INTERRUPTED" as framework.messages.IdleReason.INTERRUPTED)
) { ) {
// media finished or stopped, return to default Lovelace // media finished or stopped, return to default Lovelace
showLovelaceController(); showLovelaceController();

View File

@@ -1,6 +0,0 @@
export interface ReceivedMessage<T> {
gj: boolean;
data: T;
senderId: string;
type: "message";
}

View File

@@ -1,186 +0,0 @@
{
"schemaVersion": "1.0.0",
"readme": "",
"modules": [
{
"kind": "javascript-module",
"path": "src/components/ha-alert.ts",
"declarations": [
{
"kind": "class",
"description": "A custom alert component for displaying messages with various alert types.",
"name": "HaAlert",
"cssProperties": [
{
"description": "The color used for \"info\" alerts.",
"name": "--info-color"
},
{
"description": "The color used for \"warning\" alerts.",
"name": "--warning-color"
},
{
"description": "The color used for \"error\" alerts.",
"name": "--error-color"
},
{
"description": "The color used for \"success\" alerts.",
"name": "--success-color"
},
{
"description": "The primary text color used in the alert.",
"name": "--primary-text-color"
}
],
"cssParts": [
{
"description": "The container for the alert.",
"name": "issue-type"
},
{
"description": "The container for the alert icon.",
"name": "icon"
},
{
"description": "The container for the alert content.",
"name": "content"
},
{
"description": "The container for the alert actions.",
"name": "action"
},
{
"description": "The container for the alert title.",
"name": "title"
}
],
"slots": [
{
"description": "The main content of the alert.",
"name": ""
},
{
"description": "Slot for providing a custom icon for the alert.",
"name": "icon"
},
{
"description": "Slot for providing custom actions or buttons for the alert.",
"name": "action"
}
],
"members": [
{
"kind": "field",
"name": "title",
"type": {
"text": "string"
},
"privacy": "public",
"default": "\"\"",
"description": "The title of the alert. Defaults to an empty string.",
"attribute": "title"
},
{
"kind": "field",
"name": "alertType",
"type": {
"text": "\"info\" | \"warning\" | \"error\" | \"success\""
},
"privacy": "public",
"default": "\"info\"",
"description": "The type of alert to display. Defaults to \"info\". Determines the styling and icon used.",
"attribute": "alert-type"
},
{
"kind": "field",
"name": "dismissable",
"type": {
"text": "boolean"
},
"privacy": "public",
"default": "false",
"description": "Whether the alert can be dismissed. Defaults to `false`. If `true`, a dismiss button is displayed.",
"attribute": "dismissable"
},
{
"kind": "field",
"name": "narrow",
"type": {
"text": "boolean"
},
"privacy": "public",
"default": "false",
"description": "Whether the alert should use a narrow layout. Defaults to `false`.",
"attribute": "narrow"
},
{
"kind": "method",
"name": "_dismissClicked",
"privacy": "private"
}
],
"events": [
{
"description": "Fired when the dismiss button is clicked.",
"name": "alert-dismissed-clicked"
}
],
"attributes": [
{
"name": "title",
"type": {
"text": "string"
},
"default": "\"\"",
"description": "The title of the alert. Defaults to an empty string.",
"fieldName": "title"
},
{
"name": "alert-type",
"type": {
"text": "\"info\" | \"warning\" | \"error\" | \"success\""
},
"default": "\"info\"",
"description": "The type of alert to display. Defaults to \"info\". Determines the styling and icon used.",
"fieldName": "alertType"
},
{
"name": "dismissable",
"type": {
"text": "boolean"
},
"default": "false",
"description": "Whether the alert can be dismissed. Defaults to `false`. If `true`, a dismiss button is displayed.",
"fieldName": "dismissable"
},
{
"name": "narrow",
"type": {
"text": "boolean"
},
"default": "false",
"description": "Whether the alert should use a narrow layout. Defaults to `false`.",
"fieldName": "narrow"
}
],
"superclass": {
"name": "LitElement",
"package": "lit"
},
"tagName": "ha-alert",
"customElement": true
}
],
"exports": [
{
"kind": "custom-element-definition",
"name": "ha-alert",
"declaration": {
"name": "HaAlert",
"module": "src/components/ha-alert.ts"
}
}
]
}
]
}

View File

@@ -20,9 +20,7 @@
"prepack": "pinst --disable", "prepack": "pinst --disable",
"postpack": "pinst --enable", "postpack": "pinst --enable",
"test": "vitest run --config test/vitest.config.ts", "test": "vitest run --config test/vitest.config.ts",
"test:coverage": "vitest run --config test/vitest.config.ts --coverage", "test:coverage": "vitest run --config test/vitest.config.ts --coverage"
"analyze": "cem analyze --litelement --globs \"src/components/ha-alert.ts\" --dev",
"doc": "gulp generate-component-docs"
}, },
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)", "author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
"license": "Apache-2.0", "license": "Apache-2.0",
@@ -155,17 +153,15 @@
"@babel/plugin-transform-runtime": "7.28.5", "@babel/plugin-transform-runtime": "7.28.5",
"@babel/preset-env": "7.28.5", "@babel/preset-env": "7.28.5",
"@bundle-stats/plugin-webpack-filter": "4.21.7", "@bundle-stats/plugin-webpack-filter": "4.21.7",
"@custom-elements-manifest/analyzer": "0.10.4",
"@custom-elements-manifest/to-markdown": "0.1.0",
"@lokalise/node-api": "15.4.0", "@lokalise/node-api": "15.4.0",
"@octokit/auth-oauth-device": "8.0.3", "@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.0.3", "@octokit/plugin-retry": "8.0.3",
"@octokit/rest": "22.0.1", "@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.3.15", "@rsdoctor/rspack-plugin": "1.3.16",
"@rspack/core": "1.6.7", "@rspack/core": "1.6.7",
"@rspack/dev-server": "1.1.4", "@rspack/dev-server": "1.1.4",
"@types/babel__plugin-transform-runtime": "7.9.5", "@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.22", "@types/chromecast-caf-receiver": "6.0.25",
"@types/chromecast-caf-sender": "1.0.11", "@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0", "@types/color-name": "2.0.0",
"@types/culori": "4.0.1", "@types/culori": "4.0.1",
@@ -221,8 +217,8 @@
"terser-webpack-plugin": "5.3.16", "terser-webpack-plugin": "5.3.16",
"ts-lit-plugin": "2.0.2", "ts-lit-plugin": "2.0.2",
"typescript": "5.9.3", "typescript": "5.9.3",
"typescript-eslint": "8.49.0", "typescript-eslint": "8.50.0",
"vite-tsconfig-paths": "5.1.4", "vite-tsconfig-paths": "6.0.1",
"vitest": "4.0.15", "vitest": "4.0.15",
"webpack-stats-plugin": "1.1.3", "webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0", "webpackbar": "7.0.0",

View File

@@ -61,7 +61,6 @@ class HaDevicesPicker extends LitElement {
(entityId) => html` (entityId) => html`
<div> <div>
<ha-device-picker <ha-device-picker
allow-custom-entity
.curValue=${entityId} .curValue=${entityId}
.hass=${this.hass} .hass=${this.hass}
.deviceFilter=${this.deviceFilter} .deviceFilter=${this.deviceFilter}
@@ -79,7 +78,6 @@ class HaDevicesPicker extends LitElement {
)} )}
<div> <div>
<ha-device-picker <ha-device-picker
allow-custom-entity
.hass=${this.hass} .hass=${this.hass}
.helper=${this.helper} .helper=${this.helper}
.deviceFilter=${this.deviceFilter} .deviceFilter=${this.deviceFilter}

View File

@@ -99,7 +99,6 @@ class HaEntitiesPicker extends LitElement {
(entityId) => html` (entityId) => html`
<div class="entity"> <div class="entity">
<ha-entity-picker <ha-entity-picker
allow-custom-entity
.curValue=${entityId} .curValue=${entityId}
.hass=${this.hass} .hass=${this.hass}
.includeDomains=${this.includeDomains} .includeDomains=${this.includeDomains}
@@ -129,7 +128,6 @@ class HaEntitiesPicker extends LitElement {
</ha-sortable> </ha-sortable>
<div> <div>
<ha-entity-picker <ha-entity-picker
allow-custom-entity
.hass=${this.hass} .hass=${this.hass}
.includeDomains=${this.includeDomains} .includeDomains=${this.includeDomains}
.excludeDomains=${this.excludeDomains} .excludeDomains=${this.excludeDomains}

View File

@@ -1,16 +1,11 @@
import type { PropertyValues } from "lit";
import { LitElement, html, nothing } from "lit"; import { LitElement, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array"; import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import type { HomeAssistant, ValueChangedEvent } from "../../types"; import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-combo-box"; import "../ha-generic-picker";
import type { HaComboBox } from "../ha-combo-box"; import type { PickerComboBoxItem } from "../ha-picker-combo-box";
interface AttributeOption {
value: string;
label: string;
}
@customElement("ha-entity-attribute-picker") @customElement("ha-entity-attribute-picker")
class HaEntityAttributePicker extends LitElement { class HaEntityAttributePicker extends LitElement {
@@ -42,51 +37,44 @@ class HaEntityAttributePicker extends LitElement {
@property() public helper?: string; @property() public helper?: string;
@state() private _opened = false; private _getItemsMemoized = memoizeOne(
(
entityId: string | string[] | undefined,
hideAttributes: string[] | undefined,
hass: HomeAssistant
): PickerComboBoxItem[] => {
const entityIds = entityId ? ensureArray(entityId) : [];
const options: PickerComboBoxItem[] = [];
const optionsSet = new Set<string>();
@query("ha-combo-box", true) private _comboBox!: HaComboBox; for (const id of entityIds) {
const stateObj = hass.states[id];
protected shouldUpdate(changedProps: PropertyValues) {
return !(!changedProps.has("_opened") && this._opened);
}
protected updated(changedProps: PropertyValues) {
if (
(changedProps.has("_opened") && this._opened) ||
changedProps.has("entityId") ||
changedProps.has("attribute")
) {
const entityIds = this.entityId ? ensureArray(this.entityId) : [];
const entitiesOptions = entityIds.map<AttributeOption[]>((entityId) => {
const stateObj = this.hass.states[entityId];
if (!stateObj) { if (!stateObj) {
return []; continue;
} }
const attributes = Object.keys(stateObj.attributes).filter( const attributes = Object.keys(stateObj.attributes).filter(
(a) => !this.hideAttributes?.includes(a) (a) => !hideAttributes?.includes(a)
); );
return attributes.map((a) => ({ for (const attribute of attributes) {
value: a, if (!optionsSet.has(attribute)) {
label: this.hass.formatEntityAttributeName(stateObj, a), optionsSet.add(attribute);
})); options.push({
id: attribute,
primary: hass.formatEntityAttributeName(stateObj, attribute),
sorting_label: attribute,
}); });
const options: AttributeOption[] = [];
const optionsSet = new Set<string>();
for (const entityOptions of entitiesOptions) {
for (const option of entityOptions) {
if (!optionsSet.has(option.value)) {
optionsSet.add(option.value);
options.push(option);
} }
} }
} }
(this._comboBox as any).filteredItems = options; return options;
}
} }
);
private _getItems = () =>
this._getItemsMemoized(this.entityId, this.hideAttributes, this.hass);
protected render() { protected render() {
if (!this.hass) { if (!this.hass) {
@@ -94,10 +82,9 @@ class HaEntityAttributePicker extends LitElement {
} }
return html` return html`
<ha-combo-box <ha-generic-picker
.hass=${this.hass} .hass=${this.hass}
.value=${this.value} .value=${this.value}
.autofocus=${this.autofocus}
.label=${this.label ?? .label=${this.label ??
this.hass.localize( this.hass.localize(
"ui.components.entity.entity-attribute-picker.attribute" "ui.components.entity.entity-attribute-picker.attribute"
@@ -106,38 +93,21 @@ class HaEntityAttributePicker extends LitElement {
.required=${this.required} .required=${this.required}
.helper=${this.helper} .helper=${this.helper}
.allowCustomValue=${this.allowCustomValue} .allowCustomValue=${this.allowCustomValue}
item-id-path="value" .getItems=${this._getItems}
item-value-path="value"
item-label-path="label"
@opened-changed=${this._openedChanged}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
> >
</ha-combo-box> </ha-generic-picker>
`; `;
} }
private get _value() {
return this.value || "";
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _valueChanged(ev: ValueChangedEvent<string>) { private _valueChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation(); ev.stopPropagation();
const newValue = ev.detail.value; const newValue = ev.detail.value;
if (newValue !== this._value) { if (newValue !== this.value) {
this._setValue(newValue); this.value = newValue;
} fireEvent(this, "value-changed", { value: newValue });
}
private _setValue(value: string) {
this.value = value;
setTimeout(() => {
fireEvent(this, "value-changed", { value });
fireEvent(this, "change"); fireEvent(this, "change");
}, 0); }
} }
} }

View File

@@ -1,15 +1,11 @@
import "@material/mwc-menu/mwc-menu-surface";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js"; import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { IFuseOptions } from "fuse.js";
import Fuse from "fuse.js";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query } from "lit/decorators";
import { repeat } from "lit/directives/repeat"; import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array"; import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
import type { EntityNameItem } from "../../common/entity/compute_entity_name_display"; import type { EntityNameItem } from "../../common/entity/compute_entity_name_display";
import { getEntityContext } from "../../common/entity/context/get_entity_context"; import { getEntityContext } from "../../common/entity/context/get_entity_context";
import type { EntityNameType } from "../../common/translations/entity-state"; import type { EntityNameType } from "../../common/translations/entity-state";
@@ -18,20 +14,18 @@ import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../chips/ha-assist-chip"; import "../chips/ha-assist-chip";
import "../chips/ha-chip-set"; import "../chips/ha-chip-set";
import "../chips/ha-input-chip"; import "../chips/ha-input-chip";
import "../ha-combo-box"; import "../ha-combo-box-item";
import type { HaComboBox } from "../ha-combo-box"; import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-input-helper-text"; import "../ha-input-helper-text";
import {
NO_ITEMS_AVAILABLE_ID,
type PickerComboBoxItem,
} from "../ha-picker-combo-box";
import "../ha-sortable"; import "../ha-sortable";
interface EntityNameOption { const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
primary: string; <ha-combo-box-item type="button" compact>
secondary?: string;
field_label: string;
value: string;
}
const rowRenderer: ComboBoxLitRenderer<EntityNameOption> = (item) => html`
<ha-combo-box-item type="button">
<span slot="headline">${item.primary}</span> <span slot="headline">${item.primary}</span>
${item.secondary ${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>` ? html`<span slot="supporting-text">${item.secondary}</span>`
@@ -79,11 +73,7 @@ export class HaEntityNamePicker extends LitElement {
@property({ type: Boolean, reflect: true }) public disabled = false; @property({ type: Boolean, reflect: true }) public disabled = false;
@query(".container", true) private _container?: HTMLDivElement; @query("ha-generic-picker", true) private _picker?: HaGenericPicker;
@query("ha-combo-box", true) private _comboBox!: HaComboBox;
@state() private _opened = false;
private _editIndex?: number; private _editIndex?: number;
@@ -115,7 +105,7 @@ export class HaEntityNamePicker extends LitElement {
return options; return options;
}); });
private _getOptions = memoizeOne((entityId?: string) => { private _getItems = memoizeOne((entityId?: string) => {
if (!entityId) { if (!entityId) {
return []; return [];
} }
@@ -124,7 +114,7 @@ export class HaEntityNamePicker extends LitElement {
const items = ( const items = (
["entity", "device", "area", "floor"] as const ["entity", "device", "area", "floor"] as const
).map<EntityNameOption>((name) => { ).map<PickerComboBoxItem>((name) => {
const stateObj = this.hass.states[entityId]; const stateObj = this.hass.states[entityId];
const isValid = types.has(name); const isValid = types.has(name);
const primary = this.hass.localize( const primary = this.hass.localize(
@@ -137,25 +127,39 @@ export class HaEntityNamePicker extends LitElement {
`ui.components.entity.entity-name-picker.types.${name}_missing` as LocalizeKeys `ui.components.entity.entity-name-picker.types.${name}_missing` as LocalizeKeys
)) || "-"; )) || "-";
const id = formatOptionValue({ type: name });
return { return {
id,
primary, primary,
secondary, secondary,
field_label: primary, search_labels: {
value: formatOptionValue({ type: name }), primary,
secondary: secondary || null,
id,
},
sorting_label: primary,
}; };
}); });
return items; return items;
}); });
private _customNameOption = memoizeOne((text: string) => ({ private _customNameOption = memoizeOne(
(text: string): PickerComboBoxItem => ({
id: formatOptionValue({ type: "text", text }),
primary: this.hass.localize( primary: this.hass.localize(
"ui.components.entity.entity-name-picker.custom_name" "ui.components.entity.entity-name-picker.custom_name"
), ),
secondary: `"${text}"`, secondary: `"${text}"`,
field_label: text, search_labels: {
value: formatOptionValue({ type: "text", text }), primary: text,
})); secondary: `"${text}"`,
id: formatOptionValue({ type: "text", text }),
},
sorting_label: text,
})
);
private _formatItem = (item: EntityNameItem) => { private _formatItem = (item: EntityNameItem) => {
if (item.type === "text") { if (item.type === "text") {
@@ -171,12 +175,29 @@ export class HaEntityNamePicker extends LitElement {
protected render() { protected render() {
const value = this._items; const value = this._items;
const options = this._getOptions(this.entityId);
const validTypes = this._validTypes(this.entityId); const validTypes = this._validTypes(this.entityId);
return html` return html`
${this.label ? html`<label>${this.label}</label>` : nothing} ${this.label ? html`<label>${this.label}</label>` : nothing}
<div class="container"> <ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.getItems=${this._getFilteredItems}
.getAdditionalItems=${this._getAdditionalItems}
.rowRenderer=${rowRenderer}
.searchFn=${this._searchFn}
.notFoundLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.no_match"
)}
.value=${this._getPickerValue()}
allow-custom-value
.customValueLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.custom_name"
)}
@value-changed=${this._pickerValueChanged}
>
<div slot="field" class="container">
<ha-sortable <ha-sortable
no-style no-style
@item-moved=${this._moveItem} @item-moved=${this._moveItem}
@@ -226,33 +247,8 @@ export class HaEntityNamePicker extends LitElement {
`} `}
</ha-chip-set> </ha-chip-set>
</ha-sortable> </ha-sortable>
<mwc-menu-surface
.open=${this._opened}
@closed=${this._onClosed}
@opened=${this._onOpened}
@input=${stopPropagation}
.anchor=${this._container}
>
<ha-combo-box
.hass=${this.hass}
.value=${""}
.autofocus=${this.autofocus}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.items=${options}
allow-custom-value
item-id-path="value"
item-value-path="value"
item-label-path="field_label"
.renderer=${rowRenderer}
@opened-changed=${this._openedChanged}
@value-changed=${this._comboBoxValueChanged}
@filter-changed=${this._filterChanged}
>
</ha-combo-box>
</mwc-menu-surface>
</div> </div>
</ha-generic-picker>
${this._renderHelper()} ${this._renderHelper()}
`; `;
} }
@@ -267,32 +263,22 @@ export class HaEntityNamePicker extends LitElement {
: nothing; : nothing;
} }
private _onClosed(ev) { private async _addItem(ev: Event) {
ev.stopPropagation(); ev.stopPropagation();
this._opened = false;
this._editIndex = undefined; this._editIndex = undefined;
await this.updateComplete;
await this._picker?.open();
} }
private async _onOpened(ev) { private async _editItem(ev: Event) {
if (!this._opened) {
return;
}
ev.stopPropagation(); ev.stopPropagation();
this._opened = true; const idx = parseInt(
await this._comboBox?.focus(); (ev.currentTarget as HTMLElement).dataset.idx || "",
await this._comboBox?.open(); 10
} );
private async _addItem(ev) {
ev.stopPropagation();
this._opened = true;
}
private async _editItem(ev) {
ev.stopPropagation();
const idx = parseInt(ev.currentTarget.dataset.idx, 10);
this._editIndex = idx; this._editIndex = idx;
this._opened = true; await this.updateComplete;
await this._picker?.open();
} }
private get _items(): EntityNameItem[] { private get _items(): EntityNameItem[] {
@@ -322,78 +308,80 @@ export class HaEntityNamePicker extends LitElement {
} }
); );
private _openedChanged(ev: ValueChangedEvent<boolean>) { private _getPickerValue(): string | undefined {
const open = ev.detail.value; if (this._editIndex != null) {
if (open) { const item = this._items[this._editIndex];
const options = this._comboBox.items || []; return item ? formatOptionValue(item) : undefined;
}
return undefined;
}
const initialItem = private _getFilteredItems = (
searchString?: string,
_section?: string
): PickerComboBoxItem[] => {
const items = this._getItems(this.entityId);
const currentItem =
this._editIndex != null ? this._items[this._editIndex] : undefined; this._editIndex != null ? this._items[this._editIndex] : undefined;
const currentValue = currentItem ? formatOptionValue(currentItem) : "";
const initialValue = initialItem ? formatOptionValue(initialItem) : "";
const filteredItems = this._filterSelectedOptions(options, initialValue);
if (initialItem?.type === "text" && initialItem.text) {
filteredItems.push(this._customNameOption(initialItem.text));
}
this._comboBox.filteredItems = filteredItems;
this._comboBox.setInputValue(initialValue);
} else {
this._opened = false;
this._comboBox.setInputValue("");
}
}
private _filterSelectedOptions = (
options: EntityNameOption[],
current?: string
) => {
const items = this._items;
const excludedValues = new Set( const excludedValues = new Set(
items this._items
.filter((item) => UNIQUE_TYPES.has(item.type)) .filter((item) => UNIQUE_TYPES.has(item.type))
.map((item) => formatOptionValue(item)) .map((item) => formatOptionValue(item))
); );
const filteredOptions = options.filter( const filteredItems = items.filter(
(option) => !excludedValues.has(option.value) || option.value === current (item) => !excludedValues.has(item.id) || item.id === currentValue
); );
return filteredOptions;
// When editing an existing text item, include it in the base items
if (currentItem?.type === "text" && currentItem.text && !searchString) {
filteredItems.push(this._customNameOption(currentItem.text));
}
return filteredItems;
}; };
private _filterChanged(ev: ValueChangedEvent<string>) { private _getAdditionalItems = (
const input = ev.detail.value; searchString?: string
const filter = input?.toLowerCase() || ""; ): PickerComboBoxItem[] => {
const options = this._comboBox.items || []; if (!searchString) {
return [];
}
const currentItem = const currentItem =
this._editIndex != null ? this._items[this._editIndex] : undefined; this._editIndex != null ? this._items[this._editIndex] : undefined;
const currentValue = currentItem ? formatOptionValue(currentItem) : ""; // Don't add if it's the same as the current item being edited
if (
let filteredItems = this._filterSelectedOptions(options, currentValue); currentItem?.type === "text" &&
currentItem.text &&
if (!filter) { currentItem.text === searchString
this._comboBox.filteredItems = filteredItems; ) {
return; return [];
} }
const fuseOptions: IFuseOptions<EntityNameOption> = { // Always return custom name option when there's a search string
keys: ["primary", "secondary", "value"], // This prevents "No matching items found" from showing
isCaseSensitive: false, return [this._customNameOption(searchString)];
minMatchCharLength: Math.min(filter.length, 2),
threshold: 0.2,
ignoreDiacritics: true,
}; };
const fuse = new Fuse(filteredItems, fuseOptions); private _searchFn = (
filteredItems = fuse.search(filter).map((result) => result.item); search: string,
filteredItems.push(this._customNameOption(input)); filteredItems: PickerComboBoxItem[],
this._comboBox.filteredItems = filteredItems; _allItems: PickerComboBoxItem[]
): PickerComboBoxItem[] => {
// Remove NO_ITEMS_AVAILABLE_ID if we have additional items (custom name option)
// This prevents "No matching items found" from showing when custom values are allowed
const hasAdditionalItems = this._getAdditionalItems(search).length > 0;
if (hasAdditionalItems) {
return filteredItems.filter(
(item) => typeof item !== "string" || item !== NO_ITEMS_AVAILABLE_ID
);
} }
return filteredItems;
};
private async _moveItem(ev: CustomEvent) { private async _moveItem(ev: CustomEvent) {
ev.stopPropagation(); ev.stopPropagation();
@@ -403,25 +391,21 @@ export class HaEntityNamePicker extends LitElement {
const element = newValue.splice(oldIndex, 1)[0]; const element = newValue.splice(oldIndex, 1)[0];
newValue.splice(newIndex, 0, element); newValue.splice(newIndex, 0, element);
this._setValue(newValue); this._setValue(newValue);
await this.updateComplete;
this._filterChanged({ detail: { value: "" } } as ValueChangedEvent<string>);
} }
private async _removeItem(ev) { private async _removeItem(ev: Event) {
ev.stopPropagation(); ev.stopPropagation();
const value = [...this._items]; const value = [...this._items];
const idx = parseInt(ev.target.dataset.idx, 10); const idx = parseInt((ev.target as HTMLElement).dataset.idx || "", 10);
value.splice(idx, 1); value.splice(idx, 1);
this._setValue(value); this._setValue(value);
await this.updateComplete;
this._filterChanged({ detail: { value: "" } } as ValueChangedEvent<string>);
} }
private _comboBoxValueChanged(ev: ValueChangedEvent<string>): void { private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
ev.stopPropagation(); ev.stopPropagation();
const value = ev.detail.value; const value = ev.detail.value;
if (this.disabled || value === "") { if (this.disabled || !value) {
return; return;
} }
@@ -431,11 +415,16 @@ export class HaEntityNamePicker extends LitElement {
if (this._editIndex != null) { if (this._editIndex != null) {
newValue[this._editIndex] = item; newValue[this._editIndex] = item;
this._editIndex = undefined;
} else { } else {
newValue.push(item); newValue.push(item);
} }
this._setValue(newValue); this._setValue(newValue);
if (this._picker) {
this._picker.value = undefined;
}
} }
private _setValue(value: EntityNameItem[]) { private _setValue(value: EntityNameItem[]) {
@@ -497,10 +486,6 @@ export class HaEntityNamePicker extends LitElement {
order: 1; order: 1;
} }
mwc-menu-surface {
--mdc-menu-min-width: 100%;
}
ha-chip-set { ha-chip-set {
padding: var(--ha-space-2) var(--ha-space-2); padding: var(--ha-space-2) var(--ha-space-2);
} }

View File

@@ -277,12 +277,13 @@ export class HaEntityPicker extends LitElement {
.disabled=${this.disabled} .disabled=${this.disabled}
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomEntity} .allowCustomValue=${this.allowCustomEntity}
.required=${this.required}
.label=${this.label} .label=${this.label}
.placeholder=${placeholder}
.helper=${this.helper} .helper=${this.helper}
.value=${this.addButton ? undefined : this.value}
.searchLabel=${this.searchLabel} .searchLabel=${this.searchLabel}
.notFoundLabel=${this._notFoundLabel} .notFoundLabel=${this._notFoundLabel}
.placeholder=${placeholder}
.value=${this.addButton ? undefined : this.value}
.rowRenderer=${this._rowRenderer} .rowRenderer=${this._rowRenderer}
.getItems=${this._getItems} .getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems} .getAdditionalItems=${this._getAdditionalItems}
@@ -290,6 +291,7 @@ export class HaEntityPicker extends LitElement {
.searchFn=${this._searchFn} .searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer} .valueRenderer=${this._valueRenderer}
.searchKeys=${entityComboBoxKeys} .searchKeys=${entityComboBoxKeys}
use-top-label
.addButtonLabel=${this.addButton .addButtonLabel=${this.addButton
? this.hass.localize("ui.components.entity.entity-picker.add") ? this.hass.localize("ui.components.entity.entity-picker.add")
: undefined} : undefined}

View File

@@ -1,16 +1,11 @@
import "@material/mwc-menu/mwc-menu-surface";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js"; import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { IFuseOptions } from "fuse.js";
import Fuse from "fuse.js";
import type { HassEntity } from "home-assistant-js-websocket"; import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query } from "lit/decorators";
import { repeat } from "lit/directives/repeat"; import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array"; import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { computeDomain } from "../../common/entity/compute_domain"; import { computeDomain } from "../../common/entity/compute_domain";
import { import {
STATE_DISPLAY_SPECIAL_CONTENT, STATE_DISPLAY_SPECIAL_CONTENT,
@@ -20,21 +15,13 @@ import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../chips/ha-assist-chip"; import "../chips/ha-assist-chip";
import "../chips/ha-chip-set"; import "../chips/ha-chip-set";
import "../chips/ha-input-chip"; import "../chips/ha-input-chip";
import "../ha-combo-box"; import "../ha-combo-box-item";
import type { HaComboBox } from "../ha-combo-box"; import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-input-helper-text";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
import "../ha-sortable"; import "../ha-sortable";
interface StateContentOption {
primary: string;
value: string;
}
const rowRenderer: ComboBoxLitRenderer<StateContentOption> = (item) => html`
<ha-combo-box-item type="button">
<span slot="headline">${item.primary}</span>
</ha-combo-box-item>
`;
const HIDDEN_ATTRIBUTES = [ const HIDDEN_ATTRIBUTES = [
"access_token", "access_token",
"available_modes", "available_modes",
@@ -111,63 +98,88 @@ export class HaStateContentPicker extends LitElement {
@property() public helper?: string; @property() public helper?: string;
@query(".container", true) private _container?: HTMLDivElement; @query("ha-generic-picker", true) private _picker?: HaGenericPicker;
@query("ha-combo-box", true) private _comboBox!: HaComboBox;
@state() private _opened = false;
private _editIndex?: number; private _editIndex?: number;
private _options = memoizeOne( private _getItems = memoizeOne(
(entityId?: string, stateObj?: HassEntity, allowName?: boolean) => { (entityId?: string, stateObj?: HassEntity, allowName?: boolean) => {
const domain = entityId ? computeDomain(entityId) : undefined; const domain = entityId ? computeDomain(entityId) : undefined;
return [ const items: PickerComboBoxItem[] = [
{ {
id: "state",
primary: this.hass.localize( primary: this.hass.localize(
"ui.components.state-content-picker.state" "ui.components.state-content-picker.state"
), ),
value: "state", sorting_label: this.hass.localize(
"ui.components.state-content-picker.state"
),
}, },
...(allowName ...(allowName
? [ ? [
{ {
id: "name",
primary: this.hass.localize( primary: this.hass.localize(
"ui.components.state-content-picker.name" "ui.components.state-content-picker.name"
), ),
value: "name", sorting_label: this.hass.localize(
}, "ui.components.state-content-picker.name"
),
} satisfies PickerComboBoxItem,
] ]
: []), : []),
{ {
id: "last_changed",
primary: this.hass.localize( primary: this.hass.localize(
"ui.components.state-content-picker.last_changed" "ui.components.state-content-picker.last_changed"
), ),
value: "last_changed", sorting_label: this.hass.localize(
"ui.components.state-content-picker.last_changed"
),
}, },
{ {
id: "last_updated",
primary: this.hass.localize( primary: this.hass.localize(
"ui.components.state-content-picker.last_updated" "ui.components.state-content-picker.last_updated"
), ),
value: "last_updated", sorting_label: this.hass.localize(
"ui.components.state-content-picker.last_updated"
),
}, },
...(domain ...(domain
? STATE_DISPLAY_SPECIAL_CONTENT.filter((content) => ? STATE_DISPLAY_SPECIAL_CONTENT.filter((content) =>
STATE_DISPLAY_SPECIAL_CONTENT_DOMAINS[domain]?.includes(content) STATE_DISPLAY_SPECIAL_CONTENT_DOMAINS[domain]?.includes(content)
).map((content) => ({ ).map(
(content) =>
({
id: content,
primary: this.hass.localize( primary: this.hass.localize(
`ui.components.state-content-picker.${content}` `ui.components.state-content-picker.${content}`
), ),
value: content, sorting_label: this.hass.localize(
})) `ui.components.state-content-picker.${content}`
),
}) satisfies PickerComboBoxItem
)
: []), : []),
...Object.keys(stateObj?.attributes ?? {}) ...Object.keys(stateObj?.attributes ?? {})
.filter((a) => !HIDDEN_ATTRIBUTES.includes(a)) .filter((a) => !HIDDEN_ATTRIBUTES.includes(a))
.map((attribute) => ({ .map(
primary: this.hass.formatEntityAttributeName(stateObj!, attribute), (attribute) =>
value: attribute, ({
})), id: attribute,
] satisfies StateContentOption[]; primary: this.hass.formatEntityAttributeName(
stateObj!,
attribute
),
sorting_label: this.hass.formatEntityAttributeName(
stateObj!,
attribute
),
}) satisfies PickerComboBoxItem
),
];
return items;
} }
); );
@@ -178,11 +190,23 @@ export class HaStateContentPicker extends LitElement {
? this.hass.states[this.entityId] ? this.hass.states[this.entityId]
: undefined; : undefined;
const options = this._options(this.entityId, stateObj, this.allowName);
return html` return html`
${this.label ? html`<label>${this.label}</label>` : nothing} ${this.label ? html`<label>${this.label}</label>` : nothing}
<div class="container ${this.disabled ? "disabled" : ""}"> <ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.value=${this._getPickerValue()}
.getItems=${this._getFilteredItems}
.getAdditionalItems=${this._getAdditionalItems}
.notFoundLabel=${this.hass.localize("ui.components.combo-box.no_match")}
allow-custom-value
.customValueLabel=${this.hass.localize(
"ui.components.entity.entity-state-content-picker.custom_state"
)}
@value-changed=${this._pickerValueChanged}
>
<div slot="field" class="container">
<ha-sortable <ha-sortable
no-style no-style
@item-moved=${this._moveItem} @item-moved=${this._moveItem}
@@ -195,7 +219,7 @@ export class HaStateContentPicker extends LitElement {
this._value, this._value,
(item) => item, (item) => item,
(item: string, idx) => { (item: string, idx) => {
const label = options.find((o) => o.value === item)?.primary; const label = this._getItemLabel(item, stateObj);
const isValid = !!label; const isValid = !!label;
return html` return html`
<ha-input-chip <ha-input-chip
@@ -231,69 +255,58 @@ export class HaStateContentPicker extends LitElement {
`} `}
</ha-chip-set> </ha-chip-set>
</ha-sortable> </ha-sortable>
<mwc-menu-surface
.open=${this._opened}
@closed=${this._onClosed}
@opened=${this._onOpened}
@input=${stopPropagation}
.anchor=${this._container}
>
<ha-combo-box
.hass=${this.hass}
.value=${""}
.autofocus=${this.autofocus}
.disabled=${this.disabled || !this.entityId}
.required=${this.required && !value.length}
.helper=${this.helper}
.items=${options}
allow-custom-value
item-id-path="value"
item-value-path="value"
item-label-path="primary"
.renderer=${rowRenderer}
@opened-changed=${this._openedChanged}
@value-changed=${this._comboBoxValueChanged}
@filter-changed=${this._filterChanged}
>
</ha-combo-box>
</mwc-menu-surface>
</div> </div>
</ha-generic-picker>
${this._renderHelper()}
`; `;
} }
private _onClosed(ev) { private _renderHelper() {
return this.helper
? html`
<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>
`
: nothing;
}
private async _addItem(ev: Event) {
ev.stopPropagation(); ev.stopPropagation();
this._opened = false;
this._editIndex = undefined; this._editIndex = undefined;
await this.updateComplete;
await this._picker?.open();
} }
private async _onOpened(ev) { private async _editItem(ev: Event) {
if (!this._opened) {
return;
}
ev.stopPropagation(); ev.stopPropagation();
this._opened = true; const idx = parseInt(
await this._comboBox?.focus(); (ev.currentTarget as HTMLElement).dataset.idx || "",
await this._comboBox?.open(); 10
} );
private async _addItem(ev) {
ev.stopPropagation();
this._opened = true;
}
private async _editItem(ev) {
ev.stopPropagation();
const idx = parseInt(ev.currentTarget.dataset.idx, 10);
this._editIndex = idx; this._editIndex = idx;
this._opened = true; await this.updateComplete;
await this._picker?.open();
} }
private get _value() { private get _value() {
return !this.value ? [] : ensureArray(this.value); return !this.value ? [] : ensureArray(this.value);
} }
private _getItemLabel = memoizeOne(
(value: string, stateObj?: HassEntity): string | undefined => {
const stateObjForItems = this.entityId
? this.hass.states[this.entityId]
: stateObj;
const items = this._getItems(
this.entityId,
stateObjForItems,
this.allowName
);
return items.find((item) => item.id === value)?.primary;
}
);
private _toValue = memoizeOne((value: string[]): typeof this.value => { private _toValue = memoizeOne((value: string[]): typeof this.value => {
if (value.length === 0) { if (value.length === 0) {
return undefined; return undefined;
@@ -304,64 +317,88 @@ export class HaStateContentPicker extends LitElement {
return value; return value;
}); });
private _openedChanged(ev: ValueChangedEvent<boolean>) { private _getPickerValue(): string | undefined {
const open = ev.detail.value; if (this._editIndex != null) {
if (open) { return this._value[this._editIndex];
const options = this._comboBox.items || [];
const initialValue =
this._editIndex != null ? this._value[this._editIndex] : "";
const filteredItems = this._filterSelectedOptions(options, initialValue);
this._comboBox.filteredItems = filteredItems;
this._comboBox.setInputValue(initialValue);
} else {
this._opened = false;
} }
return undefined;
} }
private _filterSelectedOptions = ( private _customValueOption = memoizeOne(
options: StateContentOption[], (text: string): PickerComboBoxItem => ({
current?: string id: text,
) => { primary: this.hass.localize(
"ui.components.entity.entity-state-content-picker.custom_state"
),
secondary: `"${text}"`,
search_labels: {
primary: text,
secondary: `"${text}"`,
id: text,
},
sorting_label: text,
})
);
private _getFilteredItems = (
searchString?: string,
_section?: string
): PickerComboBoxItem[] => {
const stateObj = this.entityId
? this.hass.states[this.entityId]
: undefined;
const items = this._getItems(this.entityId, stateObj, this.allowName);
const currentValue =
this._editIndex != null ? this._value[this._editIndex] : undefined;
const value = this._value; const value = this._value;
return options.filter( const filteredItems = items.filter(
(option) => !value.includes(option.value) || option.value === current (item) => !value.includes(item.id) || item.id === currentValue
); );
// When editing an existing custom value, include it in the base items
if (
currentValue &&
!items.find((item) => item.id === currentValue) &&
!searchString
) {
filteredItems.push(this._customValueOption(currentValue));
}
return filteredItems;
}; };
private _filterChanged(ev: ValueChangedEvent<string>) { private _getAdditionalItems = (
const input = ev.detail.value; searchString?: string
const filter = input?.toLowerCase() || ""; ): PickerComboBoxItem[] => {
const options = this._comboBox.items || []; if (!searchString) {
return [];
}
const currentValue = const currentValue =
this._editIndex != null ? this._value[this._editIndex] : ""; this._editIndex != null ? this._value[this._editIndex] : undefined;
this._comboBox.filteredItems = this._filterSelectedOptions( // Don't add if it's the same as the current item being edited
options, if (currentValue && currentValue === searchString) {
currentValue return [];
);
if (!filter) {
return;
} }
const fuseOptions: IFuseOptions<StateContentOption> = { // Check if the search string matches an existing item
keys: ["primary", "secondary", "value"], const stateObj = this.entityId
isCaseSensitive: false, ? this.hass.states[this.entityId]
minMatchCharLength: Math.min(filter.length, 2), : undefined;
threshold: 0.2, const items = this._getItems(this.entityId, stateObj, this.allowName);
ignoreDiacritics: true, const existingItem = items.find((item) => item.id === searchString);
// Only return custom value option if it doesn't match an existing item
if (!existingItem) {
return [this._customValueOption(searchString)];
}
return [];
}; };
const fuse = new Fuse(this._comboBox.filteredItems, fuseOptions);
const filteredItems = fuse.search(filter).map((result) => result.item);
this._comboBox.filteredItems = filteredItems;
}
private async _moveItem(ev: CustomEvent) { private async _moveItem(ev: CustomEvent) {
ev.stopPropagation(); ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail; const { oldIndex, newIndex } = ev.detail;
@@ -370,25 +407,21 @@ export class HaStateContentPicker extends LitElement {
const element = newValue.splice(oldIndex, 1)[0]; const element = newValue.splice(oldIndex, 1)[0];
newValue.splice(newIndex, 0, element); newValue.splice(newIndex, 0, element);
this._setValue(newValue); this._setValue(newValue);
await this.updateComplete;
this._filterChanged({ detail: { value: "" } } as ValueChangedEvent<string>);
} }
private async _removeItem(ev) { private async _removeItem(ev: Event) {
ev.stopPropagation(); ev.stopPropagation();
const value = [...this._value]; const value = [...this._value];
const idx = parseInt(ev.target.dataset.idx, 10); const idx = parseInt((ev.target as HTMLElement).dataset.idx || "", 10);
value.splice(idx, 1); value.splice(idx, 1);
this._setValue(value); this._setValue(value);
await this.updateComplete;
this._filterChanged({ detail: { value: "" } } as ValueChangedEvent<string>);
} }
private _comboBoxValueChanged(ev: ValueChangedEvent<string>): void { private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
ev.stopPropagation(); ev.stopPropagation();
const value = ev.detail.value; const value = ev.detail.value;
if (this.disabled || value === "") { if (this.disabled || !value) {
return; return;
} }
@@ -396,11 +429,16 @@ export class HaStateContentPicker extends LitElement {
if (this._editIndex != null) { if (this._editIndex != null) {
newValue[this._editIndex] = value; newValue[this._editIndex] = value;
this._editIndex = undefined;
} else { } else {
newValue.push(value); newValue.push(value);
} }
this._setValue(newValue); this._setValue(newValue);
if (this._picker) {
this._picker.value = undefined;
}
} }
private _setValue(value: string[]) { private _setValue(value: string[]) {
@@ -442,7 +480,7 @@ export class HaStateContentPicker extends LitElement {
height 180ms ease-in-out, height 180ms ease-in-out,
background-color 180ms ease-in-out; background-color 180ms ease-in-out;
} }
.container.disabled:after { :host([disabled]) .container:after {
background-color: var( background-color: var(
--mdc-text-field-disabled-line-color, --mdc-text-field-disabled-line-color,
rgba(0, 0, 0, 0.42) rgba(0, 0, 0, 0.42)
@@ -462,10 +500,6 @@ export class HaStateContentPicker extends LitElement {
order: 1; order: 1;
} }
mwc-menu-surface {
--mdc-menu-min-width: 100%;
}
ha-chip-set { ha-chip-set {
padding: var(--ha-space-2) var(--ha-space-2); padding: var(--ha-space-2) var(--ha-space-2);
} }
@@ -486,6 +520,11 @@ export class HaStateContentPicker extends LitElement {
.sortable-drag { .sortable-drag {
cursor: grabbing; cursor: grabbing;
} }
ha-input-helper-text {
display: block;
margin: var(--ha-space-2) 0 0;
}
`; `;
} }

View File

@@ -1,27 +1,22 @@
import type { PropertyValues } from "lit";
import { LitElement, html, nothing } from "lit"; import { LitElement, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array"; import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { getStates } from "../../common/entity/get_states"; import { getStates } from "../../common/entity/get_states";
import type { HomeAssistant, ValueChangedEvent } from "../../types"; import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-combo-box"; import "../ha-generic-picker";
import type { HaComboBox } from "../ha-combo-box"; import type { PickerComboBoxItem } from "../ha-picker-combo-box";
interface StateOption {
value: string;
label: string;
}
@customElement("ha-entity-state-picker") @customElement("ha-entity-state-picker")
class HaEntityStatePicker extends LitElement { export class HaEntityStatePicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId?: string | string[]; @property({ attribute: false }) public entityId?: string | string[];
@property() public attribute?: string; @property() public attribute?: string;
@property({ attribute: false }) public extraOptions?: any[]; @property({ attribute: false }) public extraOptions?: PickerComboBoxItem[];
// eslint-disable-next-line lit/no-native-attributes // eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false; @property({ type: Boolean }) public autofocus = false;
@@ -42,59 +37,76 @@ class HaEntityStatePicker extends LitElement {
@property() public helper?: string; @property() public helper?: string;
@state() private _opened = false; private _getItems = memoizeOne(
(
hass: HomeAssistant,
entityId: string | string[] | undefined,
attribute: string | undefined,
hideStates: string[] | undefined,
extraOptions: PickerComboBoxItem[] | undefined
): PickerComboBoxItem[] => {
const entityIds = entityId ? ensureArray(entityId) : [];
@query("ha-combo-box", true) private _comboBox!: HaComboBox; const entitiesOptions = entityIds.map<PickerComboBoxItem[]>(
(entityIdItem) => {
protected shouldUpdate(changedProps: PropertyValues) { const stateObj = hass.states[entityIdItem] || {
return !(!changedProps.has("_opened") && this._opened); entity_id: entityIdItem,
}
protected updated(changedProps: PropertyValues) {
if (
(changedProps.has("_opened") && this._opened) ||
changedProps.has("entityId") ||
changedProps.has("attribute") ||
changedProps.has("extraOptions")
) {
const entityIds = this.entityId ? ensureArray(this.entityId) : [];
const entitiesOptions = entityIds.map<StateOption[]>((entityId) => {
const stateObj = this.hass.states[entityId] || {
entity_id: entityId,
attributes: {}, attributes: {},
}; };
const states = getStates(this.hass, stateObj, this.attribute).filter( const states = getStates(hass, stateObj, attribute).filter(
(s) => !this.hideStates?.includes(s) (s) => !hideStates?.includes(s)
); );
return states.map((s) => ({ return states
value: s, .map((s) => {
label: this.attribute const primary = attribute
? this.hass.formatEntityAttributeValue(stateObj, this.attribute, s) ? hass.formatEntityAttributeValue(stateObj, attribute, s)
: this.hass.formatEntityState(stateObj, s), : hass.formatEntityState(stateObj, s);
})); return {
}); id: s,
primary,
sorting_label: primary,
};
})
.filter((option) => option.id && option.primary);
}
);
const options: StateOption[] = []; const options: PickerComboBoxItem[] = [];
const optionsSet = new Set<string>(); const optionsSet = new Set<string>();
for (const entityOptions of entitiesOptions) { for (const entityOptions of entitiesOptions) {
for (const option of entityOptions) { for (const option of entityOptions) {
if (!optionsSet.has(option.value)) { if (!optionsSet.has(option.id)) {
optionsSet.add(option.value); optionsSet.add(option.id);
options.push(option); options.push(option);
} }
} }
} }
if (this.extraOptions) { if (extraOptions) {
options.unshift(...this.extraOptions); // Filter out any extraOptions with empty primary or id fields
const validExtraOptions = extraOptions.filter(
(option) => option.id && option.primary
);
options.unshift(...validExtraOptions);
} }
(this._comboBox as any).filteredItems = options; return options;
}
} }
);
private _getFilteredItems = (
_searchString?: string,
_section?: string
): PickerComboBoxItem[] =>
this._getItems(
this.hass,
this.entityId,
this.attribute,
this.hideStates,
this.extraOptions
);
protected render() { protected render() {
if (!this.hass) { if (!this.hass) {
@@ -102,48 +114,39 @@ class HaEntityStatePicker extends LitElement {
} }
return html` return html`
<ha-combo-box <ha-generic-picker
.hass=${this.hass} .hass=${this.hass}
.value=${this._value} .allowCustomValue=${this.allowCustomValue}
.disabled=${this.disabled || !this.entityId}
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
.required=${this.required}
.label=${this.label ?? .label=${this.label ??
this.hass.localize("ui.components.entity.entity-state-picker.state")} this.hass.localize("ui.components.entity.entity-state-picker.state")}
.disabled=${this.disabled || !this.entityId}
.required=${this.required}
.helper=${this.helper} .helper=${this.helper}
.allowCustomValue=${this.allowCustomValue} .value=${this.value}
item-id-path="value" .getItems=${this._getFilteredItems}
item-value-path="value" .notFoundLabel=${this.hass.localize("ui.components.combo-box.no_match")}
item-label-path="label" .customValueLabel=${this.hass.localize(
@opened-changed=${this._openedChanged} "ui.components.entity.entity-state-picker.add_custom_state"
)}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
> >
</ha-combo-box> </ha-generic-picker>
`; `;
} }
private get _value() {
return this.value || "";
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _valueChanged(ev: ValueChangedEvent<string>) { private _valueChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation(); ev.stopPropagation();
const newValue = ev.detail.value; const newValue = ev.detail.value;
if (newValue !== this._value) { if (newValue !== this.value) {
this._setValue(newValue); this._setValue(newValue);
} }
} }
private _setValue(value: string) { private _setValue(value: string | undefined) {
this.value = value; this.value = value;
setTimeout(() => {
fireEvent(this, "value-changed", { value }); fireEvent(this, "value-changed", { value });
fireEvent(this, "change"); fireEvent(this, "change");
}, 0);
} }
} }

View File

@@ -471,14 +471,14 @@ export class HaStatisticPicker extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomEntity} .allowCustomValue=${this.allowCustomEntity}
.label=${this.label}
.disabled=${this.disabled} .disabled=${this.disabled}
.label=${this.label}
.placeholder=${placeholder}
.value=${this.value}
.notFoundLabel=${this._notFoundLabel} .notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass.localize( .emptyLabel=${this.hass.localize(
"ui.components.statistic-picker.no_statistics" "ui.components.statistic-picker.no_statistics"
)} )}
.placeholder=${placeholder}
.value=${this.value}
.rowRenderer=${this._rowRenderer} .rowRenderer=${this._rowRenderer}
.getItems=${this._getItems} .getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems} .getAdditionalItems=${this._getAdditionalItems}

View File

@@ -1,29 +1,29 @@
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { html, LitElement, nothing } from "lit"; import { html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../common/config/is_component_loaded"; import { isComponentLoaded } from "../common/config/is_component_loaded";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
import type { HassioAddonInfo } from "../data/hassio/addon";
import { fetchHassioAddonsInfo } from "../data/hassio/addon"; import { fetchHassioAddonsInfo } from "../data/hassio/addon";
import type { HomeAssistant, ValueChangedEvent } from "../types"; import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./ha-alert"; import "./ha-alert";
import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box";
import "./ha-combo-box-item"; import "./ha-combo-box-item";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
const rowRenderer: ComboBoxLitRenderer<HassioAddonInfo> = (item) => html` const SEARCH_KEYS = [
{ name: "primary", weight: 10 },
{ name: "secondary", weight: 8 },
{ name: "search_labels.description", weight: 6 },
{ name: "search_labels.repository", weight: 5 },
];
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
<ha-combo-box-item type="button"> <ha-combo-box-item type="button">
<span slot="headline">${item.name}</span> <span slot="headline">${item.primary}</span>
<span slot="supporting-text">${item.slug}</span> <span slot="supporting-text">${item.secondary}</span>
${item.icon ${item.icon
? html` ? html` <img alt="" slot="start" .src=${item.icon} /> `
<img
alt=""
slot="start"
.src="/api/hassio/addons/${item.slug}/icon"
/>
`
: nothing} : nothing}
</ha-combo-box-item> </ha-combo-box-item>
`; `;
@@ -38,22 +38,22 @@ class HaAddonPicker extends LitElement {
@property() public helper?: string; @property() public helper?: string;
@state() private _addons?: HassioAddonInfo[]; @state() private _addons?: PickerComboBoxItem[];
@property({ type: Boolean }) public disabled = false; @property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false; @property({ type: Boolean }) public required = false;
@query("ha-combo-box") private _comboBox!: HaComboBox; @query("ha-generic-picker") private _genericPicker!: HaGenericPicker;
@state() private _error?: string; @state() private _error?: string;
public open() { public open() {
this._comboBox?.open(); this._genericPicker?.open();
} }
public focus() { public focus() {
this._comboBox?.focus(); this._genericPicker?.focus();
} }
protected firstUpdated() { protected firstUpdated() {
@@ -61,29 +61,34 @@ class HaAddonPicker extends LitElement {
} }
protected render() { protected render() {
const label =
this.label === undefined && this.hass
? this.hass.localize("ui.components.addon-picker.addon")
: this.label;
if (this._error) { if (this._error) {
return html`<ha-alert alert-type="error">${this._error}</ha-alert>`; return html`<ha-alert alert-type="error">${this._error}</ha-alert>`;
} }
if (!this._addons) { if (!this._addons) {
return nothing; return nothing;
} }
return html` return html`
<ha-combo-box <ha-generic-picker
.hass=${this.hass} .hass=${this.hass}
.label=${this.label === undefined && this.hass .autofocus=${this.autofocus}
? this.hass.localize("ui.components.addon-picker.addon") .label=${label}
: this.label} .valueRenderer=${this._valueRenderer}
.value=${this._value}
.required=${this.required}
.disabled=${this.disabled}
.helper=${this.helper} .helper=${this.helper}
.renderer=${rowRenderer} .disabled=${this.disabled}
.items=${this._addons} .required=${this.required}
item-value-path="slug" .value=${this.value}
item-id-path="slug" .getItems=${this._getItems}
item-label-path="name" .searchKeys=${SEARCH_KEYS}
.rowRenderer=${rowRenderer}
@value-changed=${this._addonChanged} @value-changed=${this._addonChanged}
></ha-combo-box> >
</ha-generic-picker>
`; `;
} }
@@ -93,9 +98,19 @@ class HaAddonPicker extends LitElement {
const addonsInfo = await fetchHassioAddonsInfo(this.hass); const addonsInfo = await fetchHassioAddonsInfo(this.hass);
this._addons = addonsInfo.addons this._addons = addonsInfo.addons
.filter((addon) => addon.version) .filter((addon) => addon.version)
.sort((a, b) => .map((addon) => ({
stringCompare(a.name, b.name, this.hass.locale.language) id: addon.slug,
); primary: addon.name,
secondary: addon.slug,
icon: addon.icon
? `/api/hassio/addons/${addon.slug}/icon`
: undefined,
search_labels: {
description: addon.description || null,
repository: addon.repository || null,
},
sorting_label: [addon.name, addon.slug].filter(Boolean).join("_"),
}));
} else { } else {
this._error = this.hass.localize( this._error = this.hass.localize(
"ui.components.addon-picker.error.no_supervisor" "ui.components.addon-picker.error.no_supervisor"
@@ -108,6 +123,8 @@ class HaAddonPicker extends LitElement {
} }
} }
private _getItems = () => this._addons!;
private get _value() { private get _value() {
return this.value || ""; return this.value || "";
} }
@@ -128,6 +145,17 @@ class HaAddonPicker extends LitElement {
fireEvent(this, "change"); fireEvent(this, "change");
}, 0); }, 0);
} }
private _valueRenderer = (itemId: string) => {
const item = this._addons!.find((addon) => addon.id === itemId);
return html`${item?.icon
? html`<img
slot="start"
alt=${item.primary ?? "Unknown"}
.src=${item.icon}
/>`
: nothing}<span slot="headline">${item?.primary || "Unknown"}</span>`;
};
} }
declare global { declare global {

View File

@@ -25,36 +25,6 @@ declare global {
} }
} }
/**
* A custom alert component for displaying messages with various alert types.
*
* @element ha-alert
*
* @property {string} title - The title of the alert. Defaults to an empty string.
* @property {"info" | "warning" | "error" | "success"} alertType - The type of alert to display.
* Defaults to "info". Determines the styling and icon used.
* @property {boolean} dismissable - Whether the alert can be dismissed. Defaults to `false`.
* If `true`, a dismiss button is displayed.
* @property {boolean} narrow - Whether the alert should use a narrow layout. Defaults to `false`.
*
* @slot - The main content of the alert.
* @slot icon - Slot for providing a custom icon for the alert.
* @slot action - Slot for providing custom actions or buttons for the alert.
*
* @fires alert-dismissed-clicked - Fired when the dismiss button is clicked.
*
* @csspart issue-type - The container for the alert.
* @csspart icon - The container for the alert icon.
* @csspart content - The container for the alert content.
* @csspart action - The container for the alert actions.
* @csspart title - The container for the alert title.
*
* @cssprop --info-color - The color used for "info" alerts.
* @cssprop --warning-color - The color used for "warning" alerts.
* @cssprop --error-color - The color used for "error" alerts.
* @cssprop --success-color - The color used for "success" alerts.
* @cssprop --primary-text-color - The primary text color used in the alert.
*/
@customElement("ha-alert") @customElement("ha-alert")
class HaAlert extends LitElement { class HaAlert extends LitElement {
// eslint-disable-next-line lit/no-native-attributes // eslint-disable-next-line lit/no-native-attributes

View File

@@ -51,9 +51,6 @@ export class HaAreaPicker extends LitElement {
@property({ type: Boolean, attribute: "no-add" }) @property({ type: Boolean, attribute: "no-add" })
public noAdd = false; public noAdd = false;
@property({ type: Boolean, attribute: "show-label" })
public showLabel = false;
/** /**
* Show only areas with entities from specific domains. * Show only areas with entities from specific domains.
* @type {Array} * @type {Array}
@@ -366,16 +363,19 @@ export class HaAreaPicker extends LitElement {
}; };
protected render(): TemplateResult { protected render(): TemplateResult {
const placeholder = const baseLabel =
this.placeholder ?? this.hass.localize("ui.components.area-picker.area"); this.label ?? this.hass.localize("ui.components.area-picker.area");
const valueRenderer = this._computeValueRenderer(this.hass.areas); const valueRenderer = this._computeValueRenderer(this.hass.areas);
let showLabel = this.showLabel; // Only show label if there's no floor
if (this.value) { let label: string | undefined = baseLabel;
if (this.value && baseLabel) {
const area = this.hass.areas[this.value]; const area = this.hass.areas[this.value];
if (area) { if (area) {
const { floor } = getAreaContext(area, this.hass.floors); const { floor } = getAreaContext(area, this.hass.floors);
showLabel = !floor && this.showLabel; if (floor) {
label = undefined;
}
} }
} }
@@ -383,14 +383,12 @@ export class HaAreaPicker extends LitElement {
<ha-generic-picker <ha-generic-picker
.hass=${this.hass} .hass=${this.hass}
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
.label=${this.label} .label=${label}
.helper=${this.helper} .helper=${this.helper}
.notFoundLabel=${this._notFoundLabel} .notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass.localize("ui.components.area-picker.no_areas")} .emptyLabel=${this.hass.localize("ui.components.area-picker.no_areas")}
.disabled=${this.disabled} .disabled=${this.disabled}
.required=${this.required} .required=${this.required}
.placeholder=${placeholder}
.showLabel=${showLabel}
.value=${this.value} .value=${this.value}
.getItems=${this._getItems} .getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems} .getAdditionalItems=${this._getAdditionalItems}

View File

@@ -1,25 +1,23 @@
import { mdiInvertColorsOff, mdiPalette } from "@mdi/js"; import { mdiInvertColorsOff, mdiPalette } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit"; import { html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { styleMap } from "lit/directives/style-map"; import { styleMap } from "lit/directives/style-map";
import { computeCssColor, THEME_COLORS } from "../common/color/compute-color"; import { computeCssColor, THEME_COLORS } from "../common/color/compute-color";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import type { LocalizeKeys } from "../common/translations/localize"; import type { LocalizeKeys } from "../common/translations/localize";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import "./ha-list-item"; import "./ha-generic-picker";
import "./ha-md-divider"; import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import "./ha-select"; import type { PickerValueRenderer } from "./ha-picker-field";
import type { HaSelect } from "./ha-select";
@customElement("ha-color-picker") @customElement("ha-color-picker")
export class HaColorPicker extends LitElement { export class HaColorPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string; @property() public label?: string;
@property() public helper?: string; @property() public helper?: string;
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public value?: string; @property() public value?: string;
@property({ type: String, attribute: "default_color" }) @property({ type: String, attribute: "default_color" })
@@ -33,137 +31,178 @@ export class HaColorPicker extends LitElement {
@property({ type: Boolean }) public disabled = false; @property({ type: Boolean }) public disabled = false;
@query("ha-select") private _select?: HaSelect; @property({ type: Boolean }) public required = false;
connectedCallback(): void { render() {
super.connectedCallback(); const effectiveValue = this.value ?? this.defaultColor ?? "";
// Refresh layout options when the field is connected to the DOM to ensure current value displayed
this._select?.layoutOptions(); return html`
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.required=${this.required}
.hideClearIcon=${!this.value && !!this.defaultColor}
.label=${this.label}
.helper=${this.helper}
.value=${effectiveValue}
.getItems=${this._getItems}
.rowRenderer=${this._rowRenderer}
.valueRenderer=${this._valueRenderer}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
`;
} }
private _valueSelected(ev) { private _getItems = () =>
ev.stopPropagation(); this._getColors(
if (!this.isConnected) return; this.includeNone,
const value = ev.target.value; this.includeState,
this.value = value === this.defaultColor ? undefined : value; this.defaultColor,
fireEvent(this, "value-changed", { this.value
value: this.value, );
private _getColors = (
includeNone: boolean,
includeState: boolean,
defaultColor: string | undefined,
currentValue: string | undefined
): PickerComboBoxItem[] => {
const items: PickerComboBoxItem[] = [];
const defaultSuffix = this.hass.localize(
"ui.components.color-picker.default"
);
const addDefaultSuffix = (label: string, isDefault: boolean) =>
isDefault && defaultSuffix ? `${label} (${defaultSuffix})` : label;
if (includeNone) {
const noneLabel =
this.hass.localize("ui.components.color-picker.none") || "None";
items.push({
id: "none",
primary: addDefaultSuffix(noneLabel, defaultColor === "none"),
icon_path: mdiInvertColorsOff,
sorting_label: noneLabel,
}); });
} }
render() { if (includeState) {
const value = this.value || this.defaultColor || ""; const stateLabel =
this.hass.localize("ui.components.color-picker.state") || "State";
items.push({
id: "state",
primary: addDefaultSuffix(stateLabel, defaultColor === "state"),
icon_path: mdiPalette,
sorting_label: stateLabel,
});
}
const isCustom = !( Array.from(THEME_COLORS).forEach((color) => {
THEME_COLORS.has(value) || const themeLabel =
value === "none" || this.hass.localize(
value === "state"
);
return html`
<ha-select
.icon=${Boolean(value)}
.label=${this.label}
.value=${value}
.helper=${this.helper}
.disabled=${this.disabled}
@closed=${stopPropagation}
@selected=${this._valueSelected}
fixedMenuPosition
naturalMenuWidth
.clearable=${!this.defaultColor}
>
${value
? html`
<span slot="icon">
${value === "none"
? html`
<ha-svg-icon path=${mdiInvertColorsOff}></ha-svg-icon>
`
: value === "state"
? html`<ha-svg-icon path=${mdiPalette}></ha-svg-icon>`
: this._renderColorCircle(value || "grey")}
</span>
`
: nothing}
${this.includeNone
? html`
<ha-list-item value="none" graphic="icon">
${this.hass.localize("ui.components.color-picker.none")}
${this.defaultColor === "none"
? ` (${this.hass.localize("ui.components.color-picker.default")})`
: nothing}
<ha-svg-icon
slot="graphic"
path=${mdiInvertColorsOff}
></ha-svg-icon>
</ha-list-item>
`
: nothing}
${this.includeState
? html`
<ha-list-item value="state" graphic="icon">
${this.hass.localize("ui.components.color-picker.state")}
${this.defaultColor === "state"
? ` (${this.hass.localize("ui.components.color-picker.default")})`
: nothing}
<ha-svg-icon slot="graphic" path=${mdiPalette}></ha-svg-icon>
</ha-list-item>
`
: nothing}
${this.includeState || this.includeNone
? html`<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>`
: nothing}
${Array.from(THEME_COLORS).map(
(color) => html`
<ha-list-item .value=${color} graphic="icon">
${this.hass.localize(
`ui.components.color-picker.colors.${color}` as LocalizeKeys `ui.components.color-picker.colors.${color}` as LocalizeKeys
) || color} ) || color;
${this.defaultColor === color items.push({
? ` (${this.hass.localize("ui.components.color-picker.default")})` id: color,
: nothing} primary: addDefaultSuffix(themeLabel, defaultColor === color),
<span slot="graphic">${this._renderColorCircle(color)}</span> sorting_label: themeLabel,
</ha-list-item> });
` });
)}
${isCustom const isSpecial =
? html` currentValue === "none" ||
<ha-list-item .value=${value} graphic="icon"> currentValue === "state" ||
${value} THEME_COLORS.has(currentValue || "");
<span slot="graphic">${this._renderColorCircle(value)}</span>
</ha-list-item> const hasValue = currentValue && currentValue.length > 0;
`
: nothing} if (hasValue && !isSpecial) {
</ha-select> items.push({
id: currentValue!,
primary: currentValue!,
sorting_label: currentValue!,
});
}
return items;
};
private _rowRenderer: (
item: PickerComboBoxItem,
index?: number
) => ReturnType<typeof html> = (item) => html`
<ha-combo-box-item type="button" compact>
${item.id === "none"
? html`<ha-svg-icon
slot="start"
.path=${mdiInvertColorsOff}
></ha-svg-icon>`
: item.id === "state"
? html`<ha-svg-icon slot="start" .path=${mdiPalette}></ha-svg-icon>`
: html`<span slot="start">
${this._renderColorCircle(item.id)}
</span>`}
<span slot="headline">${item.primary}</span>
</ha-combo-box-item>
`;
private _valueRenderer: PickerValueRenderer = (value: string) => {
if (value === "none") {
return html`
<ha-svg-icon slot="start" .path=${mdiInvertColorsOff}></ha-svg-icon>
<span slot="headline">
${this.hass.localize("ui.components.color-picker.none")}
</span>
`; `;
} }
if (value === "state") {
return html`
<ha-svg-icon slot="start" .path=${mdiPalette}></ha-svg-icon>
<span slot="headline">
${this.hass.localize("ui.components.color-picker.state")}
</span>
`;
}
return html`
<span slot="start">${this._renderColorCircle(value)}</span>
<span slot="headline">
${this.hass.localize(
`ui.components.color-picker.colors.${value}` as LocalizeKeys
) || value}
</span>
`;
};
private _renderColorCircle(color: string) { private _renderColorCircle(color: string) {
return html` return html`
<span <span
class="circle-color"
style=${styleMap({ style=${styleMap({
"--circle-color": computeCssColor(color), "--circle-color": computeCssColor(color),
display: "block",
"background-color": "var(--circle-color, var(--divider-color))",
border: "1px solid var(--outline-color)",
"border-radius": "var(--ha-border-radius-pill)",
width: "20px",
height: "20px",
"box-sizing": "border-box",
})} })}
></span> ></span>
`; `;
} }
static styles = css` private _valueChanged(ev: CustomEvent<{ value?: string }>) {
.circle-color { ev.stopPropagation();
display: block; const selected = ev.detail.value;
background-color: var(--circle-color, var(--divider-color)); const normalized =
border: 1px solid var(--outline-color); selected && selected === this.defaultColor
border-radius: var(--ha-border-radius-pill); ? undefined
width: 20px; : (selected ?? undefined);
height: 20px; this.value = normalized;
box-sizing: border-box; fireEvent(this, "value-changed", { value: this.value });
} }
ha-select {
width: 100%;
}
`;
} }
declare global { declare global {

View File

@@ -57,10 +57,9 @@ class HaConfigEntryPicker extends LitElement {
return html` return html`
<ha-generic-picker <ha-generic-picker
.hass=${this.hass} .hass=${this.hass}
.placeholder=${this.label === undefined && this.hass .label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.config-entry-picker.config_entry") ? this.hass.localize("ui.components.config-entry-picker.config_entry")
: this.label} : this.label}
show-label
.value=${this.value} .value=${this.value}
.required=${this.required} .required=${this.required}
.disabled=${this.disabled} .disabled=${this.disabled}

View File

@@ -389,14 +389,14 @@ export class HaFloorPicker extends LitElement {
<ha-generic-picker <ha-generic-picker
.hass=${this.hass} .hass=${this.hass}
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
.disabled=${this.disabled}
.label=${this.label} .label=${this.label}
.helper=${this.helper} .helper=${this.helper}
.disabled=${this.disabled} .placeholder=${placeholder}
.notFoundLabel=${this._notFoundLabel} .notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass.localize( .emptyLabel=${this.hass.localize(
"ui.components.floor-picker.no_floors" "ui.components.floor-picker.no_floors"
)} )}
.placeholder=${placeholder}
.value=${this.value} .value=${this.value}
.getItems=${this._getItems} .getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems} .getAdditionalItems=${this._getAdditionalItems}

View File

@@ -7,8 +7,10 @@ import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { tinykeys } from "tinykeys"; import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { PickerMixin } from "../mixins/picker-mixin";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm"; import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import { isIosApp } from "../util/is_ios";
import "./ha-bottom-sheet"; import "./ha-bottom-sheet";
import "./ha-button"; import "./ha-button";
import "./ha-combo-box-item"; import "./ha-combo-box-item";
@@ -20,39 +22,18 @@ import type {
PickerComboBoxSearchFn, PickerComboBoxSearchFn,
} from "./ha-picker-combo-box"; } from "./ha-picker-combo-box";
import "./ha-picker-field"; import "./ha-picker-field";
import type { PickerValueRenderer } from "./ha-picker-field";
import "./ha-svg-icon"; import "./ha-svg-icon";
@customElement("ha-generic-picker") @customElement("ha-generic-picker")
export class HaGenericPicker extends LitElement { export class HaGenericPicker extends PickerMixin(LitElement) {
@property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public hass?: HomeAssistant;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@property({ type: Boolean, attribute: "allow-custom-value" }) @property({ type: Boolean, attribute: "allow-custom-value" })
public allowCustomValue; public allowCustomValue;
@property() public value?: string;
@property() public icon?: string;
@property() public label?: string;
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: String, attribute: "search-label" }) @property({ type: String, attribute: "search-label" })
public searchLabel?: string; public searchLabel?: string;
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@property({ attribute: "show-label", type: Boolean })
public showLabel = false;
/** To prevent lags, getItems needs to be memoized */ /** To prevent lags, getItems needs to be memoized */
@property({ attribute: false }) @property({ attribute: false })
public getItems!: ( public getItems!: (
@@ -66,9 +47,6 @@ export class HaGenericPicker extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public rowRenderer?: RenderItemFunction<PickerComboBoxItem>; public rowRenderer?: RenderItemFunction<PickerComboBoxItem>;
@property({ attribute: false })
public valueRenderer?: PickerValueRenderer;
@property({ attribute: false }) @property({ attribute: false })
public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>; public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
@@ -118,7 +96,11 @@ export class HaGenericPicker extends LitElement {
@property({ attribute: "selected-section" }) public selectedSection?: string; @property({ attribute: "selected-section" }) public selectedSection?: string;
@property({ attribute: "unknown-item-text" }) public unknownItemText?: string; @property({ type: Boolean, attribute: "use-top-label" })
public useTopLabel = false;
@property({ attribute: "custom-value-label" })
public customValueLabel?: string;
@query(".container") private _containerElement?: HTMLDivElement; @query(".container") private _containerElement?: HTMLDivElement;
@@ -149,11 +131,13 @@ export class HaGenericPicker extends LitElement {
private _unsubscribeTinyKeys?: () => void; private _unsubscribeTinyKeys?: () => void;
protected render() { protected render() {
return html` // Only show label if it's not a top label and there is a value.
${this.label const label = this.useTopLabel && this.value ? undefined : this.label;
return html`<div class="container">
${this.useTopLabel && this.label
? html`<label ?disabled=${this.disabled}>${this.label}</label>` ? html`<label ?disabled=${this.disabled}>${this.label}</label>`
: nothing} : nothing}
<div class="container">
<div id="picker"> <div id="picker">
<slot name="field"> <slot name="field">
${this.addButtonLabel && !this.value ${this.addButtonLabel && !this.value
@@ -173,14 +157,20 @@ export class HaGenericPicker extends LitElement {
type="button" type="button"
class=${this._opened ? "opened" : ""} class=${this._opened ? "opened" : ""}
compact compact
.unknown=${this._unknownValue(this.value, this.getItems())} .unknown=${this._unknownValue(
this.allowCustomValue,
this.value,
this.getItems()
)}
.unknownItemText=${this.unknownItemText} .unknownItemText=${this.unknownItemText}
aria-label=${ifDefined(this.label)} aria-label=${ifDefined(this.label)}
@click=${this.open} @click=${this.open}
@clear=${this._clear} @clear=${this._clear}
.icon=${this.icon} .icon=${this.icon}
.showLabel=${this.showLabel} .image=${this.image}
.label=${label}
.placeholder=${this.placeholder} .placeholder=${this.placeholder}
.helper=${this.helper}
.value=${this.value} .value=${this.value}
.valueRenderer=${this.valueRenderer} .valueRenderer=${this.valueRenderer}
.required=${this.required} .required=${this.required}
@@ -188,6 +178,7 @@ export class HaGenericPicker extends LitElement {
.invalid=${this.invalid} .invalid=${this.invalid}
.hideClearIcon=${this.hideClearIcon} .hideClearIcon=${this.hideClearIcon}
> >
<slot name="start"></slot>
</ha-picker-field>`} </ha-picker-field>`}
</slot> </slot>
</div> </div>
@@ -226,8 +217,7 @@ export class HaGenericPicker extends LitElement {
</ha-bottom-sheet>` </ha-bottom-sheet>`
: nothing} : nothing}
</div> </div>
${this._renderHelper()} ${this._renderHelper()}`;
`;
} }
private _renderComboBox(dialogMode = false) { private _renderComboBox(dialogMode = false) {
@@ -236,6 +226,7 @@ export class HaGenericPicker extends LitElement {
} }
return html` return html`
<ha-picker-combo-box <ha-picker-combo-box
id="combo-box"
.hass=${this.hass} .hass=${this.hass}
.allowCustomValue=${this.allowCustomValue} .allowCustomValue=${this.allowCustomValue}
.label=${this.searchLabel} .label=${this.searchLabel}
@@ -252,13 +243,24 @@ export class HaGenericPicker extends LitElement {
.sectionTitleFunction=${this.sectionTitleFunction} .sectionTitleFunction=${this.sectionTitleFunction}
.selectedSection=${this.selectedSection} .selectedSection=${this.selectedSection}
.searchKeys=${this.searchKeys} .searchKeys=${this.searchKeys}
.customValueLabel=${this.customValueLabel}
></ha-picker-combo-box> ></ha-picker-combo-box>
`; `;
} }
private _unknownValue = memoizeOne( private _unknownValue = memoizeOne(
(value?: string, items?: (PickerComboBoxItem | string)[]) => { (
if (value === undefined || value === null || value === "" || !items) { allowCustomValue: boolean,
value?: string,
items?: (PickerComboBoxItem | string)[]
) => {
if (
allowCustomValue ||
value === undefined ||
value === null ||
value === "" ||
!items
) {
return false; return false;
} }
@@ -284,6 +286,15 @@ export class HaGenericPicker extends LitElement {
private _dialogOpened = () => { private _dialogOpened = () => {
this._opened = true; this._opened = true;
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (this.hass && isIosApp(this.hass)) {
this.hass.auth.external!.fireMessage({
type: "focus_element",
payload: {
element_id: "combo-box",
},
});
return;
}
this._comboBox?.focus(); this._comboBox?.focus();
}); });
}; };
@@ -310,7 +321,7 @@ export class HaGenericPicker extends LitElement {
this._newValue = value; this._newValue = value;
} }
private _clear(e) { private _clear(e: CustomEvent) {
e.stopPropagation(); e.stopPropagation();
this._setValue(undefined); this._setValue(undefined);
} }

View File

@@ -113,7 +113,6 @@ export class HaIconPicker extends LitElement {
<ha-generic-picker <ha-generic-picker
.hass=${this.hass} .hass=${this.hass}
allow-custom-value allow-custom-value
show-label
.getItems=${this._getIconPickerItems} .getItems=${this._getIconPickerItems}
.helper=${this.helper} .helper=${this.helper}
.disabled=${this.disabled} .disabled=${this.disabled}
@@ -122,7 +121,7 @@ export class HaIconPicker extends LitElement {
.invalid=${this.invalid} .invalid=${this.invalid}
.rowRenderer=${rowRenderer} .rowRenderer=${rowRenderer}
.icon=${this._icon} .icon=${this._icon}
.placeholder=${this.label} .label=${this.label}
.value=${this._value} .value=${this._value}
.searchFn=${this._filterIcons} .searchFn=${this._filterIcons}
.notFoundLabel=${this.hass?.localize( .notFoundLabel=${this.hass?.localize(
@@ -131,6 +130,7 @@ export class HaIconPicker extends LitElement {
popover-placement="bottom-start" popover-placement="bottom-start"
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
> >
<slot name="start"></slot>
</ha-generic-picker> </ha-generic-picker>
`; `;
} }

View File

@@ -231,7 +231,6 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
static styles = css` static styles = css`
ha-chip-set { ha-chip-set {
margin-bottom: 8px;
background-color: var(--mdc-text-field-fill-color); background-color: var(--mdc-text-field-fill-color);
border-bottom: 1px solid var(--ha-color-border-neutral-normal); border-bottom: 1px solid var(--ha-color-border-neutral-normal);
border-top-right-radius: var(--ha-border-radius-sm); border-top-right-radius: var(--ha-border-radius-sm);

View File

@@ -116,6 +116,11 @@ export class HaLanguagePicker extends LitElement {
> `; > `;
protected render() { protected render() {
const label =
this.label ??
(this.hass?.localize("ui.components.language-picker.language") ||
"Language");
const value = const value =
this.value ?? this.value ??
(this.required && !this.disabled ? this._getItems()[0].id : this.value); (this.required && !this.disabled ? this._getItems()[0].id : this.value);
@@ -129,10 +134,7 @@ export class HaLanguagePicker extends LitElement {
.emptyLabel=${this.hass?.localize( .emptyLabel=${this.hass?.localize(
"ui.components.language-picker.no_languages" "ui.components.language-picker.no_languages"
) || "No languages available"} ) || "No languages available"}
.placeholder=${this.label ?? .label=${label}
(this.hass?.localize("ui.components.language-picker.language") ||
"Language")}
show-label
.value=${value} .value=${value}
.valueRenderer=${this._valueRenderer} .valueRenderer=${this._valueRenderer}
.disabled=${this.disabled} .disabled=${this.disabled}

View File

@@ -1,55 +1,17 @@
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; import { html, LitElement, nothing } from "lit";
import type { PropertyValues, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { titleCase } from "../common/string/title-case"; import { titleCase } from "../common/string/title-case";
import { fetchConfig } from "../data/lovelace/config/types"; import { fetchConfig } from "../data/lovelace/config/types";
import type { LovelaceViewRawConfig } from "../data/lovelace/config/view";
import { getPanelIcon, getPanelTitle } from "../data/panel"; import { getPanelIcon, getPanelTitle } from "../data/panel";
import type { HomeAssistant, PanelInfo, ValueChangedEvent } from "../types"; import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./ha-combo-box"; import "./ha-generic-picker";
import type { HaComboBox } from "./ha-combo-box";
import "./ha-combo-box-item";
import "./ha-icon"; import "./ha-icon";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
interface NavigationItem {
path: string;
icon: string;
title: string;
}
const DEFAULT_ITEMS: NavigationItem[] = [];
const rowRenderer: ComboBoxLitRenderer<NavigationItem> = (item) => html`
<ha-combo-box-item type="button">
<ha-icon .icon=${item.icon} slot="start"></ha-icon>
<span slot="headline">${item.title || item.path}</span>
${item.title
? html`<span slot="supporting-text">${item.path}</span>`
: nothing}
</ha-combo-box-item>
`;
const createViewNavigationItem = (
prefix: string,
view: LovelaceViewRawConfig,
index: number
) => ({
path: `/${prefix}/${view.path ?? index}`,
icon: view.icon ?? "mdi:view-compact",
title: view.title ?? (view.path ? titleCase(view.path) : `${index}`),
});
const createPanelNavigationItem = (hass: HomeAssistant, panel: PanelInfo) => ({
path: `/${panel.url_path}`,
icon: getPanelIcon(panel) || "mdi:view-dashboard",
title: getPanelTitle(hass, panel) || "",
});
@customElement("ha-navigation-picker") @customElement("ha-navigation-picker")
export class HaNavigationPicker extends LitElement { export class HaNavigationPicker extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string; @property() public label?: string;
@@ -61,46 +23,51 @@ export class HaNavigationPicker extends LitElement {
@property({ type: Boolean }) public required = false; @property({ type: Boolean }) public required = false;
@state() private _opened = false; @state() private _loading = true;
private navigationItemsLoaded = false; protected firstUpdated() {
this._loadNavigationItems();
}
private navigationItems: NavigationItem[] = DEFAULT_ITEMS; private _navigationItems: PickerComboBoxItem[] = [];
@query("ha-combo-box", true) private comboBox!: HaComboBox; protected render() {
protected render(): TemplateResult {
return html` return html`
<ha-combo-box <ha-generic-picker
.hass=${this.hass} .hass=${this.hass}
item-value-path="path" .value=${this._loading ? undefined : this.value}
item-label-path="path"
.value=${this._value}
allow-custom-value allow-custom-value
.filteredItems=${this.navigationItems} .placeholder=${this.label}
.label=${this.label}
.helper=${this.helper} .helper=${this.helper}
.disabled=${this.disabled} .disabled=${this._loading || this.disabled}
.required=${this.required} .required=${this.required}
.renderer=${rowRenderer} .getItems=${this._getItems}
@opened-changed=${this._openedChanged} .valueRenderer=${this._valueRenderer}
.customValueLabel=${this.hass.localize(
"ui.components.navigation-picker.add_custom_path"
)}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
@filter-changed=${this._filterChanged}
> >
</ha-combo-box> </ha-generic-picker>
`; `;
} }
private async _openedChanged(ev: ValueChangedEvent<boolean>) { private _valueRenderer = (itemId: string) => {
this._opened = ev.detail.value; const item = this._navigationItems.find((navItem) => navItem.id === itemId);
if (this._opened && !this.navigationItemsLoaded) { return html`
this._loadNavigationItems(); ${item?.icon
} ? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
} : nothing}
<span slot="headline">${item?.primary || itemId}</span>
${item?.primary
? html`<span slot="supporting-text">${itemId}</span>`
: nothing}
`;
};
private _getItems = () => this._navigationItems;
private async _loadNavigationItems() { private async _loadNavigationItems() {
this.navigationItemsLoaded = true;
const panels = Object.entries(this.hass!.panels).map(([id, panel]) => ({ const panels = Object.entries(this.hass!.panels).map(([id, panel]) => ({
id, id,
...panel, ...panel,
@@ -124,27 +91,47 @@ export class HaNavigationPicker extends LitElement {
const panelViewConfig = new Map(viewConfigs); const panelViewConfig = new Map(viewConfigs);
this.navigationItems = []; this._navigationItems = [];
for (const panel of panels) { for (const panel of panels) {
this.navigationItems.push(createPanelNavigationItem(this.hass!, panel)); const path = `/${panel.url_path}`;
const panelTitle = getPanelTitle(this.hass, panel);
const primary = panelTitle || path;
this._navigationItems.push({
id: path,
primary,
secondary: panelTitle ? path : undefined,
icon: getPanelIcon(panel) || "mdi:view-dashboard",
sorting_label: [
primary.startsWith("/") ? `zzz${primary}` : primary,
path,
]
.filter(Boolean)
.join("_"),
});
const config = panelViewConfig.get(panel.id); const config = panelViewConfig.get(panel.id);
if (!config || !("views" in config)) continue; if (!config || !("views" in config)) continue;
config.views.forEach((view, index) => config.views.forEach((view, index) => {
this.navigationItems.push( const viewPath = `/${panel.url_path}/${view.path ?? index}`;
createViewNavigationItem(panel.url_path, view, index) const viewPrimary =
) view.title ?? (view.path ? titleCase(view.path) : `${index}`);
); this._navigationItems.push({
id: viewPath,
secondary: viewPath,
icon: view.icon ?? "mdi:view-compact",
primary: viewPrimary,
sorting_label: [
viewPrimary.startsWith("/") ? `zzz${viewPrimary}` : viewPrimary,
viewPath,
].join("_"),
});
});
} }
this.comboBox.filteredItems = this.navigationItems; this._loading = false;
}
protected shouldUpdate(changedProps: PropertyValues) {
return !this._opened || changedProps.has("_opened");
} }
private _valueChanged(ev: ValueChangedEvent<string>) { private _valueChanged(ev: ValueChangedEvent<string>) {
@@ -152,61 +139,18 @@ export class HaNavigationPicker extends LitElement {
this._setValue(ev.detail.value); this._setValue(ev.detail.value);
} }
private _setValue(value: string) { private _setValue(value = "") {
this.value = value; this.value = value;
fireEvent( fireEvent(
this, this,
"value-changed", "value-changed",
{ value: this._value }, { value: this.value },
{ {
bubbles: false, bubbles: false,
composed: false, composed: false,
} }
); );
} }
private _filterChanged(ev: CustomEvent): void {
const filterString = ev.detail.value.toLowerCase();
const characterCount = filterString.length;
if (characterCount >= 2) {
const filteredItems: NavigationItem[] = [];
this.navigationItems.forEach((item) => {
if (
item.path.toLowerCase().includes(filterString) ||
item.title.toLowerCase().includes(filterString)
) {
filteredItems.push(item);
}
});
if (filteredItems.length > 0) {
this.comboBox.filteredItems = filteredItems;
} else {
this.comboBox.filteredItems = [];
}
} else {
this.comboBox.filteredItems = this.navigationItems;
}
}
private get _value() {
return this.value || "";
}
static styles = css`
ha-icon,
ha-svg-icon {
color: var(--primary-text-color);
position: relative;
bottom: 0px;
}
*[slot="prefix"] {
margin-right: 8px;
margin-inline-end: 8px;
margin-inline-start: initial;
}
`;
} }
declare global { declare global {

View File

@@ -1,6 +1,6 @@
import type { LitVirtualizer } from "@lit-labs/virtualizer"; import type { LitVirtualizer } from "@lit-labs/virtualizer";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize"; import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiMagnify, mdiMinusBoxOutline } from "@mdi/js"; import { mdiMagnify, mdiMinusBoxOutline, mdiPlus } from "@mdi/js";
import Fuse from "fuse.js"; import Fuse from "fuse.js";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { import {
@@ -53,7 +53,8 @@ export interface PickerComboBoxItem {
icon_path?: string; icon_path?: string;
icon?: string; icon?: string;
} }
const NO_ITEMS_AVAILABLE_ID = "___no_items_available___";
export const NO_ITEMS_AVAILABLE_ID = "___no_items_available___";
const DEFAULT_ROW_RENDERER: RenderItemFunction<PickerComboBoxItem> = ( const DEFAULT_ROW_RENDERER: RenderItemFunction<PickerComboBoxItem> = (
item item
@@ -91,6 +92,9 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@property({ type: Boolean, attribute: "allow-custom-value" }) @property({ type: Boolean, attribute: "allow-custom-value" })
public allowCustomValue; public allowCustomValue;
@property({ attribute: "custom-value-label" })
public customValueLabel?: string;
@property() public label?: string; @property() public label?: string;
@property() public value?: string; @property() public value?: string;
@@ -187,10 +191,15 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
} }
protected render() { protected render() {
const searchLabel =
this.label ??
(this.allowCustomValue
? (this.hass?.localize("ui.components.combo-box.search_or_custom") ??
"Search | Add custom value")
: (this.hass?.localize("ui.common.search") ?? "Search"));
return html`<ha-textfield return html`<ha-textfield
.label=${this.label ?? .label=${searchLabel}
this.hass?.localize("ui.common.search") ??
"Search"}
@input=${this._filterChanged} @input=${this._filterChanged}
></ha-textfield> ></ha-textfield>
${this._renderSectionButtons()} ${this._renderSectionButtons()}
@@ -438,13 +447,23 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
); );
} }
if (this.allowCustomValue && searchString) {
filteredItems.push({
id: searchString,
primary:
this.customValueLabel ??
this.hass?.localize("ui.components.combo-box.add_custom_item") ??
"Add custom item",
secondary: `"${searchString}"`,
icon_path: mdiPlus,
});
}
this._items = filteredItems as PickerComboBoxItem[]; this._items = filteredItems as PickerComboBoxItem[];
} }
this._selectedItemIndex = -1; this._selectedItemIndex = -1;
if (this._virtualizerElement) { this._valuePinned = true;
this._virtualizerElement.scrollTo(0, 0);
}
}; };
private _toggleSection(ev: Event) { private _toggleSection(ev: Event) {
@@ -639,7 +658,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
typeof item === "string" ? item : item?.id; typeof item === "string" ? item : item?.id;
private _getInitialSelectedIndex() { private _getInitialSelectedIndex() {
if (!this._virtualizerElement || !this.value) { if (!this._virtualizerElement || this._search || !this.value) {
return 0; return 0;
} }

View File

@@ -9,13 +9,15 @@ import {
type TemplateResult, type TemplateResult,
} from "lit"; } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { localizeContext } from "../data/context"; import { localizeContext } from "../data/context";
import { PickerMixin } from "../mixins/picker-mixin";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import "./ha-combo-box-item"; import "./ha-combo-box-item";
import type { HaComboBoxItem } from "./ha-combo-box-item"; import type { HaComboBoxItem } from "./ha-combo-box-item";
import "./ha-icon-button";
import "./ha-icon"; import "./ha-icon";
import "./ha-icon-button";
declare global { declare global {
interface HASSDomEvents { interface HASSDomEvents {
@@ -26,32 +28,7 @@ declare global {
export type PickerValueRenderer = (value: string) => TemplateResult<1>; export type PickerValueRenderer = (value: string) => TemplateResult<1>;
@customElement("ha-picker-field") @customElement("ha-picker-field")
export class HaPickerField extends LitElement { export class HaPickerField extends PickerMixin(LitElement) {
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@property() public value?: string;
@property() public icon?: string;
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: Boolean, reflect: true }) public unknown = false;
@property({ attribute: "unknown-item-text" }) public unknownItemText?: string;
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@property({ attribute: "show-label", type: Boolean })
public showLabel = false;
@property({ attribute: false })
public valueRenderer?: PickerValueRenderer;
@property({ type: Boolean, reflect: true }) public invalid = false; @property({ type: Boolean, reflect: true }) public invalid = false;
@query("ha-combo-box-item", true) public item!: HaComboBoxItem; @query("ha-combo-box-item", true) public item!: HaComboBoxItem;
@@ -66,31 +43,48 @@ export class HaPickerField extends LitElement {
} }
protected render() { protected render() {
const hasValue = !!this.value?.length; const hasValue = !!this.value;
const showClearIcon = const showClearIcon =
!!this.value && !this.required && !this.disabled && !this.hideClearIcon; !!this.value && !this.required && !this.disabled && !this.hideClearIcon;
const placeholderText = this.placeholder ?? this.label;
const overlineLabel = const overlineLabel =
this.showLabel && hasValue && this.placeholder this.label && hasValue
? html`<span slot="overline">${this.placeholder}</span>` ? html`<span slot="overline"
>${this.label}${this.required ? " *" : ""}</span
>`
: nothing; : nothing;
const headlineContent = hasValue const headlineContent = hasValue
? this.valueRenderer ? this.valueRenderer
? this.valueRenderer(this.value ?? "") ? this.valueRenderer(this.value ?? "")
: html`<span slot="headline">${this.value}</span>` : html`<span slot="headline">${this.value}</span>`
: this.placeholder : placeholderText
? html`<span slot="headline" class="placeholder"> ? html`<span slot="headline" class="placeholder">
${this.placeholder} ${placeholderText}${this.required ? " *" : ""}
</span>` </span>`
: nothing; : nothing;
return html` return html`
<ha-combo-box-item .disabled=${this.disabled} type="button" compact> <ha-combo-box-item
${this.icon aria-label=${ifDefined(this.label || this.placeholder)}
.disabled=${this.disabled}
type="button"
compact
>
${this.image
? html`<img
alt=${this.label ?? ""}
slot="start"
.src=${this.image}
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>`
: this.icon
? html`<ha-icon slot="start" .icon=${this.icon}></ha-icon>` ? html`<ha-icon slot="start" .icon=${this.icon}></ha-icon>`
: nothing} : html`<slot name="start"></slot>`}
${overlineLabel}${headlineContent} ${overlineLabel}${headlineContent}
${this.unknown ${this.unknown
? html`<div slot="supporting-text" class="unknown"> ? html`<div slot="supporting-text" class="unknown">
@@ -117,7 +111,7 @@ export class HaPickerField extends LitElement {
`; `;
} }
private _clear(e) { private _clear(e: CustomEvent) {
e.stopPropagation(); e.stopPropagation();
fireEvent(this, "clear"); fireEvent(this, "clear");
} }
@@ -200,7 +194,10 @@ export class HaPickerField extends LitElement {
.placeholder { .placeholder {
color: var(--secondary-text-color); color: var(--secondary-text-color);
padding: 0 8px; }
:host([invalid]) .placeholder {
color: var(--mdc-theme-error, var(--error-color, #b00020));
} }
.unknown { .unknown {

View File

@@ -28,7 +28,6 @@ export class HaAddonSelector extends LitElement {
.helper=${this.helper} .helper=${this.helper}
.disabled=${this.disabled} .disabled=${this.disabled}
.required=${this.required} .required=${this.required}
allow-custom-entity
></ha-addon-picker>`; ></ha-addon-picker>`;
} }

View File

@@ -29,7 +29,6 @@ export class HaConfigEntrySelector extends LitElement {
.disabled=${this.disabled} .disabled=${this.disabled}
.required=${this.required} .required=${this.required}
.integration=${this.selector.config_entry?.integration} .integration=${this.selector.config_entry?.integration}
allow-custom-entity
></ha-config-entry-picker>`; ></ha-config-entry-picker>`;
} }

View File

@@ -107,7 +107,6 @@ export class HaDeviceSelector extends LitElement {
.placeholder=${this.placeholder} .placeholder=${this.placeholder}
.disabled=${this.disabled} .disabled=${this.disabled}
.required=${this.required} .required=${this.required}
allow-custom-entity
></ha-device-picker> ></ha-device-picker>
`; `;
} }

View File

@@ -66,15 +66,14 @@ export class HaEntitySelector extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.value=${this.value} .value=${this.value}
.label=${this.label} .label=${this.label}
.placeholder=${this.placeholder}
.helper=${this.helper} .helper=${this.helper}
.includeEntities=${this.selector.entity?.include_entities} .includeEntities=${this.selector.entity?.include_entities}
.excludeEntities=${this.selector.entity?.exclude_entities} .excludeEntities=${this.selector.entity?.exclude_entities}
.entityFilter=${this._filterEntities} .entityFilter=${this._filterEntities}
.createDomains=${this._createDomains} .createDomains=${this._createDomains}
.placeholder=${this.placeholder}
.disabled=${this.disabled} .disabled=${this.disabled}
.required=${this.required} .required=${this.required}
allow-custom-entity
></ha-entity-picker>`; ></ha-entity-picker>`;
} }
@@ -83,13 +82,13 @@ export class HaEntitySelector extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.value=${this.value} .value=${this.value}
.label=${this.label} .label=${this.label}
.placeholder=${this.placeholder}
.helper=${this.helper} .helper=${this.helper}
.includeEntities=${this.selector.entity.include_entities} .includeEntities=${this.selector.entity.include_entities}
.excludeEntities=${this.selector.entity.exclude_entities} .excludeEntities=${this.selector.entity.exclude_entities}
.reorder=${this.selector.entity.reorder ?? false} .reorder=${this.selector.entity.reorder ?? false}
.entityFilter=${this._filterEntities} .entityFilter=${this._filterEntities}
.createDomains=${this._createDomains} .createDomains=${this._createDomains}
.placeholder=${this.placeholder}
.disabled=${this.disabled} .disabled=${this.disabled}
.required=${this.required} .required=${this.required}
></ha-entities-picker> ></ha-entities-picker>

View File

@@ -52,7 +52,7 @@ export class HaIconSelector extends LitElement {
${!placeholder && stateObj ${!placeholder && stateObj
? html` ? html`
<ha-state-icon <ha-state-icon
slot="fallback" slot="start"
.hass=${this.hass} .hass=${this.hass}
.stateObj=${stateObj} .stateObj=${stateObj}
></ha-state-icon> ></ha-state-icon>

View File

@@ -1,7 +1,8 @@
import { mdiDragHorizontalVariant } from "@mdi/js"; import { mdiDragHorizontalVariant } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit"; import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat"; import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array"; import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation"; import { stopPropagation } from "../../common/dom/stop_propagation";
@@ -11,9 +12,8 @@ import type { HomeAssistant } from "../../types";
import "../chips/ha-chip-set"; import "../chips/ha-chip-set";
import "../chips/ha-input-chip"; import "../chips/ha-input-chip";
import "../ha-checkbox"; import "../ha-checkbox";
import "../ha-combo-box";
import type { HaComboBox } from "../ha-combo-box";
import "../ha-formfield"; import "../ha-formfield";
import "../ha-generic-picker";
import "../ha-input-helper-text"; import "../ha-input-helper-text";
import "../ha-list-item"; import "../ha-list-item";
import "../ha-radio"; import "../ha-radio";
@@ -40,8 +40,6 @@ export class HaSelectSelector extends LitElement {
@property({ type: Boolean }) public required = true; @property({ type: Boolean }) public required = true;
@query("ha-combo-box", true) private comboBox!: HaComboBox;
private _itemMoved(ev: CustomEvent): void { private _itemMoved(ev: CustomEvent): void {
ev.stopPropagation(); ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail; const { oldIndex, newIndex } = ev.detail;
@@ -59,15 +57,8 @@ export class HaSelectSelector extends LitElement {
}); });
} }
private _filter = "";
protected render() { protected render() {
const options = const options = this._getOptions(this.selector);
this.selector.select?.options?.map((option) =>
typeof option === "object"
? (option as SelectOption)
: ({ value: option, label: option } as SelectOption)
) || [];
const translationKey = this.selector.select?.translation_key; const translationKey = this.selector.select?.translation_key;
@@ -165,10 +156,6 @@ export class HaSelectSelector extends LitElement {
const value = const value =
!this.value || this.value === "" ? [] : ensureArray(this.value); !this.value || this.value === "" ? [] : ensureArray(this.value);
const optionItems = options.filter(
(option) => !option.disabled && !value?.includes(option.value)
);
return html` return html`
${value?.length ${value?.length
? html` ? html`
@@ -212,50 +199,33 @@ export class HaSelectSelector extends LitElement {
` `
: nothing} : nothing}
<ha-combo-box <ha-generic-picker
item-value-path="value"
item-label-path="label"
.hass=${this.hass} .hass=${this.hass}
.label=${this.label}
.helper=${this.helper} .helper=${this.helper}
.disabled=${this.disabled} .disabled=${this.disabled}
.required=${this.required && !value.length} .required=${this.required && !value.length}
.value=${""} .value=${""}
.items=${optionItems} .addButtonLabel=${this.label}
.getItems=${this._getItems(options, value, true)}
.allowCustomValue=${this.selector.select.custom_value ?? false} .allowCustomValue=${this.selector.select.custom_value ?? false}
@filter-changed=${this._filterChanged}
@value-changed=${this._comboBoxValueChanged} @value-changed=${this._comboBoxValueChanged}
@opened-changed=${this._openedChanged} ></ha-generic-picker>
></ha-combo-box>
`; `;
} }
if (this.selector.select?.custom_value) { if (this.selector.select?.custom_value) {
if (
this.value !== undefined &&
!Array.isArray(this.value) &&
!options.find((option) => option.value === this.value)
) {
options.unshift({ value: this.value, label: this.value });
}
const optionItems = options.filter((option) => !option.disabled);
return html` return html`
<ha-combo-box <ha-generic-picker
item-value-path="value"
item-label-path="label"
.hass=${this.hass} .hass=${this.hass}
.label=${this.label} .label=${this.label}
.helper=${this.helper} .helper=${this.helper}
.disabled=${this.disabled} .disabled=${this.disabled}
.required=${this.required} .required=${this.required}
.items=${optionItems} .getItems=${this._getItems(options)}
.value=${this.value} .value=${this.value as string | undefined}
@filter-changed=${this._filterChanged}
@value-changed=${this._comboBoxValueChanged} @value-changed=${this._comboBoxValueChanged}
@opened-changed=${this._openedChanged} allow-custom-value
></ha-combo-box> ></ha-generic-picker>
`; `;
} }
@@ -274,7 +244,7 @@ export class HaSelectSelector extends LitElement {
> >
${options.map( ${options.map(
(item: SelectOption) => html` (item: SelectOption) => html`
<ha-list-item .value=${item.value} .disabled=${item.disabled} <ha-list-item .value=${item.value} .disabled=${!!item.disabled}
>${item.label}</ha-list-item >${item.label}</ha-list-item
> >
` `
@@ -291,6 +261,30 @@ export class HaSelectSelector extends LitElement {
: ""; : "";
} }
private _getOptions = memoizeOne(
(selector: SelectSelector) =>
selector.select?.options?.map((option) =>
typeof option === "object"
? (option as SelectOption)
: ({ value: option, label: option } as SelectOption)
) || []
);
private _getItems = memoizeOne(
(options: SelectOption[], value?: string[], multiple = false) => {
const filteredOptions = options.filter((option) =>
!option.disabled && !multiple ? true : !value?.includes(option.value)
);
return () =>
filteredOptions.map((option) => ({
id: option.value,
primary: option.label,
sorting_label: option.label,
}));
}
);
private get _mode(): "list" | "dropdown" | "box" { private get _mode(): "list" | "dropdown" | "box" {
return ( return (
this.selector.select?.mode || this.selector.select?.mode ||
@@ -355,8 +349,6 @@ export class HaSelectSelector extends LitElement {
fireEvent(this, "value-changed", { fireEvent(this, "value-changed", {
value, value,
}); });
await this.updateComplete;
this._filterChanged();
} }
private _comboBoxValueChanged(ev: CustomEvent): void { private _comboBoxValueChanged(ev: CustomEvent): void {
@@ -374,49 +366,17 @@ export class HaSelectSelector extends LitElement {
return; return;
} }
const currentValue = const currentValue = !this.value ? [] : ensureArray(this.value);
!this.value || this.value === "" ? [] : ensureArray(this.value);
if (newValue !== undefined && currentValue.includes(newValue)) { if (newValue !== undefined && currentValue.includes(newValue)) {
return; return;
} }
setTimeout(() => {
this._filterChanged();
this.comboBox.setInputValue("");
}, 0);
fireEvent(this, "value-changed", { fireEvent(this, "value-changed", {
value: [...currentValue, newValue], value: [...currentValue, newValue],
}); });
} }
private _openedChanged(ev?: CustomEvent): void {
if (ev?.detail.value) {
this._filterChanged();
}
}
private _filterChanged(ev?: CustomEvent): void {
this._filter = ev?.detail.value || "";
const filteredItems = this.comboBox.items?.filter((item) => {
const label = item.label || item.value;
return label.toLowerCase().includes(this._filter?.toLowerCase());
});
if (
this._filter &&
this.selector.select?.custom_value &&
filteredItems &&
!filteredItems.some((item) => (item.label || item.value) === this._filter)
) {
filteredItems.unshift({ label: this._filter, value: this._filter });
}
this.comboBox.filteredItems = filteredItems;
}
static styles = css` static styles = css`
:host { :host {
position: relative; position: relative;

View File

@@ -135,7 +135,6 @@ class HaServicePicker extends LitElement {
<ha-generic-picker <ha-generic-picker
.hass=${this.hass} .hass=${this.hass}
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
allow-custom-value
.notFoundLabel=${this.hass.localize( .notFoundLabel=${this.hass.localize(
"ui.components.service-picker.no_match" "ui.components.service-picker.no_match"
)} )}

View File

@@ -235,6 +235,12 @@ export class HaWaDialog extends ScrollableFadeMixin(LitElement) {
); );
max-width: var(--ha-dialog-max-width, var(--safe-width)); max-width: var(--ha-dialog-max-width, var(--safe-width));
} }
@media (prefers-reduced-motion: reduce) {
wa-dialog {
--show-duration: 0ms;
--hide-duration: 0ms;
}
}
:host([width="small"]) wa-dialog { :host([width="small"]) wa-dialog {
--width: min(var(--ha-dialog-width-sm, 320px), var(--full-width)); --width: min(var(--ha-dialog-width-sm, 320px), var(--full-width));

View File

@@ -132,9 +132,9 @@ class HaUserPicker extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
.label=${this.label} .label=${this.label}
.notFoundLabel=${this._notFoundLabel}
.placeholder=${placeholder} .placeholder=${placeholder}
.value=${this.value} .value=${this.value}
.notFoundLabel=${this._notFoundLabel}
.getItems=${this._getItems} .getItems=${this._getItems}
.valueRenderer=${this._valueRenderer} .valueRenderer=${this._valueRenderer}
.rowRenderer=${this._rowRenderer} .rowRenderer=${this._rowRenderer}

View File

@@ -88,7 +88,6 @@ export const DOMAINS_HIDE_DEFAULT_MORE_INFO = [
"select", "select",
"text", "text",
"update", "update",
"event",
]; ];
/** Domains that should have the history hidden in the more info dialog. */ /** Domains that should have the history hidden in the more info dialog. */

View File

@@ -137,7 +137,7 @@ export class CloudStepSignin extends LitElement {
), ),
}); });
if (totpCode !== null && totpCode !== "") { if (totpCode !== null && totpCode !== "") {
await doLogin(username, totpCode); await doLogin(username, totpCode.trim());
return; return;
} }
} }

View File

@@ -176,6 +176,13 @@ interface EMOutgoingMessageAddEntityTo extends EMMessage {
}; };
} }
interface EMOutgoingMessageFocusElement extends EMMessage {
type: "focus_element";
payload: {
element_id: string;
};
}
type EMOutgoingMessageWithoutAnswer = type EMOutgoingMessageWithoutAnswer =
| EMMessageResultError | EMMessageResultError
| EMMessageResultSuccess | EMMessageResultSuccess
@@ -197,7 +204,8 @@ type EMOutgoingMessageWithoutAnswer =
| EMOutgoingMessageThreadStoreInPlatformKeychain | EMOutgoingMessageThreadStoreInPlatformKeychain
| EMOutgoingMessageImprovScan | EMOutgoingMessageImprovScan
| EMOutgoingMessageImprovConfigureDevice | EMOutgoingMessageImprovConfigureDevice
| EMOutgoingMessageAddEntityTo; | EMOutgoingMessageAddEntityTo
| EMOutgoingMessageFocusElement;
export interface EMIncomingMessageRestart { export interface EMIncomingMessageRestart {
id: number; id: number;

View File

@@ -0,0 +1,38 @@
import type { ReactiveElement } from "lit";
import { property } from "lit/decorators";
import type { Constructor } from "../types";
import type { PickerValueRenderer } from "../components/ha-picker-field";
export const PickerMixin = <T extends Constructor<ReactiveElement>>(
superClass: T
) => {
class PickerFieldClass extends superClass {
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@property() public icon?: string;
@property() public image?: string;
@property() public label?: string;
@property() public placeholder?: string;
@property() public helper?: string;
@property() public value?: string;
@property({ type: Boolean, reflect: true }) public unknown = false;
@property({ attribute: "unknown-item-text" })
public unknownItemText?: string;
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@property({ attribute: false })
public valueRenderer?: PickerValueRenderer;
}
return PickerFieldClass;
};

View File

@@ -5,13 +5,15 @@ import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert"; import "../../../components/ha-alert";
import "../../../components/ha-button"; import "../../../components/ha-button";
import "../../../components/ha-combo-box"; import "../../../components/ha-dialog-footer";
import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-fade-in"; import "../../../components/ha-fade-in";
import "../../../components/ha-generic-picker";
import "../../../components/ha-markdown"; import "../../../components/ha-markdown";
import "../../../components/ha-password-field"; import "../../../components/ha-password-field";
import type { PickerComboBoxItem } from "../../../components/ha-picker-combo-box";
import "../../../components/ha-spinner"; import "../../../components/ha-spinner";
import "../../../components/ha-textfield"; import "../../../components/ha-textfield";
import "../../../components/ha-wa-dialog";
import type { import type {
ApplicationCredential, ApplicationCredential,
ApplicationCredentialsConfig, ApplicationCredentialsConfig,
@@ -59,6 +61,10 @@ export class DialogAddApplicationCredential extends LitElement {
@state() private _config?: ApplicationCredentialsConfig; @state() private _config?: ApplicationCredentialsConfig;
@state() private _open = false;
@state() private _invalid = false;
public showDialog(params: AddApplicationCredentialDialogParams) { public showDialog(params: AddApplicationCredentialDialogParams) {
this._params = params; this._params = params;
this._domain = params.selectedDomain; this._domain = params.selectedDomain;
@@ -69,6 +75,7 @@ export class DialogAddApplicationCredential extends LitElement {
this._clientSecret = ""; this._clientSecret = "";
this._error = undefined; this._error = undefined;
this._loading = false; this._loading = false;
this._open = true;
this._fetchConfig(); this._fetchConfig();
} }
@@ -90,16 +97,16 @@ export class DialogAddApplicationCredential extends LitElement {
? domainToName(this.hass.localize, this._domain!) ? domainToName(this.hass.localize, this._domain!)
: ""; : "";
return html` return html`
<ha-dialog <ha-wa-dialog
open .hass=${this.hass}
.open=${this._open}
@closed=${this._abortDialog} @closed=${this._abortDialog}
scrimClickAction .preventScrimClose=${!!this._domain ||
escapeKeyAction !!this._name ||
.heading=${createCloseHeading( !!this._clientId ||
this.hass, !!this._clientSecret}
this.hass.localize( .headerTitle=${this.hass.localize(
"ui.panel.config.application_credentials.editor.caption" "ui.panel.config.application_credentials.editor.caption"
)
)} )}
> >
${!this._config ${!this._config
@@ -165,20 +172,23 @@ export class DialogAddApplicationCredential extends LitElement {
: nothing} : nothing}
${this._params.selectedDomain ${this._params.selectedDomain
? nothing ? nothing
: html`<ha-combo-box : html`<ha-generic-picker
name="domain" name="domain"
.hass=${this.hass} .hass=${this.hass}
.label=${this.hass.localize( .label=${this.hass.localize(
"ui.panel.config.application_credentials.editor.domain" "ui.panel.config.application_credentials.editor.domain"
)} )}
.value=${this._domain} .value=${this._domain}
.items=${this._domains} .invalid=${this._invalid && !this._domain}
item-id-path="id" .getItems=${this._getDomainItems}
item-value-path="id"
item-label-path="name"
required required
.disabled=${!this._domains}
.valueRenderer=${this._domainRenderer}
@value-changed=${this._handleDomainPicked} @value-changed=${this._handleDomainPicked}
></ha-combo-box>`} .errorMessage=${this.hass.localize(
"ui.common.error_required"
)}
></ha-generic-picker>`}
${this._description ${this._description
? html`<ha-markdown ? html`<ha-markdown
breaks breaks
@@ -192,9 +202,10 @@ export class DialogAddApplicationCredential extends LitElement {
"ui.panel.config.application_credentials.editor.name" "ui.panel.config.application_credentials.editor.name"
)} )}
.value=${this._name} .value=${this._name}
.invalid=${this._invalid && !this._name}
required required
@input=${this._handleValueChanged} @input=${this._handleValueChanged}
.validationMessage=${this.hass.localize( .errorMessage=${this.hass.localize(
"ui.common.error_required" "ui.common.error_required"
)} )}
dialogInitialFocus dialogInitialFocus
@@ -206,9 +217,10 @@ export class DialogAddApplicationCredential extends LitElement {
"ui.panel.config.application_credentials.editor.client_id" "ui.panel.config.application_credentials.editor.client_id"
)} )}
.value=${this._clientId} .value=${this._clientId}
.invalid=${this._invalid && !this._clientId}
required required
@input=${this._handleValueChanged} @input=${this._handleValueChanged}
.validationMessage=${this.hass.localize( .errorMessage=${this.hass.localize(
"ui.common.error_required" "ui.common.error_required"
)} )}
dialogInitialFocus dialogInitialFocus
@@ -223,9 +235,10 @@ export class DialogAddApplicationCredential extends LitElement {
)} )}
name="clientSecret" name="clientSecret"
.value=${this._clientSecret} .value=${this._clientSecret}
.invalid=${this._invalid && !this._clientSecret}
required required
@input=${this._handleValueChanged} @input=${this._handleValueChanged}
.validationMessage=${this.hass.localize( .errorMessage=${this.hass.localize(
"ui.common.error_required" "ui.common.error_required"
)} )}
.helper=${this.hass.localize( .helper=${this.hass.localize(
@@ -235,30 +248,33 @@ export class DialogAddApplicationCredential extends LitElement {
></ha-password-field> ></ha-password-field>
</div> </div>
<ha-dialog-footer slot="footer">
<ha-button <ha-button
appearance="plain" appearance="plain"
slot="secondaryAction" slot="secondaryAction"
@click=${this._abortDialog} @click=${this._closeDialog}
.disabled=${this._loading} .disabled=${this._loading}
> >
${this.hass.localize("ui.common.cancel")} ${this.hass.localize("ui.common.cancel")}
</ha-button> </ha-button>
<ha-button <ha-button
slot="primaryAction" slot="primaryAction"
.disabled=${!this._domain ||
!this._clientId ||
!this._clientSecret}
@click=${this._addApplicationCredential} @click=${this._addApplicationCredential}
.loading=${this._loading} .loading=${this._loading}
> >
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.application_credentials.editor.add" "ui.panel.config.application_credentials.editor.add"
)} )}
</ha-button>`} </ha-button>
</ha-dialog> </ha-dialog-footer>`}
</ha-wa-dialog>
`; `;
} }
private _closeDialog() {
this._open = false;
}
public closeDialog() { public closeDialog() {
this._params = undefined; this._params = undefined;
this._domains = undefined; this._domains = undefined;
@@ -303,9 +319,16 @@ export class DialogAddApplicationCredential extends LitElement {
private async _addApplicationCredential(ev) { private async _addApplicationCredential(ev) {
ev.preventDefault(); ev.preventDefault();
if (!this._domain || !this._clientId || !this._clientSecret) { if (
!this._domain ||
!this._name ||
!this._clientId ||
!this._clientSecret
) {
this._invalid = true;
return; return;
} }
this._invalid = false;
this._loading = true; this._loading = true;
this._error = ""; this._error = "";
@@ -328,6 +351,20 @@ export class DialogAddApplicationCredential extends LitElement {
this.closeDialog(); this.closeDialog();
} }
private _getDomainItems = (): PickerComboBoxItem[] =>
this._domains?.map((domain) => ({
id: domain.id,
primary: domain.name,
sorting_label: domain.name,
})) ?? [];
private _domainRenderer = (domainId: string) => {
const domain = this._domains?.find((d) => d.id === domainId);
return html`<span slot="headline"
>${domain ? domain.name : domainId}</span
>`;
};
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyleDialog, haStyleDialog,
@@ -338,15 +375,12 @@ export class DialogAddApplicationCredential extends LitElement {
} }
.row { .row {
display: flex; display: flex;
padding: 8px 0; padding: var(--ha-space-2) 0;
}
ha-combo-box {
display: block;
margin-bottom: 24px;
} }
ha-textfield { ha-textfield {
display: block; display: block;
margin-bottom: 24px; margin-top: var(--ha-space-4);
margin-bottom: var(--ha-space-4);
} }
a { a {
text-decoration: none; text-decoration: none;
@@ -355,7 +389,8 @@ export class DialogAddApplicationCredential extends LitElement {
--mdc-icon-size: 16px; --mdc-icon-size: 16px;
} }
ha-markdown { ha-markdown {
margin-bottom: 16px; margin-top: var(--ha-space-4);
margin-bottom: var(--ha-space-4);
} }
ha-fade-in { ha-fade-in {
display: flex; display: flex;

View File

@@ -11,12 +11,13 @@ import "../../../components/ha-alert";
import "../../../components/ha-aliases-editor"; import "../../../components/ha-aliases-editor";
import "../../../components/ha-area-picker"; import "../../../components/ha-area-picker";
import "../../../components/ha-button"; import "../../../components/ha-button";
import { createCloseHeading } from "../../../components/ha-dialog"; import "../../../components/ha-dialog-footer";
import "../../../components/ha-floor-icon";
import "../../../components/ha-icon-picker"; import "../../../components/ha-icon-picker";
import "../../../components/ha-picture-upload";
import "../../../components/ha-settings-row"; import "../../../components/ha-settings-row";
import "../../../components/ha-svg-icon"; import "../../../components/ha-svg-icon";
import "../../../components/ha-textfield"; import "../../../components/ha-textfield";
import "../../../components/ha-wa-dialog";
import { updateAreaRegistryEntry } from "../../../data/area_registry"; import { updateAreaRegistryEntry } from "../../../data/area_registry";
import type { import type {
FloorRegistryEntry, FloorRegistryEntry,
@@ -49,6 +50,8 @@ class DialogFloorDetail extends LitElement {
@state() private _removedAreas = new Set<string>(); @state() private _removedAreas = new Set<string>();
@state() private _open = false;
public showDialog(params: FloorRegistryDetailDialogParams): void { public showDialog(params: FloorRegistryDetailDialogParams): void {
this._params = params; this._params = params;
this._error = undefined; this._error = undefined;
@@ -60,9 +63,14 @@ class DialogFloorDetail extends LitElement {
this._level = this._params.entry?.level ?? null; this._level = this._params.entry?.level ?? null;
this._addedAreas.clear(); this._addedAreas.clear();
this._removedAreas.clear(); this._removedAreas.clear();
this._open = true;
} }
public closeDialog(): void { public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this._error = ""; this._error = "";
this._params = undefined; this._params = undefined;
this._addedAreas.clear(); this._addedAreas.clear();
@@ -96,18 +104,15 @@ class DialogFloorDetail extends LitElement {
return nothing; return nothing;
} }
const entry = this._params.entry; const entry = this._params.entry;
const nameInvalid = !this._isNameValid();
return html` return html`
<ha-dialog <ha-wa-dialog
open .hass=${this.hass}
@closed=${this.closeDialog} .open=${this._open}
.heading=${createCloseHeading( header-title=${entry
this.hass,
entry
? this.hass.localize("ui.panel.config.floors.editor.update_floor") ? this.hass.localize("ui.panel.config.floors.editor.update_floor")
: this.hass.localize("ui.panel.config.floors.editor.create_floor") : this.hass.localize("ui.panel.config.floors.editor.create_floor")}
)} @closed=${this._dialogClosed}
> >
<div> <div>
${this._error ${this._error
@@ -128,6 +133,7 @@ class DialogFloorDetail extends LitElement {
: nothing} : nothing}
<ha-textfield <ha-textfield
autofocus
.value=${this._name} .value=${this._name}
@input=${this._nameChanged} @input=${this._nameChanged}
.label=${this.hass.localize("ui.panel.config.floors.editor.name")} .label=${this.hass.localize("ui.panel.config.floors.editor.name")}
@@ -135,7 +141,6 @@ class DialogFloorDetail extends LitElement {
"ui.panel.config.floors.editor.name_required" "ui.panel.config.floors.editor.name_required"
)} )}
required required
dialogInitialFocus
></ha-textfield> ></ha-textfield>
<ha-textfield <ha-textfield
@@ -160,7 +165,7 @@ class DialogFloorDetail extends LitElement {
${!this._icon ${!this._icon
? html` ? html`
<ha-floor-icon <ha-floor-icon
slot="fallback" slot="start"
.floor=${{ level: this._level }} .floor=${{ level: this._level }}
></ha-floor-icon> ></ha-floor-icon>
` `
@@ -230,6 +235,7 @@ class DialogFloorDetail extends LitElement {
></ha-aliases-editor> ></ha-aliases-editor>
</div> </div>
</div> </div>
<ha-dialog-footer slot="footer">
<ha-button <ha-button
appearance="plain" appearance="plain"
slot="secondaryAction" slot="secondaryAction"
@@ -240,13 +246,14 @@ class DialogFloorDetail extends LitElement {
<ha-button <ha-button
slot="primaryAction" slot="primaryAction"
@click=${this._updateEntry} @click=${this._updateEntry}
.disabled=${nameInvalid || !!this._submitting} .disabled=${!!this._submitting}
> >
${entry ${entry
? this.hass.localize("ui.common.save") ? this.hass.localize("ui.common.save")
: this.hass.localize("ui.common.create")} : this.hass.localize("ui.common.create")}
</ha-button> </ha-button>
</ha-dialog> </ha-dialog-footer>
</ha-wa-dialog>
`; `;
} }
@@ -285,10 +292,6 @@ class DialogFloorDetail extends LitElement {
this._addedAreas = new Set(this._addedAreas); this._addedAreas = new Set(this._addedAreas);
} }
private _isNameValid() {
return this._name.trim() !== "";
}
private _nameChanged(ev) { private _nameChanged(ev) {
this._error = undefined; this._error = undefined;
this._name = ev.target.value; this._name = ev.target.value;
@@ -305,7 +308,16 @@ class DialogFloorDetail extends LitElement {
} }
private async _updateEntry() { private async _updateEntry() {
if (this._name.trim() === "") {
this._error = this.hass.localize(
"ui.panel.config.floors.editor.name_required"
);
return;
}
this._error = undefined;
this._submitting = true; this._submitting = true;
const create = !this._params!.entry; const create = !this._params!.entry;
try { try {
const values: FloorRegistryEntryMutableParams = { const values: FloorRegistryEntryMutableParams = {
@@ -344,13 +356,13 @@ class DialogFloorDetail extends LitElement {
css` css`
ha-textfield { ha-textfield {
display: block; display: block;
margin-bottom: 16px; margin-bottom: var(--ha-space-4);
} }
ha-floor-icon { ha-floor-icon {
color: var(--secondary-text-color); color: var(--secondary-text-color);
} }
ha-chip-set { ha-chip-set {
margin-bottom: 8px; margin-bottom: var(--ha-space-2);
} }
`, `,
]; ];

View File

@@ -148,7 +148,7 @@ class DialogAutomationSave extends LitElement implements HassDialog {
@value-changed=${this._iconChanged} @value-changed=${this._iconChanged}
> >
<ha-domain-icon <ha-domain-icon
slot="fallback" slot="start"
domain=${this._params.domain} domain=${this._params.domain}
.hass=${this.hass} .hass=${this.hass}
> >
@@ -176,8 +176,10 @@ class DialogAutomationSave extends LitElement implements HassDialog {
id="category" id="category"
.hass=${this.hass} .hass=${this.hass}
.scope=${this._params.domain} .scope=${this._params.domain}
.label=${this.hass.localize(
"ui.components.category-picker.category"
)}
.value=${this._entryUpdates.category} .value=${this._entryUpdates.category}
show-label
@value-changed=${this._registryEntryChanged} @value-changed=${this._registryEntryChanged}
></ha-category-picker>` ></ha-category-picker>`
: nothing} : nothing}
@@ -194,7 +196,6 @@ class DialogAutomationSave extends LitElement implements HassDialog {
id="area" id="area"
.hass=${this.hass} .hass=${this.hass}
.value=${this._entryUpdates.area} .value=${this._entryUpdates.area}
show-label
@value-changed=${this._registryEntryChanged} @value-changed=${this._registryEntryChanged}
></ha-area-picker>` ></ha-area-picker>`
: nothing} : nothing}

View File

@@ -40,7 +40,6 @@ export class HaZoneCondition extends LitElement {
@value-changed=${this._entityPicked} @value-changed=${this._entityPicked}
.hass=${this.hass} .hass=${this.hass}
.disabled=${this.disabled} .disabled=${this.disabled}
allow-custom-entity
.entityFilter=${zoneAndLocationFilter} .entityFilter=${zoneAndLocationFilter}
></ha-entity-picker> ></ha-entity-picker>
<ha-entity-picker <ha-entity-picker
@@ -51,7 +50,6 @@ export class HaZoneCondition extends LitElement {
@value-changed=${this._zonePicked} @value-changed=${this._zonePicked}
.hass=${this.hass} .hass=${this.hass}
.disabled=${this.disabled} .disabled=${this.disabled}
allow-custom-entity
.includeDomains=${includeDomains} .includeDomains=${includeDomains}
></ha-entity-picker> ></ha-entity-picker>
`; `;

View File

@@ -43,7 +43,6 @@ export class HaZoneTrigger extends LitElement {
.disabled=${this.disabled} .disabled=${this.disabled}
@value-changed=${this._entityPicked} @value-changed=${this._entityPicked}
.hass=${this.hass} .hass=${this.hass}
allow-custom-entity
.entityFilter=${zoneAndLocationFilter} .entityFilter=${zoneAndLocationFilter}
></ha-entity-picker> ></ha-entity-picker>
<ha-entity-picker <ha-entity-picker
@@ -54,7 +53,6 @@ export class HaZoneTrigger extends LitElement {
.disabled=${this.disabled} .disabled=${this.disabled}
@value-changed=${this._zonePicked} @value-changed=${this._zonePicked}
.hass=${this.hass} .hass=${this.hass}
allow-custom-entity
.includeDomains=${includeDomains} .includeDomains=${includeDomains}
></ha-entity-picker> ></ha-entity-picker>

View File

@@ -65,6 +65,9 @@ class DialogAssignCategory extends LitElement {
<ha-category-picker <ha-category-picker
.hass=${this.hass} .hass=${this.hass}
.scope=${this._scope} .scope=${this._scope}
.label=${this.hass.localize(
"ui.components.category-picker.category"
)}
.value=${this._category} .value=${this._category}
@value-changed=${this._categoryChanged} @value-changed=${this._categoryChanged}
></ha-category-picker> ></ha-category-picker>

View File

@@ -39,9 +39,6 @@ export class HaCategoryPicker extends SubscribeMixin(LitElement) {
@property({ type: Boolean, attribute: "no-add" }) @property({ type: Boolean, attribute: "no-add" })
public noAdd = false; public noAdd = false;
@property({ type: Boolean, attribute: "show-label" })
public showLabel = false;
@property({ type: Boolean }) public disabled = false; @property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false; @property({ type: Boolean }) public required = false;
@@ -183,10 +180,6 @@ export class HaCategoryPicker extends SubscribeMixin(LitElement) {
}; };
protected render(): TemplateResult { protected render(): TemplateResult {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.category-picker.category");
const valueRenderer = this._computeValueRenderer(this._categories); const valueRenderer = this._computeValueRenderer(this._categories);
return html` return html`
@@ -194,13 +187,12 @@ export class HaCategoryPicker extends SubscribeMixin(LitElement) {
.hass=${this.hass} .hass=${this.hass}
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
.label=${this.label} .label=${this.label}
.placeholder=${this.placeholder}
.value=${this.value}
.notFoundLabel=${this._notFoundLabel} .notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass.localize( .emptyLabel=${this.hass.localize(
"ui.components.category-picker.no_categories" "ui.components.category-picker.no_categories"
)} )}
.placeholder=${placeholder}
.showLabel=${this.showLabel}
.value=${this.value}
.getItems=${this._getItems} .getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems} .getAdditionalItems=${this._getAdditionalItems}
.valueRenderer=${valueRenderer} .valueRenderer=${valueRenderer}

View File

@@ -155,7 +155,7 @@ export class CloudLogin extends LitElement {
), ),
}); });
if (totpCode !== null && totpCode !== "") { if (totpCode !== null && totpCode !== "") {
this._login(email, password, checkConnection, totpCode); this._login(email, password, checkConnection, totpCode.trim());
return "continue"; return "continue";
} }
} }

View File

@@ -80,7 +80,6 @@ class DialogDeviceRegistryDetail extends LitElement {
<ha-area-picker <ha-area-picker
.hass=${this.hass} .hass=${this.hass}
.value=${this._areaId} .value=${this._areaId}
show-label
@value-changed=${this._areaPicked} @value-changed=${this._areaPicked}
></ha-area-picker> ></ha-area-picker>
<ha-labels-picker <ha-labels-picker

View File

@@ -403,7 +403,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
${!this._icon && !stateObj?.attributes.icon && stateObj ${!this._icon && !stateObj?.attributes.icon && stateObj
? html` ? html`
<ha-state-icon <ha-state-icon
slot="fallback" slot="start"
.hass=${this.hass} .hass=${this.hass}
.stateObj=${stateObj} .stateObj=${stateObj}
></ha-state-icon> ></ha-state-icon>
@@ -778,7 +778,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.value=${this._areaId} .value=${this._areaId}
.disabled=${this.disabled} .disabled=${this.disabled}
show-label
@value-changed=${this._areaPicked} @value-changed=${this._areaPicked}
></ha-area-picker>` ></ha-area-picker>`
: ""} : ""}
@@ -1013,7 +1012,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
? html`<ha-area-picker ? html`<ha-area-picker
.hass=${this.hass} .hass=${this.hass}
.value=${this._areaId} .value=${this._areaId}
show-label
.disabled=${this.disabled} .disabled=${this.disabled}
@value-changed=${this._areaPicked} @value-changed=${this._areaPicked}
></ha-area-picker>` ></ha-area-picker>`
@@ -1545,6 +1543,12 @@ export class EntityRegistrySettingsEditor extends LitElement {
margin-inline-end: 0; margin-inline-end: 0;
margin-inline-start: initial; margin-inline-start: initial;
} }
ha-settings-row {
display: grid;
grid-template-columns: 1fr auto;
gap: var(--ha-space-4);
align-items: start;
}
ha-textfield, ha-textfield,
ha-icon-picker, ha-icon-picker,
ha-select, ha-select,

View File

@@ -16,13 +16,14 @@ import "../../../../../components/buttons/ha-progress-button";
import type { HaProgressButton } from "../../../../../components/buttons/ha-progress-button"; import type { HaProgressButton } from "../../../../../components/buttons/ha-progress-button";
import "../../../../../components/ha-alert"; import "../../../../../components/ha-alert";
import "../../../../../components/ha-card"; import "../../../../../components/ha-card";
import "../../../../../components/ha-generic-picker";
import "../../../../../components/ha-list-item"; import "../../../../../components/ha-list-item";
import type { PickerComboBoxItem } from "../../../../../components/ha-picker-combo-box";
import "../../../../../components/ha-select"; import "../../../../../components/ha-select";
import "../../../../../components/ha-selector/ha-selector-boolean"; import "../../../../../components/ha-selector/ha-selector-boolean";
import "../../../../../components/ha-settings-row"; import "../../../../../components/ha-settings-row";
import "../../../../../components/ha-svg-icon"; import "../../../../../components/ha-svg-icon";
import "../../../../../components/ha-textfield"; import "../../../../../components/ha-textfield";
import "../../../../../components/ha-combo-box";
import type { import type {
ZWaveJSNodeCapabilities, ZWaveJSNodeCapabilities,
ZWaveJSNodeConfigParam, ZWaveJSNodeConfigParam,
@@ -329,19 +330,22 @@ class ZWaveJSNodeConfig extends LitElement {
) { ) {
return html` return html`
${labelAndDescription} ${labelAndDescription}
<ha-combo-box <ha-generic-picker
.hass=${this.hass} .hass=${this.hass}
.value=${item.value?.toString()} .value=${item.value?.toString()}
allow-custom-value allow-custom-value
hide-clear-icon hide-clear-icon
.items=${this._getComboBoxOptions(item.metadata.states)} .getItems=${this._getManualEntryItems(item.metadata.states)}
.disabled=${!item.metadata.writeable} .disabled=${!item.metadata.writeable}
.invalid=${result?.status === "error"} .invalid=${result?.status === "error"}
.placeholder=${item.metadata.unit} .placeholder=${item.metadata.unit}
.helper=${`${this.hass.localize("ui.panel.config.zwave_js.node_config.between_min_max", { min: item.metadata.min, max: item.metadata.max })}${defaultLabel ? `, ${defaultLabel}` : ""}`} .helper=${`${this.hass.localize("ui.panel.config.zwave_js.node_config.between_min_max", { min: item.metadata.min, max: item.metadata.max })}${defaultLabel ? `, ${defaultLabel}` : ""}`}
.valueRenderer=${this._enumeratedPickerValueRenderer(
item.metadata.states
)}
@value-changed=${this._getComboBoxValueChangedCallback(id, item)} @value-changed=${this._getComboBoxValueChangedCallback(id, item)}
> >
</ha-combo-box> </ha-generic-picker>
`; `;
} }
return html`${labelAndDescription} return html`${labelAndDescription}
@@ -363,7 +367,10 @@ class ZWaveJSNodeConfig extends LitElement {
</ha-textfield>`; </ha-textfield>`;
} }
if (item.configuration_value_type === "enumerated") { if (
item.configuration_value_type === "enumerated" &&
Object.keys(item.metadata.states).length < 5
) {
return html` return html`
${labelAndDescription} ${labelAndDescription}
<ha-select <ha-select
@@ -385,6 +392,28 @@ class ZWaveJSNodeConfig extends LitElement {
</ha-select> </ha-select>
`; `;
} }
if (item.configuration_value_type === "enumerated") {
return html`
${labelAndDescription}
<ha-generic-picker
.hass=${this.hass}
.disabled=${!item.metadata.writeable}
.value=${item.value?.toString()}
.key=${id}
hide-clear-icon
@value-changed=${this._pickerValueChanged}
.helper=${defaultLabel}
.getItems=${this._getEnumeratedPickerItems(item.metadata.states!)}
.valueRenderer=${this._enumeratedPickerValueRenderer(
item.metadata.states!
)}
.property=${item.property}
.endpoint=${item.endpoint}
.propertyKey=${item.property_key}
>
</ha-generic-picker>
`;
}
return html`${labelAndDescription} return html`${labelAndDescription}
<p>${item.value}</p>`; <p>${item.value}</p>`;
@@ -429,15 +458,23 @@ class ZWaveJSNodeConfig extends LitElement {
} }
private _dropdownSelected(ev) { private _dropdownSelected(ev) {
this._handleEnumeratedPickerValueChanged(ev, ev.target.value);
}
private _pickerValueChanged(ev) {
this._handleEnumeratedPickerValueChanged(ev, ev.detail.value);
}
private _handleEnumeratedPickerValueChanged(ev, value: string) {
if (ev.target === undefined || this._config![ev.target.key] === undefined) { if (ev.target === undefined || this._config![ev.target.key] === undefined) {
return; return;
} }
if (this._config![ev.target.key].value?.toString() === ev.target.value) { if (this._config![ev.target.key].value?.toString() === value) {
return; return;
} }
this._setResult(ev.target.key, undefined); this._setResult(ev.target.key, undefined);
this._updateConfigParameter(ev.target, Number(ev.target.value)); this._updateConfigParameter(ev.target, Number(value));
} }
private _numericInputChanged(ev) { private _numericInputChanged(ev) {
@@ -474,11 +511,36 @@ class ZWaveJSNodeConfig extends LitElement {
this._updateConfigParameter(ev.target, value); this._updateConfigParameter(ev.target, value);
} }
private _getComboBoxOptions = memoizeOne((states: Record<string, string>) => private _getEnumeratedPickerItems = memoizeOne(
Object.entries(states).map(([value, label]) => ({ (states: Record<string, string>) => {
value, const items: PickerComboBoxItem[] = Object.entries(states).map(
label: `${value} - ${label}`, ([value, label]) => ({
})) id: value,
primary: label,
sorting_label: `${label}_${value}`,
})
);
return () => items;
}
);
private _enumeratedPickerValueRenderer = memoizeOne(
(states: Record<string, string>) => (value: string) =>
html`<span slot="headline">${states[value] || value}</span>`
);
private _getManualEntryItems = memoizeOne(
(states: Record<string, string>) => {
const items: PickerComboBoxItem[] = Object.entries(states).map(
([value, label]) => ({
id: value,
primary: `${label}`,
secondary: value,
sorting_label: `${label}_${value}`,
})
);
return () => items;
}
); );
private _getComboBoxValueChangedCallback( private _getComboBoxValueChangedCallback(

View File

@@ -5,9 +5,10 @@ import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert"; import "../../../components/ha-alert";
import "../../../components/ha-button"; import "../../../components/ha-button";
import "../../../components/ha-color-picker"; import "../../../components/ha-color-picker";
import { createCloseHeading } from "../../../components/ha-dialog"; import "../../../components/ha-dialog-footer";
import "../../../components/ha-icon-picker"; import "../../../components/ha-icon-picker";
import "../../../components/ha-switch"; import "../../../components/ha-switch";
import "../../../components/ha-wa-dialog";
import "../../../components/ha-textarea"; import "../../../components/ha-textarea";
import "../../../components/ha-textfield"; import "../../../components/ha-textfield";
import type { LabelRegistryEntryMutableParams } from "../../../data/label/label_registry"; import type { LabelRegistryEntryMutableParams } from "../../../data/label/label_registry";
@@ -37,6 +38,8 @@ class DialogLabelDetail
@state() private _submitting = false; @state() private _submitting = false;
@state() private _open = false;
public showDialog(params: LabelDetailDialogParams): void { public showDialog(params: LabelDetailDialogParams): void {
this._params = params; this._params = params;
this._error = undefined; this._error = undefined;
@@ -51,20 +54,17 @@ class DialogLabelDetail
this._color = ""; this._color = "";
this._description = ""; this._description = "";
} }
document.body.addEventListener("keydown", this._handleKeyPress); this._open = true;
} }
private _handleKeyPress = (ev: KeyboardEvent) => {
if (ev.key === "Escape") {
ev.stopPropagation();
}
};
public closeDialog() { public closeDialog() {
this._open = false;
return true;
}
private _dialogClosed(): void {
this._params = undefined; this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName }); fireEvent(this, "dialog-closed", { dialog: this.localName });
document.body.removeEventListener("keydown", this._handleKeyPress);
return true;
} }
protected render() { protected render() {
@@ -73,17 +73,13 @@ class DialogLabelDetail
} }
return html` return html`
<ha-dialog <ha-wa-dialog
open .hass=${this.hass}
@closed=${this.closeDialog} .open=${this._open}
scrimClickAction header-title=${this._params.entry
escapeKeyAction
.heading=${createCloseHeading(
this.hass,
this._params.entry
? this._params.entry.name || this._params.entry.label_id ? this._params.entry.name || this._params.entry.label_id
: this.hass!.localize("ui.dialogs.label-detail.new_label") : this.hass!.localize("ui.dialogs.label-detail.new_label")}
)} @closed=${this._dialogClosed}
> >
<div> <div>
${this._error ${this._error
@@ -91,7 +87,7 @@ class DialogLabelDetail
: ""} : ""}
<div class="form"> <div class="form">
<ha-textfield <ha-textfield
dialogInitialFocus autofocus
.value=${this._name} .value=${this._name}
.configValue=${"name"} .configValue=${"name"}
@input=${this._input} @input=${this._input}
@@ -125,6 +121,8 @@ class DialogLabelDetail
></ha-textarea> ></ha-textarea>
</div> </div>
</div> </div>
<ha-dialog-footer slot="footer">
${this._params.entry && this._params.removeEntry ${this._params.entry && this._params.removeEntry
? html` ? html`
<ha-button <ha-button
@@ -147,7 +145,8 @@ class DialogLabelDetail
? this.hass!.localize("ui.common.update") ? this.hass!.localize("ui.common.update")
: this.hass!.localize("ui.common.create")} : this.hass!.localize("ui.common.create")}
</ha-button> </ha-button>
</ha-dialog> </ha-dialog-footer>
</ha-wa-dialog>
`; `;
} }

View File

@@ -673,12 +673,12 @@ class HaPanelDevAction extends LitElement {
haStyle, haStyle,
css` css`
.content { .content {
padding: 16px; padding: var(--ha-space-4);
max-width: 1200px; max-width: 1200px;
margin: auto; margin: auto;
} }
.button-row { .button-row {
padding: 8px 16px; padding: var(--ha-space-2) var(--ha-space-4);
border-top: 1px solid var(--divider-color); border-top: 1px solid var(--divider-color);
border-bottom: 1px solid var(--divider-color); border-bottom: 1px solid var(--divider-color);
background: var(--card-background-color); background: var(--card-background-color);
@@ -698,8 +698,8 @@ class HaPanelDevAction extends LitElement {
align-items: center; align-items: center;
} }
.switch-mode-container .error { .switch-mode-container .error {
margin-left: 8px; margin-left: var(--ha-space-2);
margin-inline-start: 8px; margin-inline-start: var(--ha-space-2);
margin-inline-end: initial; margin-inline-end: initial;
} }
.attributes { .attributes {
@@ -732,7 +732,7 @@ class HaPanelDevAction extends LitElement {
} }
.attributes td { .attributes td {
padding: 4px; padding: var(--ha-space-1);
vertical-align: middle; vertical-align: middle;
} }
@@ -748,7 +748,7 @@ class HaPanelDevAction extends LitElement {
.response img { .response img {
max-width: 100%; max-width: 100%;
height: auto; height: auto;
margin-top: 24px; margin-top: var(--ha-space-6);
} }
`, `,
]; ];

View File

@@ -240,13 +240,13 @@ class HaPanelDevAssist extends SubscribeMixin(LitElement) {
haStyle, haStyle,
css` css`
.content { .content {
padding: 28px 20px 16px; padding: var(--ha-space-7) var(--ha-space-5) var(--ha-space-4);
max-width: 1040px; max-width: 1040px;
margin: 0 auto; margin: 0 auto;
} }
.description { .description {
margin: 0; margin: 0;
margin-bottom: 16px; margin-bottom: var(--ha-space-4);
} }
ha-textarea { ha-textarea {
width: 100%; width: 100%;
@@ -255,18 +255,18 @@ class HaPanelDevAssist extends SubscribeMixin(LitElement) {
text-align: right; text-align: right;
} }
.form { .form {
margin-bottom: 16px; margin-bottom: var(--ha-space-4);
} }
.result-toolbar { .result-toolbar {
text-align: center; text-align: center;
margin-bottom: 16px; margin-bottom: var(--ha-space-4);
} }
.result { .result {
margin-bottom: 16px; margin-bottom: var(--ha-space-4);
} }
.sentence { .sentence {
font-weight: var(--ha-font-weight-medium); font-weight: var(--ha-font-weight-medium);
margin-bottom: 8px; margin-bottom: var(--ha-space-2);
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
@@ -280,7 +280,7 @@ class HaPanelDevAssist extends SubscribeMixin(LitElement) {
ha-code-editor, ha-code-editor,
ha-alert { ha-alert {
display: block; display: block;
margin-top: 16px; margin-top: var(--ha-space-4);
} }
`, `,
]; ];

View File

@@ -1,10 +1,20 @@
import { LitElement, css, html } from "lit"; import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import "../../../components/ha-card"; import "../../../components/ha-card";
import "../../../components/ha-button";
import "../../../components/entity/ha-entity-picker";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles"; import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import "./ha-debug-connection-row"; import "./ha-debug-connection-row";
import {
getStatisticMetadata,
validateStatistics,
} from "../../../data/recorder";
import { computeDomain } from "../../../common/entity/compute_domain";
import { copyToClipboard } from "../../../common/util/copy-clipboard";
import { showToast } from "../../../util/toast";
import { getExtendedEntityRegistryEntry } from "../../../data/entity/entity_registry";
@customElement("developer-tools-debug") @customElement("developer-tools-debug")
class HaPanelDevDebug extends SubscribeMixin(LitElement) { class HaPanelDevDebug extends SubscribeMixin(LitElement) {
@@ -12,6 +22,8 @@ class HaPanelDevDebug extends SubscribeMixin(LitElement) {
@property({ type: Boolean }) public narrow = false; @property({ type: Boolean }) public narrow = false;
@state() private _entityId?: string;
protected render() { protected render() {
return html` return html`
<div class="content"> <div class="content">
@@ -25,15 +37,83 @@ class HaPanelDevDebug extends SubscribeMixin(LitElement) {
.narrow=${this.narrow} .narrow=${this.narrow}
></ha-debug-connection-row> ></ha-debug-connection-row>
</ha-card> </ha-card>
<ha-card
.header=${this.hass.localize(
"ui.panel.developer-tools.tabs.debug.entity_diagnostic.title"
)}
>
<div class="card-content">
<ha-entity-picker
.hass=${this.hass}
.helper=${this.hass.localize(
"ui.panel.developer-tools.tabs.debug.entity_diagnostic.description"
)}
@value-changed=${this._entityPicked}
></ha-entity-picker>
</div>
<div class="card-actions">
<ha-button
@click=${this._copyEntityDiagnostic}
appearance="filled"
.disabled=${!this._entityId}
>${this.hass.localize(
"ui.panel.developer-tools.tabs.debug.entity_diagnostic.copy_to_clipboard"
)}</ha-button
>
</div>
</ha-card>
</div> </div>
`; `;
} }
private async _copyEntityDiagnostic() {
const id = this._entityId!;
let statistic;
if (computeDomain(id) === "sensor") {
const [metadata, issues] = await Promise.all([
getStatisticMetadata(this.hass, [id]),
validateStatistics(this.hass),
]);
const issue = issues[id];
if (metadata || issue) {
statistic = {
metadata,
issue,
};
}
}
const entity = await getExtendedEntityRegistryEntry(this.hass, id);
const device = entity?.device_id && this.hass.devices[entity.device_id];
const data = {
state: this.hass.states[id],
entity,
device,
statistic,
};
const json = JSON.stringify(data, null, 2);
await copyToClipboard(json);
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
});
}
private _entityPicked(ev) {
this._entityId = ev.detail.value;
}
static styles = [ static styles = [
haStyle, haStyle,
css` css`
ha-card {
margin-bottom: var(--ha-space-4);
}
.card-content {
padding: var(--ha-space-2);
}
.content { .content {
padding: 28px 20px 16px; padding: var(--ha-space-7) var(--ha-space-5) var(--ha-space-4);
display: block; display: block;
max-width: 600px; max-width: 600px;
margin: 0 auto; margin: 0 auto;

View File

@@ -148,7 +148,7 @@ class HaPanelDevEvent extends LitElement {
css` css`
.content { .content {
gap: var(--ha-space-4); gap: var(--ha-space-4);
padding: 16px; padding: var(--ha-space-4);
max-width: 1200px; max-width: 1200px;
margin: auto; margin: auto;
} }
@@ -169,7 +169,7 @@ class HaPanelDevEvent extends LitElement {
} }
ha-button { ha-button {
margin-top: 8px; margin-top: var(--ha-space-2);
} }
ha-textfield { ha-textfield {
@@ -178,7 +178,7 @@ class HaPanelDevEvent extends LitElement {
event-subscribe-card { event-subscribe-card {
display: block; display: block;
margin-top: 16px; margin-top: var(--ha-space-4);
direction: var(--direction); direction: var(--direction);
} }

View File

@@ -160,16 +160,16 @@ class EventSubscribeCard extends LitElement {
static styles = css` static styles = css`
ha-textfield { ha-textfield {
display: block; display: block;
margin-bottom: 16px; margin-bottom: var(--ha-space-4);
} }
.error-message { .error-message {
margin-top: 8px; margin-top: var(--ha-space-2);
} }
.event { .event {
border-top: 1px solid var(--divider-color); border-top: 1px solid var(--divider-color);
padding-top: 8px; padding-top: var(--ha-space-2);
padding-bottom: 8px; padding-bottom: var(--ha-space-2);
margin: 16px 0; margin: var(--ha-space-4) 0;
} }
.event:last-child { .event:last-child {
border-bottom: 0; border-bottom: 0;
@@ -179,7 +179,7 @@ class EventSubscribeCard extends LitElement {
font-family: var(--ha-font-family-code); font-family: var(--ha-font-family-code);
} }
ha-card { ha-card {
margin-bottom: 5px; margin-bottom: var(--ha-space-1);
} }
`; `;
} }

View File

@@ -176,12 +176,12 @@ class PanelDeveloperTools extends LitElement {
display: flex; display: flex;
align-items: center; align-items: center;
font-size: var(--ha-font-size-xl); font-size: var(--ha-font-size-xl);
padding: 8px 12px; padding: var(--ha-space-2) var(--ha-space-3);
font-weight: var(--ha-font-weight-normal); font-weight: var(--ha-font-weight-normal);
box-sizing: border-box; box-sizing: border-box;
} }
:host([narrow]) .toolbar { :host([narrow]) .toolbar {
padding: 4px; padding: var(--ha-space-1);
} }
.main-title { .main-title {
margin: var(--margin-title); margin: var(--margin-title);

View File

@@ -298,7 +298,7 @@ class HaPanelDevStateRenderer extends LitElement {
} }
.cell .padded { .cell .padded {
padding: 4px; padding: var(--ha-space-1);
} }
.entities .row .header:nth-child(1), .entities .row .header:nth-child(1),
@@ -328,11 +328,11 @@ class HaPanelDevStateRenderer extends LitElement {
.entities ha-svg-icon { .entities ha-svg-icon {
--mdc-icon-size: 20px; --mdc-icon-size: 20px;
padding: 4px; padding: var(--ha-space-1);
cursor: pointer; cursor: pointer;
flex-shrink: 0; flex-shrink: 0;
margin-right: 8px; margin-right: var(--ha-space-2);
margin-inline-end: 8px; margin-inline-end: var(--ha-space-2);
margin-inline-start: initial; margin-inline-start: initial;
} }

View File

@@ -144,7 +144,6 @@ class HaPanelDevState extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.value=${this._entityId} .value=${this._entityId}
@value-changed=${this._entityIdChanged} @value-changed=${this._entityIdChanged}
allow-custom-entity
show-entity-id show-entity-id
></ha-entity-picker> ></ha-entity-picker>
${this._entityId ${this._entityId
@@ -508,7 +507,7 @@ class HaPanelDevState extends LitElement {
-webkit-user-select: initial; -webkit-user-select: initial;
-moz-user-select: initial; -moz-user-select: initial;
display: block; display: block;
padding: 16px; padding: var(--ha-space-4);
} }
:host search-input { :host search-input {
@@ -526,7 +525,7 @@ class HaPanelDevState extends LitElement {
} }
.heading ha-formfield { .heading ha-formfield {
margin-right: 8px; margin-right: var(--ha-space-2);
--mdc-typography-body2-font-size: var(--ha-font-size-m); --mdc-typography-body2-font-size: var(--ha-font-size-m);
--mdc-typography-body2-font-weight: var(--ha-font-weight-medium); --mdc-typography-body2-font-weight: var(--ha-font-weight-medium);
} }
@@ -535,9 +534,9 @@ class HaPanelDevState extends LitElement {
display: block; display: block;
font-family: var(--ha-font-family-code); font-family: var(--ha-font-family-code);
color: var(--secondary-text-color); color: var(--secondary-text-color);
padding: 0 8px; padding: 0 var(--ha-space-2);
margin-bottom: 8px; margin-bottom: var(--ha-space-2);
margin-top: 4px; margin-top: var(--ha-space-1);
font-size: var(--ha-font-size-s); font-size: var(--ha-font-size-s);
--mdc-icon-size: 14px; --mdc-icon-size: 14px;
--mdc-icon-button-size: 24px; --mdc-icon-button-size: 24px;
@@ -558,15 +557,15 @@ class HaPanelDevState extends LitElement {
} }
.state-input { .state-input {
margin-top: 16px; margin-top: var(--ha-space-4);
} }
ha-expansion-panel { ha-expansion-panel {
margin: 0 8px 16px; margin: 0 var(--ha-space-2) var(--ha-space-4);
} }
ha-expansion-panel p { ha-expansion-panel p {
padding: 0 8px; padding: 0 var(--ha-space-2);
} }
.inputs { .inputs {
@@ -575,12 +574,12 @@ class HaPanelDevState extends LitElement {
} }
.info { .info {
padding: 0 16px; padding: 0 var(--ha-space-4);
} }
.button-row { .button-row {
display: flex; display: flex;
margin: 8px 0; margin: var(--ha-space-2) 0;
align-items: center; align-items: center;
gap: var(--ha-space-2); gap: var(--ha-space-2);
} }

View File

@@ -732,7 +732,7 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
height: 56px; height: 56px;
width: 100%; width: 100%;
justify-content: space-between; justify-content: space-between;
padding: 0 16px; padding: 0 var(--ha-space-4);
gap: var(--ha-space-4); gap: var(--ha-space-4);
box-sizing: border-box; box-sizing: border-box;
background: var(--primary-background-color); background: var(--primary-background-color);
@@ -751,7 +751,7 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--ha-space-4); gap: var(--ha-space-4);
padding: 0 16px; padding: 0 var(--ha-space-4);
overflow-x: scroll; overflow-x: scroll;
-ms-overflow-style: none; -ms-overflow-style: none;
scrollbar-width: none; scrollbar-width: none;
@@ -763,7 +763,7 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 8px 12px; padding: var(--ha-space-2) var(--ha-space-3);
box-sizing: border-box; box-sizing: border-box;
font-size: var(--ha-font-size-m); font-size: var(--ha-font-size-m);
--ha-assist-chip-container-color: var(--card-background-color); --ha-assist-chip-container-color: var(--card-background-color);
@@ -776,8 +776,8 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
} }
.selection-controls p { .selection-controls p {
margin-left: 8px; margin-left: var(--ha-space-2);
margin-inline-start: 8px; margin-inline-start: var(--ha-space-2);
margin-inline-end: initial; margin-inline-end: initial;
} }

View File

@@ -513,7 +513,7 @@ export class DialogStatisticsFixUnsupportedUnitMetadata extends LitElement {
.text-content, .text-content,
ha-selector-datetime, ha-selector-datetime,
ha-selector-number { ha-selector-number {
margin-bottom: 20px; margin-bottom: var(--ha-space-5);
} }
ha-list-item { ha-list-item {
margin: 0 -24px; margin: 0 -24px;
@@ -522,7 +522,7 @@ export class DialogStatisticsFixUnsupportedUnitMetadata extends LitElement {
.table-row { .table-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin-bottom: 20px; margin-bottom: var(--ha-space-5);
} }
.stat-list { .stat-list {
min-height: 360px; min-height: 360px;

View File

@@ -275,7 +275,7 @@ ${type === "object"
.content { .content {
gap: var(--ha-space-4); gap: var(--ha-space-4);
padding: 16px; padding: var(--ha-space-4);
} }
.content.horizontal { .content.horizontal {
@@ -289,7 +289,7 @@ ${type === "object"
} }
ha-card { ha-card {
margin-bottom: 16px; margin-bottom: var(--ha-space-4);
} }
.edit-pane { .edit-pane {
@@ -307,14 +307,14 @@ ${type === "object"
.render-spinner { .render-spinner {
position: absolute; position: absolute;
top: 8px; top: var(--ha-space-2);
right: 8px; right: var(--ha-space-2);
inset-inline-end: 8px; inset-inline-end: var(--ha-space-2);
inset-inline-start: initial; inset-inline-start: initial;
} }
ha-alert { ha-alert {
margin-bottom: 8px; margin-bottom: var(--ha-space-2);
display: block; display: block;
} }
@@ -325,7 +325,7 @@ ${type === "object"
clear: both; clear: both;
white-space: pre-wrap; white-space: pre-wrap;
background-color: var(--secondary-background-color); background-color: var(--secondary-background-color);
padding: 8px; padding: var(--ha-space-2);
margin-top: 0; margin-top: 0;
margin-bottom: 0; margin-bottom: 0;
direction: ltr; direction: ltr;

View File

@@ -251,19 +251,19 @@ export class DeveloperYamlConfig extends LitElement {
} }
.content { .content {
padding: 28px 20px 16px; padding: var(--ha-space-7) var(--ha-space-5) var(--ha-space-4);
max-width: 1040px; max-width: 1040px;
margin: 0 auto; margin: 0 auto;
} }
ha-card { ha-card {
margin-top: 24px; margin-top: var(--ha-space-6);
} }
.card-actions { .card-actions {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
padding: 4px; padding: var(--ha-space-1);
} }
`, `,
]; ];

View File

@@ -75,7 +75,6 @@ export class DialogEditHome
"ui.panel.home.editor.favorite_entities_helper" "ui.panel.home.editor.favorite_entities_helper"
)} )}
reorder reorder
allow-custom-entity
@value-changed=${this._favoriteEntitiesChanged} @value-changed=${this._favoriteEntitiesChanged}
></ha-entities-picker> ></ha-entities-picker>

View File

@@ -167,7 +167,6 @@ export class HuiEntityEditor extends LitElement {
.index=${index} .index=${index}
.entityFilter=${this.entityFilter} .entityFilter=${this.entityFilter}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
allow-custom-entity
></ha-entity-picker> ></ha-entity-picker>
</div> </div>
` `

View File

@@ -51,7 +51,6 @@ export class HuiGraphFooterEditor
return html` return html`
<div class="card-config"> <div class="card-config">
<ha-entity-picker <ha-entity-picker
allow-custom-entity
.label=${this.hass.localize( .label=${this.hass.localize(
"ui.panel.lovelace.editor.card.generic.entity" "ui.panel.lovelace.editor.card.generic.entity"
)} )}

View File

@@ -78,7 +78,6 @@ export class HuiHeadingBadgesEditor extends LitElement {
${isEntityBadge && entityBadge ${isEntityBadge && entityBadge
? html` ? html`
<ha-entity-picker <ha-entity-picker
allow-custom-entity
hide-clear-icon hide-clear-icon
.hass=${this.hass} .hass=${this.hass}
.value=${entityBadge.entity ?? ""} .value=${entityBadge.entity ?? ""}
@@ -131,7 +130,6 @@ export class HuiHeadingBadgesEditor extends LitElement {
@value-changed=${this._entityPicked} @value-changed=${this._entityPicked}
.value=${undefined} .value=${undefined}
@click=${preventDefault} @click=${preventDefault}
allow-custom-entity
add-button add-button
></ha-entity-picker> ></ha-entity-picker>
</div> </div>

View File

@@ -316,7 +316,6 @@ export class HuiStatisticsGraphCardEditor
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
></ha-form> ></ha-form>
<ha-statistics-picker <ha-statistics-picker
allow-custom-entity
.hass=${this.hass} .hass=${this.hass}
.placeholder=${this.hass!.localize( .placeholder=${this.hass!.localize(
"ui.panel.lovelace.editor.card.statistics-graph.pick_statistic" "ui.panel.lovelace.editor.card.statistics-graph.pick_statistic"

View File

@@ -80,7 +80,6 @@ export class HuiEntitiesCardRowEditor extends LitElement {
` `
: html` : html`
<ha-entity-picker <ha-entity-picker
allow-custom-entity
hide-clear-icon hide-clear-icon
.hass=${this.hass} .hass=${this.hass}
.value=${(entityConf as EntityConfig).entity} .value=${(entityConf as EntityConfig).entity}

View File

@@ -36,7 +36,6 @@ export class HuiHomeDashboardStrategyEditor
"ui.panel.lovelace.editor.strategy.home.add_favorite_entity" "ui.panel.lovelace.editor.strategy.home.add_favorite_entity"
)} )}
reorder reorder
allow-custom-entity
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
> >
</ha-entities-picker> </ha-entities-picker>

View File

@@ -141,30 +141,17 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
); );
const maxCommonControls = Math.max(8, favoriteEntities.length); const maxCommonControls = Math.max(8, favoriteEntities.length);
const commonControlsSectionBase = { const commonControlsSection = {
strategy: { strategy: {
type: "common-controls", type: "common-controls",
limit: maxCommonControls, limit: maxCommonControls,
include_entities: favoriteEntities, include_entities: favoriteEntities,
title: hass.localize("ui.panel.lovelace.strategy.home.favorites"),
hide_empty: true, hide_empty: true,
} satisfies CommonControlSectionStrategyConfig, } satisfies CommonControlSectionStrategyConfig,
column_span: maxColumns, column_span: maxColumns,
} as LovelaceStrategySectionConfig; } as LovelaceStrategySectionConfig;
const commonControlsSectionMobile = {
...commonControlsSectionBase,
strategy: {
...commonControlsSectionBase.strategy,
title: hass.localize("ui.panel.lovelace.strategy.home.commonly_used"),
},
visibility: [smallScreenCondition],
} as LovelaceStrategySectionConfig;
const commonControlsSectionDesktop = {
...commonControlsSectionBase,
visibility: [largeScreenCondition],
} as LovelaceStrategySectionConfig;
const allEntities = Object.keys(hass.states); const allEntities = Object.keys(hass.states);
const mediaPlayerFilter = HOME_SUMMARIES_FILTERS.media_players.map( const mediaPlayerFilter = HOME_SUMMARIES_FILTERS.media_players.map(
@@ -309,20 +296,8 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
const sections = ( const sections = (
[ [
{
type: "grid",
cards: [
// Heading to add some spacing on large screens
{
type: "heading",
heading_style: "subtitle",
visibility: [largeScreenCondition],
},
],
},
mobileSummarySection, mobileSummarySection,
commonControlsSectionMobile, commonControlsSection,
commonControlsSectionDesktop,
...floorsSections, ...floorsSections,
] satisfies (LovelaceSectionRawConfig | undefined)[] ] satisfies (LovelaceSectionRawConfig | undefined)[]
).filter(Boolean) as LovelaceSectionRawConfig[]; ).filter(Boolean) as LovelaceSectionRawConfig[];

View File

@@ -28,6 +28,9 @@ export const getMyRedirects = (): Redirects => ({
developer_assist: { developer_assist: {
redirect: "/developer-tools/assist", redirect: "/developer-tools/assist",
}, },
developer_debug: {
redirect: "/developer-tools/debug",
},
developer_states: { developer_states: {
redirect: "/developer-tools/state", redirect: "/developer-tools/state",
}, },

View File

@@ -672,17 +672,20 @@
"device_missing": "No related device" "device_missing": "No related device"
}, },
"add": "Add", "add": "Add",
"custom_name": "Custom name" "custom_name": "Custom name",
"no_match": "No entities found"
}, },
"entity-attribute-picker": { "entity-attribute-picker": {
"attribute": "Attribute", "attribute": "Attribute",
"show_attributes": "Show attributes" "show_attributes": "Show attributes"
}, },
"entity-state-picker": { "entity-state-picker": {
"state": "State" "state": "State",
"add_custom_state": "Add custom state"
}, },
"entity-state-content-picker": { "entity-state-content-picker": {
"add": "Add" "add": "Add",
"custom_state": "Custom state"
} }
}, },
"target-picker": { "target-picker": {
@@ -1308,7 +1311,9 @@
"combo-box": { "combo-box": {
"no_match": "No matching items found", "no_match": "No matching items found",
"no_items": "No items available", "no_items": "No items available",
"unknown_item": "Unknown item" "unknown_item": "Unknown item",
"search_or_custom": "Search | Add custom item",
"add_custom_item": "Add custom item"
}, },
"suggest_with_ai": { "suggest_with_ai": {
"label": "Suggest", "label": "Suggest",
@@ -1317,6 +1322,9 @@
"suggesting_3": "Enchanting…", "suggesting_3": "Enchanting…",
"done": "Done!", "done": "Done!",
"error": "Fail!" "error": "Fail!"
},
"navigation-picker": {
"add_custom_path": "Add custom path"
} }
}, },
"dialogs": { "dialogs": {
@@ -7177,7 +7185,7 @@
"automations": "Automations", "automations": "Automations",
"for_you": "For you", "for_you": "For you",
"home": "Home", "home": "Home",
"commonly_used": "Commonly used" "favorites": "Favorites"
}, },
"common_controls": { "common_controls": {
"not_loaded": "Usage Prediction integration is not loaded.", "not_loaded": "Usage Prediction integration is not loaded.",
@@ -9133,6 +9141,11 @@
"debug_connection": { "debug_connection": {
"title": "Debug connection", "title": "Debug connection",
"description": "Observe requests to the server and responses from the server in browser console." "description": "Observe requests to the server and responses from the server in browser console."
},
"entity_diagnostic": {
"title": "Entity diagnostic",
"description": "Select an entity to copy diagnostic details.",
"copy_to_clipboard": "[%key:ui::panel::config::automation::editor::copy_to_clipboard%]"
} }
}, },
"events": { "events": {

1537
yarn.lock

File diff suppressed because it is too large Load Diff