Compare commits

..

6 Commits

Author SHA1 Message Date
Paul Bottein
6745a97f86 Set energy selector position to fixed 2025-12-15 15:52:25 +01:00
Petar Petrov
a8286e31a0 Use dialog backdrop and define default 2025-12-11 12:47:10 +02:00
Petar Petrov
4fa7274fbe fix scrollbar 2025-12-10 12:24:03 +02:00
Petar Petrov
414c1315a4 move to hui-root via slot 2025-12-10 12:08:44 +02:00
Petar Petrov
04bfc350ea Blur view when date picker is open 2025-12-09 15:25:09 +02:00
Petar Petrov
75ec9404ce Sticky period selector for energy dashboard 2025-12-09 13:15:12 +02:00
503 changed files with 7400 additions and 8687 deletions

View File

@@ -154,7 +154,7 @@ try {
- **Use CSS custom properties**: Leverage the theme system - **Use CSS custom properties**: Leverage the theme system
- **Use spacing tokens**: Prefer `--ha-space-*` tokens over hardcoded values for consistent spacing - **Use spacing tokens**: Prefer `--ha-space-*` tokens over hardcoded values for consistent spacing
- Spacing scale: `--ha-space-1` (4px) through `--ha-space-20` (80px) in 4px increments - Spacing scale: `--ha-space-0` (0px) through `--ha-space-20` (80px) in 4px increments
- Defined in `src/resources/theme/core.globals.ts` - Defined in `src/resources/theme/core.globals.ts`
- Common values: `--ha-space-2` (8px), `--ha-space-4` (16px), `--ha-space-8` (32px) - Common values: `--ha-space-2` (8px), `--ha-space-4` (16px), `--ha-space-8` (32px)
- **Mobile-first responsive**: Design for mobile, enhance for desktop - **Mobile-first responsive**: Design for mobile, enhance for desktop

View File

@@ -37,7 +37,7 @@ jobs:
- name: Build resources - name: Build resources
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages
- name: Setup lint cache - name: Setup lint cache
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with: with:
path: | path: |
node_modules/.cache/prettier node_modules/.cache/prettier
@@ -89,7 +89,7 @@ jobs:
env: env:
IS_TEST: "true" IS_TEST: "true"
- name: Upload bundle stats - name: Upload bundle stats
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with: with:
name: frontend-bundle-stats name: frontend-bundle-stats
path: build/stats/*.json path: build/stats/*.json
@@ -113,7 +113,7 @@ jobs:
env: env:
IS_TEST: "true" IS_TEST: "true"
- name: Upload bundle stats - name: Upload bundle stats
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with: with:
name: supervisor-bundle-stats name: supervisor-bundle-stats
path: build/stats/*.json path: build/stats/*.json

View File

@@ -36,14 +36,14 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 uses: github/codeql-action/autobuild@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
# Command-line programs to run using the OS shell. # Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl # 📚 https://git.io/JvXDl
@@ -57,4 +57,4 @@ jobs:
# make release # make release
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7

View File

@@ -9,7 +9,7 @@ jobs:
lock: lock:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0 - uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1
with: with:
github-token: ${{ github.token }} github-token: ${{ github.token }}
process-only: "issues, prs" process-only: "issues, prs"

View File

@@ -57,14 +57,14 @@ jobs:
run: tar -czvf translations.tar.gz translations run: tar -czvf translations.tar.gz translations
- name: Upload build artifacts - name: Upload build artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with: with:
name: wheels name: wheels
path: dist/home_assistant_frontend*.whl path: dist/home_assistant_frontend*.whl
if-no-files-found: error if-no-files-found: error
- name: Upload translations - name: Upload translations
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with: with:
name: translations name: translations
path: translations.tar.gz path: translations.tar.gz

1
.gitignore vendored
View File

@@ -56,5 +56,4 @@ test/coverage/
# AI tooling # AI tooling
.claude .claude
.cursor

2
.nvmrc
View File

@@ -1 +1 @@
24.12.0 22.21.1

View File

@@ -1,7 +1,5 @@
compressionLevel: mixed compressionLevel: mixed
npmMinimalAgeGate: "3d"
defaultSemverRangePrefix: "" defaultSemverRangePrefix: ""
enableGlobalCache: false enableGlobalCache: false

View File

@@ -20,6 +20,8 @@ module.exports.ignorePackages = () => [];
// Files from NPM packages that we should replace with empty file // Files from NPM packages that we should replace with empty file
module.exports.emptyPackages = ({ isHassioBuild, isLandingPageBuild }) => module.exports.emptyPackages = ({ isHassioBuild, isLandingPageBuild }) =>
[ [
require.resolve("@vaadin/vaadin-material-styles/typography.js"),
require.resolve("@vaadin/vaadin-material-styles/font-icons.js"),
// Icons in supervisor conflict with icons in HA so we don't load. // Icons in supervisor conflict with icons in HA so we don't load.
(isHassioBuild || isLandingPageBuild) && (isHassioBuild || isLandingPageBuild) &&
require.resolve( require.resolve(

View File

@@ -168,16 +168,12 @@ const createRspackConfig = ({
); );
}, },
}), }),
bundle.emptyPackages({ isHassioBuild, isLandingPageBuild }).length new rspack.NormalModuleReplacementPlugin(
? new rspack.NormalModuleReplacementPlugin( new RegExp(
new RegExp( bundle.emptyPackages({ isHassioBuild, isLandingPageBuild }).join("|")
bundle ),
.emptyPackages({ isHassioBuild, isLandingPageBuild }) path.resolve(paths.root_dir, "src/util/empty.js")
.join("|") ),
),
path.resolve(paths.root_dir, "src/util/empty.js")
)
: false,
!isProdBuild && new LogStartCompilePlugin(), !isProdBuild && new LogStartCompilePlugin(),
isProdBuild && isProdBuild &&
new StatsWriterPlugin({ new StatsWriterPlugin({
@@ -221,42 +217,6 @@ const createRspackConfig = ({
"@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver.js", "@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver.js",
"@lit-labs/observers/resize-controller": "@lit-labs/observers/resize-controller":
"@lit-labs/observers/resize-controller.js", "@lit-labs/observers/resize-controller.js",
"@formatjs/intl-durationformat/should-polyfill$":
"@formatjs/intl-durationformat/should-polyfill.js",
"@formatjs/intl-durationformat/polyfill-force$":
"@formatjs/intl-durationformat/polyfill-force.js",
"@formatjs/intl-datetimeformat/should-polyfill":
"@formatjs/intl-datetimeformat/should-polyfill.js",
"@formatjs/intl-datetimeformat/polyfill-force":
"@formatjs/intl-datetimeformat/polyfill-force.js",
"@formatjs/intl-displaynames/should-polyfill":
"@formatjs/intl-displaynames/should-polyfill.js",
"@formatjs/intl-displaynames/polyfill-force":
"@formatjs/intl-displaynames/polyfill-force.js",
"@formatjs/intl-getcanonicallocales/should-polyfill":
"@formatjs/intl-getcanonicallocales/should-polyfill.js",
"@formatjs/intl-getcanonicallocales/polyfill-force":
"@formatjs/intl-getcanonicallocales/polyfill-force.js",
"@formatjs/intl-listformat/should-polyfill":
"@formatjs/intl-listformat/should-polyfill.js",
"@formatjs/intl-listformat/polyfill-force":
"@formatjs/intl-listformat/polyfill-force.js",
"@formatjs/intl-locale/should-polyfill":
"@formatjs/intl-locale/should-polyfill.js",
"@formatjs/intl-locale/polyfill-force":
"@formatjs/intl-locale/polyfill-force.js",
"@formatjs/intl-numberformat/should-polyfill":
"@formatjs/intl-numberformat/should-polyfill.js",
"@formatjs/intl-numberformat/polyfill-force":
"@formatjs/intl-numberformat/polyfill-force.js",
"@formatjs/intl-pluralrules/should-polyfill":
"@formatjs/intl-pluralrules/should-polyfill.js",
"@formatjs/intl-pluralrules/polyfill-force":
"@formatjs/intl-pluralrules/polyfill-force.js",
"@formatjs/intl-relativetimeformat/should-polyfill":
"@formatjs/intl-relativetimeformat/should-polyfill.js",
"@formatjs/intl-relativetimeformat/polyfill-force":
"@formatjs/intl-relativetimeformat/polyfill-force.js",
}, },
}, },
output: { output: {

View File

@@ -5,19 +5,17 @@ const castContext = framework.CastReceiverContext.getInstance();
const playerManager = castContext.getPlayerManager(); const playerManager = castContext.getPlayerManager();
playerManager.setMessageInterceptor( playerManager.setMessageInterceptor(
"LOAD" as framework.messages.MessageType.LOAD, 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 = "LIVE" as framework.messages.StreamType.LIVE; media.streamType = 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 =
"FMP4" as framework.messages.HlsVideoSegmentFormat.FMP4; framework.messages.HlsVideoSegmentFormat.FMP4;
} }
return loadRequestData; return loadRequestData;
} }

View File

@@ -1,9 +1,10 @@
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);
@@ -39,8 +40,7 @@ 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 = loadRequestData.media.streamType = framework.messages.StreamType.NONE;
"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,30 +89,31 @@ const showMediaPlayer = () => {
const options = new framework.CastReceiverOptions(); const options = new framework.CastReceiverOptions();
options.disableIdleTimeout = true; options.disableIdleTimeout = true;
options.customNamespaces = { options.customNamespaces = {
// type definition is wrong, should be "JSON" instead of "json" [CAST_NS]: framework.system.MessageType.JSON,
// https://developers.google.com/cast/docs/reference/web_receiver/cast.framework.system#.MessageType
[CAST_NS]: "JSON" as framework.system.MessageType.JSON,
}; };
castContext.addCustomMessageListener(CAST_NS, (ev) => { castContext.addCustomMessageListener(
// We received a show Lovelace command, stop media from playing, hide media player and show Lovelace controller CAST_NS,
if ( // @ts-ignore
playerManager.getPlayerState() !== (ev: ReceivedMessage<HassMessage>) => {
("IDLE" as framework.messages.PlayerState.IDLE) // We received a show Lovelace command, stop media from playing, hide media player and show Lovelace controller
) { if (
playerManager.stop(); playerManager.getPlayerState() !== framework.messages.PlayerState.IDLE
} else { ) {
showLovelaceController(); playerManager.stop();
} else {
showLovelaceController();
}
const msg = ev.data;
msg.senderId = ev.senderId;
lovelaceController.processIncomingMessage(msg);
} }
const msg = ev.data as HassMessage; );
msg.senderId = ev.senderId;
lovelaceController.processIncomingMessage(msg);
});
const playerManager = castContext.getPlayerManager(); const playerManager = castContext.getPlayerManager();
playerManager.setMessageInterceptor( playerManager.setMessageInterceptor(
"LOAD" as framework.messages.MessageType.LOAD, framework.messages.MessageType.LOAD,
(loadRequestData) => { (loadRequestData) => {
if ( if (
loadRequestData.media.contentId === loadRequestData.media.contentId ===
@@ -126,26 +127,24 @@ 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 = "LIVE" as framework.messages.StreamType.LIVE; media.streamType = framework.messages.StreamType.LIVE;
media.contentType = "application/vnd.apple.mpegurl"; media.contentType = "application/vnd.apple.mpegurl";
// type definition is wrong, should be "FMP4" instead of "fmp4" // @ts-ignore
// https://developers.google.com/cast/docs/reference/web_receiver/cast.framework.messages#.HlsVideoSegmentFormat
media.hlsVideoSegmentFormat = media.hlsVideoSegmentFormat =
"FMP4" as framework.messages.HlsVideoSegmentFormat.FMP4; framework.messages.HlsVideoSegmentFormat.FMP4;
} }
return loadRequestData; return loadRequestData;
} }
); );
playerManager.addEventListener( playerManager.addEventListener(
"MEDIA_STATUS" as framework.events.EventType.MEDIA_STATUS, framework.events.EventType.MEDIA_STATUS,
(event) => { (event) => {
if ( if (
event.mediaStatus?.playerState === event.mediaStatus?.playerState === framework.messages.PlayerState.IDLE &&
("IDLE" as framework.messages.PlayerState.IDLE) &&
event.mediaStatus?.idleReason && event.mediaStatus?.idleReason &&
event.mediaStatus?.idleReason !== event.mediaStatus?.idleReason !==
("INTERRUPTED" as framework.messages.IdleReason.INTERRUPTED) framework.messages.IdleReason.INTERRUPTED
) { ) {
// media finished or stopped, return to default Lovelace // media finished or stopped, return to default Lovelace
showLovelaceController(); showLovelaceController();

View File

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

View File

@@ -1,4 +1,4 @@
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry"; import type { DeviceRegistryEntry } from "../../../src/data/device_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass"; import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockDeviceRegistry = ( export const mockDeviceRegistry = (

View File

@@ -1,4 +1,4 @@
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry"; import type { EntityRegistryEntry } from "../../../src/data/entity_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass"; import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockEntityRegistry = ( export const mockEntityRegistry = (

View File

@@ -1,4 +1,4 @@
import type { LabelRegistryEntry } from "../../../src/data/label/label_registry"; import type { LabelRegistryEntry } from "../../../src/data/label_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass"; import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockLabelRegistry = ( export const mockLabelRegistry = (

View File

@@ -142,7 +142,7 @@ export class DemoAutomationDescribeAction extends LitElement {
<div class="action"> <div class="action">
<span> <span>
${this._action ${this._action
? describeAction(this.hass, [], this._action) ? describeAction(this.hass, [], [], {}, this._action)
: "<invalid YAML>"} : "<invalid YAML>"}
</span> </span>
<ha-yaml-editor <ha-yaml-editor
@@ -155,7 +155,7 @@ export class DemoAutomationDescribeAction extends LitElement {
${ACTIONS.map( ${ACTIONS.map(
(conf) => html` (conf) => html`
<div class="action"> <div class="action">
<span>${describeAction(this.hass, [], conf as any)}</span> <span>${describeAction(this.hass, [], [], {}, conf as any)}</span>
<pre>${dump(conf)}</pre> <pre>${dump(conf)}</pre>
</div> </div>
` `

View File

@@ -11,11 +11,11 @@ import { computeInitialHaFormData } from "../../../../src/components/ha-form/com
import "../../../../src/components/ha-form/ha-form"; import "../../../../src/components/ha-form/ha-form";
import type { HaFormSchema } from "../../../../src/components/ha-form/types"; import type { HaFormSchema } from "../../../../src/components/ha-form/types";
import type { AreaRegistryEntry } from "../../../../src/data/area_registry"; import type { AreaRegistryEntry } from "../../../../src/data/area_registry";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import { getEntity } from "../../../../src/fake_data/entity"; import { getEntity } from "../../../../src/fake_data/entity";
import { provideHass } from "../../../../src/fake_data/provide_hass"; import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types"; import type { HomeAssistant } from "../../../../src/types";
import "../../components/demo-black-white-row"; import "../../components/demo-black-white-row";
import type { DeviceRegistryEntry } from "../../../../src/data/device_registry";
const ENTITIES = [ const ENTITIES = [
getEntity("alarm_control_panel", "alarm", "disarmed", { getEntity("alarm_control_panel", "alarm", "disarmed", {

View File

@@ -13,9 +13,9 @@ import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row"; import "../../../../src/components/ha-settings-row";
import type { AreaRegistryEntry } from "../../../../src/data/area_registry"; import type { AreaRegistryEntry } from "../../../../src/data/area_registry";
import type { BlueprintInput } from "../../../../src/data/blueprint"; import type { BlueprintInput } from "../../../../src/data/blueprint";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry"; import type { DeviceRegistryEntry } from "../../../../src/data/device_registry";
import type { FloorRegistryEntry } from "../../../../src/data/floor_registry"; import type { FloorRegistryEntry } from "../../../../src/data/floor_registry";
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry"; import type { LabelRegistryEntry } from "../../../../src/data/label_registry";
import { showDialog } from "../../../../src/dialogs/make-dialog-manager"; import { showDialog } from "../../../../src/dialogs/make-dialog-manager";
import { getEntity } from "../../../../src/fake_data/entity"; import { getEntity } from "../../../../src/fake_data/entity";
import { provideHass } from "../../../../src/fake_data/provide_hass"; import { provideHass } from "../../../../src/fake_data/provide_hass";
@@ -40,9 +40,6 @@ const ENTITIES = [
getEntity("switch", "coffee", "off", { getEntity("switch", "coffee", "off", {
friendly_name: "Coffee", friendly_name: "Coffee",
}), }),
getEntity("number", "number", 5, {
friendly_name: "Number",
}),
]; ];
const DEVICES: DeviceRegistryEntry[] = [ const DEVICES: DeviceRegistryEntry[] = [
@@ -380,33 +377,6 @@ const SCHEMAS: {
name: "Constant", name: "Constant",
selector: { constant: { value: true, label: "Yes!" } }, selector: { constant: { value: true, label: "Yes!" } },
}, },
choose: {
name: "Choose",
selector: {
choose: {
choices: {
number: {
selector: {
number: {
min: 0,
max: 100,
step: 0.1,
},
},
},
entity: {
selector: {
entity: {
filter: {
domain: "number",
},
},
},
},
},
},
},
},
}, },
}, },
{ {

View File

@@ -6,8 +6,8 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import type { IntegrationManifest } from "../../../../src/data/integration"; import type { IntegrationManifest } from "../../../../src/data/integration";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry"; import type { DeviceRegistryEntry } from "../../../../src/data/device_registry";
import type { EntityRegistryEntry } from "../../../../src/data/entity/entity_registry"; import type { EntityRegistryEntry } from "../../../../src/data/entity_registry";
import { provideHass } from "../../../../src/fake_data/provide_hass"; import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../../../src/panels/config/integrations/ha-config-flow-card"; import "../../../../src/panels/config/integrations/ha-config-flow-card";
import type { import type {

View File

@@ -29,30 +29,30 @@
"@babel/runtime": "7.28.4", "@babel/runtime": "7.28.4",
"@braintree/sanitize-url": "7.1.1", "@braintree/sanitize-url": "7.1.1",
"@codemirror/autocomplete": "6.20.0", "@codemirror/autocomplete": "6.20.0",
"@codemirror/commands": "6.10.1", "@codemirror/commands": "6.10.0",
"@codemirror/language": "6.11.3", "@codemirror/language": "6.11.3",
"@codemirror/legacy-modes": "6.5.2", "@codemirror/legacy-modes": "6.5.2",
"@codemirror/search": "6.5.11", "@codemirror/search": "6.5.11",
"@codemirror/state": "6.5.2", "@codemirror/state": "6.5.2",
"@codemirror/view": "6.39.4", "@codemirror/view": "6.38.8",
"@date-fns/tz": "1.4.1", "@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17", "@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.0.2", "@formatjs/intl-datetimeformat": "6.18.2",
"@formatjs/intl-displaynames": "7.0.2", "@formatjs/intl-displaynames": "6.8.13",
"@formatjs/intl-durationformat": "0.8.2", "@formatjs/intl-durationformat": "0.7.6",
"@formatjs/intl-getcanonicallocales": "3.0.2", "@formatjs/intl-getcanonicallocales": "2.5.6",
"@formatjs/intl-listformat": "8.0.2", "@formatjs/intl-listformat": "7.7.13",
"@formatjs/intl-locale": "5.0.2", "@formatjs/intl-locale": "4.2.13",
"@formatjs/intl-numberformat": "9.0.3", "@formatjs/intl-numberformat": "8.15.6",
"@formatjs/intl-pluralrules": "6.0.2", "@formatjs/intl-pluralrules": "5.4.6",
"@formatjs/intl-relativetimeformat": "12.0.3", "@formatjs/intl-relativetimeformat": "11.4.13",
"@fullcalendar/core": "6.1.19", "@fullcalendar/core": "6.1.19",
"@fullcalendar/daygrid": "6.1.19", "@fullcalendar/daygrid": "6.1.19",
"@fullcalendar/interaction": "6.1.19", "@fullcalendar/interaction": "6.1.19",
"@fullcalendar/list": "6.1.19", "@fullcalendar/list": "6.1.19",
"@fullcalendar/luxon3": "6.1.19", "@fullcalendar/luxon3": "6.1.19",
"@fullcalendar/timegrid": "6.1.19", "@fullcalendar/timegrid": "6.1.19",
"@home-assistant/webawesome": "3.0.0-ha.2", "@home-assistant/webawesome": "3.0.0-ha.1",
"@lezer/highlight": "1.2.3", "@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.0.9", "@lit-labs/motion": "1.0.9",
"@lit-labs/observers": "2.0.6", "@lit-labs/observers": "2.0.6",
@@ -89,6 +89,8 @@
"@thomasloven/round-slider": "0.6.0", "@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "3.9.1", "@tsparticles/engine": "3.9.1",
"@tsparticles/preset-links": "3.2.0", "@tsparticles/preset-links": "3.2.0",
"@vaadin/combo-box": "24.9.6",
"@vaadin/vaadin-themable-mixin": "24.9.6",
"@vibrant/color": "4.0.0", "@vibrant/color": "4.0.0",
"@vue/web-component-wrapper": "1.3.0", "@vue/web-component-wrapper": "1.3.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10", "@webcomponents/scoped-custom-element-registry": "0.0.10",
@@ -112,7 +114,7 @@
"hls.js": "1.6.15", "hls.js": "1.6.15",
"home-assistant-js-websocket": "9.6.0", "home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.2", "idb-keyval": "6.2.2",
"intl-messageformat": "11.0.2", "intl-messageformat": "10.7.18",
"js-yaml": "4.1.1", "js-yaml": "4.1.1",
"leaflet": "1.9.4", "leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch", "leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
@@ -133,7 +135,7 @@
"stacktrace-js": "2.0.2", "stacktrace-js": "2.0.2",
"superstruct": "2.0.2", "superstruct": "2.0.2",
"tinykeys": "3.0.0", "tinykeys": "3.0.0",
"ua-parser-js": "2.0.7", "ua-parser-js": "2.0.6",
"vue": "2.7.16", "vue": "2.7.16",
"vue2-daterange-picker": "0.6.8", "vue2-daterange-picker": "0.6.8",
"weekstart": "2.0.0", "weekstart": "2.0.0",
@@ -151,15 +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",
"@lokalise/node-api": "15.5.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.16", "@rsdoctor/rspack-plugin": "1.3.12",
"@rspack/core": "1.6.8", "@rspack/core": "1.6.6",
"@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.25", "@types/chromecast-caf-receiver": "6.0.22",
"@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",
@@ -176,12 +178,12 @@
"@types/tar": "6.1.13", "@types/tar": "6.1.13",
"@types/ua-parser-js": "0.7.39", "@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29", "@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.0.16", "@vitest/coverage-v8": "4.0.15",
"babel-loader": "10.0.0", "babel-loader": "10.0.0",
"babel-plugin-template-html-minifier": "4.1.0", "babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3", "browserslist-useragent-regexp": "4.1.3",
"del": "8.0.1", "del": "8.0.1",
"eslint": "9.39.2", "eslint": "9.39.1",
"eslint-config-airbnb-base": "15.0.0", "eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.8", "eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.10", "eslint-import-resolver-webpack": "0.13.10",
@@ -191,7 +193,7 @@
"eslint-plugin-unused-imports": "4.3.0", "eslint-plugin-unused-imports": "4.3.0",
"eslint-plugin-wc": "3.0.2", "eslint-plugin-wc": "3.0.2",
"fancy-log": "2.0.0", "fancy-log": "2.0.0",
"fs-extra": "11.3.3", "fs-extra": "11.3.2",
"glob": "13.0.0", "glob": "13.0.0",
"gulp": "5.0.1", "gulp": "5.0.1",
"gulp-brotli": "3.0.0", "gulp-brotli": "3.0.0",
@@ -199,7 +201,7 @@
"gulp-rename": "2.1.0", "gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0", "html-minifier-terser": "7.2.0",
"husky": "9.1.7", "husky": "9.1.7",
"jsdom": "27.3.0", "jsdom": "27.2.0",
"jszip": "3.10.1", "jszip": "3.10.1",
"lint-staged": "16.2.7", "lint-staged": "16.2.7",
"lit-analyzer": "2.0.3", "lit-analyzer": "2.0.3",
@@ -212,12 +214,12 @@
"serve": "14.2.5", "serve": "14.2.5",
"sinon": "21.0.0", "sinon": "21.0.0",
"tar": "7.5.2", "tar": "7.5.2",
"terser-webpack-plugin": "5.3.16", "terser-webpack-plugin": "5.3.15",
"ts-lit-plugin": "2.0.2", "ts-lit-plugin": "2.0.2",
"typescript": "5.9.3", "typescript": "5.9.3",
"typescript-eslint": "8.50.0", "typescript-eslint": "8.48.1",
"vite-tsconfig-paths": "6.0.3", "vite-tsconfig-paths": "5.1.4",
"vitest": "4.0.16", "vitest": "4.0.15",
"webpack-stats-plugin": "1.1.3", "webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0", "webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch" "workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
@@ -236,6 +238,6 @@
}, },
"packageManager": "yarn@4.12.0", "packageManager": "yarn@4.12.0",
"volta": { "volta": {
"node": "24.12.0" "node": "22.21.1"
} }
} }

View File

@@ -3,8 +3,8 @@ import {
DOMAIN_ATTRIBUTES_FORMATERS, DOMAIN_ATTRIBUTES_FORMATERS,
DOMAIN_ATTRIBUTES_UNITS, DOMAIN_ATTRIBUTES_UNITS,
TEMPERATURE_ATTRIBUTES, TEMPERATURE_ATTRIBUTES,
} from "../../data/entity/entity_attributes"; } from "../../data/entity_attributes";
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry"; import type { EntityRegistryDisplayEntry } from "../../data/entity_registry";
import type { FrontendLocaleData } from "../../data/translation"; import type { FrontendLocaleData } from "../../data/translation";
import type { WeatherEntity } from "../../data/weather"; import type { WeatherEntity } from "../../data/weather";
import { getWeatherUnit } from "../../data/weather"; import { getWeatherUnit } from "../../data/weather";

View File

@@ -1,12 +1,12 @@
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import type { DeviceRegistryEntry } from "../../data/device/device_registry"; import type { DeviceRegistryEntry } from "../../data/device_registry";
import type { import type {
EntityRegistryDisplayEntry, EntityRegistryDisplayEntry,
EntityRegistryEntry, EntityRegistryEntry,
} from "../../data/entity/entity_registry"; } from "../../data/entity_registry";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import { getDuplicates } from "../string/get_duplicates";
import { computeStateName } from "./compute_state_name"; import { computeStateName } from "./compute_state_name";
import { getDuplicates } from "../string/get_duplicates";
export const computeDeviceName = ( export const computeDeviceName = (
device: DeviceRegistryEntry device: DeviceRegistryEntry

View File

@@ -2,7 +2,7 @@ import type { HassEntity } from "home-assistant-js-websocket";
import type { import type {
EntityRegistryDisplayEntry, EntityRegistryDisplayEntry,
EntityRegistryEntry, EntityRegistryEntry,
} from "../../data/entity/entity_registry"; } from "../../data/entity_registry";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import { computeDeviceName } from "./compute_device_name"; import { computeDeviceName } from "./compute_device_name";
import { computeStateName } from "./compute_state_name"; import { computeStateName } from "./compute_state_name";

View File

@@ -1,12 +1,12 @@
import type { HassConfig, HassEntity } from "home-assistant-js-websocket"; import type { HassConfig, HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity"; import { UNAVAILABLE, UNKNOWN } from "../../data/entity";
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry"; import type { EntityRegistryDisplayEntry } from "../../data/entity_registry";
import type { FrontendLocaleData } from "../../data/translation"; import type { FrontendLocaleData } from "../../data/translation";
import { TimeZone } from "../../data/translation"; import { TimeZone } from "../../data/translation";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import { DURATION_UNITS, formatDuration } from "../datetime/format_duration";
import { formatDate } from "../datetime/format_date"; import { formatDate } from "../datetime/format_date";
import { formatDateTime } from "../datetime/format_date_time"; import { formatDateTime } from "../datetime/format_date_time";
import { DURATION_UNITS, formatDuration } from "../datetime/format_duration";
import { formatTime } from "../datetime/format_time"; import { formatTime } from "../datetime/format_time";
import { import {
formatNumber, formatNumber,

View File

@@ -1,5 +1,5 @@
import type { AreaRegistryEntry } from "../../../data/area_registry"; import type { AreaRegistryEntry } from "../../../data/area_registry";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry"; import type { DeviceRegistryEntry } from "../../../data/device_registry";
import type { FloorRegistryEntry } from "../../../data/floor_registry"; import type { FloorRegistryEntry } from "../../../data/floor_registry";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";

View File

@@ -1,11 +1,11 @@
import type { HassEntity } from "home-assistant-js-websocket"; import type { HassEntity } from "home-assistant-js-websocket";
import type { AreaRegistryEntry } from "../../../data/area_registry"; import type { AreaRegistryEntry } from "../../../data/area_registry";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry"; import type { DeviceRegistryEntry } from "../../../data/device_registry";
import type { import type {
EntityRegistryDisplayEntry, EntityRegistryDisplayEntry,
EntityRegistryEntry, EntityRegistryEntry,
ExtEntityRegistryEntry, ExtEntityRegistryEntry,
} from "../../../data/entity/entity_registry"; } from "../../../data/entity_registry";
import type { FloorRegistryEntry } from "../../../data/floor_registry"; import type { FloorRegistryEntry } from "../../../data/floor_registry";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";

View File

@@ -1,14 +1,14 @@
import type { ConfigEntry } from "../../data/config_entries"; import type { HomeAssistant } from "../../types";
import { deleteConfigEntry } from "../../data/config_entries";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import { removeEntityRegistryEntry } from "../../data/entity/entity_registry";
import { HELPERS_CRUD } from "../../data/helpers_crud";
import type { IntegrationManifest } from "../../data/integration"; import type { IntegrationManifest } from "../../data/integration";
import { computeDomain } from "./compute_domain";
import { HELPERS_CRUD } from "../../data/helpers_crud";
import type { Helper } from "../../panels/config/helpers/const"; import type { Helper } from "../../panels/config/helpers/const";
import { isHelperDomain } from "../../panels/config/helpers/const"; import { isHelperDomain } from "../../panels/config/helpers/const";
import type { HomeAssistant } from "../../types";
import { isComponentLoaded } from "../config/is_component_loaded"; import { isComponentLoaded } from "../config/is_component_loaded";
import { computeDomain } from "./compute_domain"; import type { EntityRegistryEntry } from "../../data/entity_registry";
import { removeEntityRegistryEntry } from "../../data/entity_registry";
import type { ConfigEntry } from "../../data/config_entries";
import { deleteConfigEntry } from "../../data/config_entries";
export const isDeletableEntity = ( export const isDeletableEntity = (
hass: HomeAssistant, hass: HomeAssistant,

View File

@@ -1,9 +1,9 @@
import type { HassEntity } from "home-assistant-js-websocket"; import type { HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE_STATES } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
import { stringCompare } from "../string/compare";
import { computeDomain } from "./compute_domain";
import { computeStateDomain } from "./compute_state_domain"; import { computeStateDomain } from "./compute_state_domain";
import { UNAVAILABLE_STATES } from "../../data/entity";
import type { HomeAssistant } from "../../types";
import { computeDomain } from "./compute_domain";
import { stringCompare } from "../string/compare";
export const FIXED_DOMAIN_STATES = { export const FIXED_DOMAIN_STATES = {
alarm_control_panel: [ alarm_control_panel: [

View File

@@ -1,7 +1,7 @@
import type { HassEntity } from "home-assistant-js-websocket"; import type { HassEntity } from "home-assistant-js-websocket";
import { isUnavailableState, UNAVAILABLE } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
import { computeStateDomain } from "./compute_state_domain"; import { computeStateDomain } from "./compute_state_domain";
import { isUnavailableState, UNAVAILABLE } from "../../data/entity";
import type { HomeAssistant } from "../../types";
export const computeGroupEntitiesState = (states: HassEntity[]): string => { export const computeGroupEntitiesState = (states: HassEntity[]): string => {
if (!states.length) { if (!states.length) {

View File

@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket"; import type { HassEntity } from "home-assistant-js-websocket";
import { isUnavailableState, OFF, UNAVAILABLE } from "../../data/entity/entity"; import { isUnavailableState, OFF, UNAVAILABLE } from "../../data/entity";
import { computeDomain } from "./compute_domain"; import { computeDomain } from "./compute_domain";
export function stateActive(stateObj: HassEntity, state?: string): boolean { export function stateActive(stateObj: HassEntity, state?: string): boolean {

View File

@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket"; import type { HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE } from "../../data/entity/entity"; import { UNAVAILABLE } from "../../data/entity";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import { DOMAINS_WITH_CARD } from "../const"; import { DOMAINS_WITH_CARD } from "../const";
import { canToggleState } from "./can_toggle_state"; import { canToggleState } from "./can_toggle_state";

View File

@@ -1,6 +1,6 @@
/** Return a color representing a state. */ /** Return a color representing a state. */
import type { HassEntity } from "home-assistant-js-websocket"; import type { HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE } from "../../data/entity/entity"; import { UNAVAILABLE } from "../../data/entity";
import type { GroupEntity } from "../../data/group"; import type { GroupEntity } from "../../data/group";
import { computeGroupDomain } from "../../data/group"; import { computeGroupDomain } from "../../data/group";
import { computeCssVariable } from "../../resources/css-variables"; import { computeCssVariable } from "../../resources/css-variables";

View File

@@ -17,36 +17,25 @@ export interface NavigateOptions {
// max time to wait for dialogs to close before navigating // max time to wait for dialogs to close before navigating
const DIALOG_WAIT_TIMEOUT = 500; const DIALOG_WAIT_TIMEOUT = 500;
/** export const navigate = async (
* Ensures all dialogs are closed before navigation. path: string,
* Returns true if navigation can proceed, false if a dialog refused to close. options?: NavigateOptions,
*/ timestamp = Date.now()
const ensureDialogsClosed = async (timestamp: number): Promise<boolean> => { ) => {
const { history } = mainWindow; const { history } = mainWindow;
if (history.state?.dialog && Date.now() - timestamp < DIALOG_WAIT_TIMEOUT) {
if (!history.state?.dialog || Date.now() - timestamp >= DIALOG_WAIT_TIMEOUT) { const closed = await closeAllDialogs();
return true; if (!closed) {
} // eslint-disable-next-line no-console
console.warn("Navigation blocked, because dialog refused to close");
const closed = await closeAllDialogs(); return false;
if (!closed) { }
// eslint-disable-next-line no-console return new Promise<boolean>((resolve) => {
console.warn("Navigation blocked, because dialog refused to close"); // need to wait for history state to be updated in case a dialog was closed
return false; setTimeout(() => {
} navigate(path, options, timestamp).then(resolve);
});
// wait for history state to be updated after dialog closed });
await new Promise<void>((resolve) => {
setTimeout(resolve);
});
return ensureDialogsClosed(timestamp);
};
export const navigate = async (path: string, options?: NavigateOptions) => {
const canProceed = await ensureDialogsClosed(Date.now());
if (!canProceed) {
return false;
} }
const replace = options?.replace || false; const replace = options?.replace || false;
@@ -79,14 +68,10 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
* Navigate back in history, with fallback to a default path if no history exists. * Navigate back in history, with fallback to a default path if no history exists.
* This prevents a user from getting stuck when they navigate directly to a page with no history. * This prevents a user from getting stuck when they navigate directly to a page with no history.
*/ */
export const goBack = async (fallbackPath?: string): Promise<void> => { export const goBack = (fallbackPath?: string) => {
const canProceed = await ensureDialogsClosed(Date.now()); const { history } = mainWindow;
if (!canProceed) {
return;
}
// Check if we have history to go back to // Check if we have history to go back to
const { history } = mainWindow;
if (history.length > 1) { if (history.length > 1) {
history.back(); history.back();
return; return;

View File

@@ -2,7 +2,7 @@ import type {
HassEntity, HassEntity,
HassEntityAttributeBase, HassEntityAttributeBase,
} from "home-assistant-js-websocket"; } from "home-assistant-js-websocket";
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry"; import type { EntityRegistryDisplayEntry } from "../../data/entity_registry";
import type { FrontendLocaleData } from "../../data/translation"; import type { FrontendLocaleData } from "../../data/translation";
import { NumberFormat } from "../../data/translation"; import { NumberFormat } from "../../data/translation";
import { round } from "./round"; import { round } from "./round";

View File

@@ -1,45 +1,30 @@
/** /**
* Executes a synchronous callback within a View Transition if supported, otherwise runs it directly. * Executes a callback within a View Transition if supported, otherwise runs it directly.
* *
* @param callback - Synchronous function to execute. The callback will be passed a boolean indicating whether the view transition is available. * @param callback - Function to execute. Can be synchronous or return a Promise. The callback will be passed a boolean indicating whether the view transition is available.
* @returns Promise that resolves when the transition completes (or immediately if not supported) * @returns Promise that resolves when the transition completes (or immediately if not supported)
* *
* @example * @example
* ```typescript * ```typescript
* // Synchronous callback
* withViewTransition(() => { * withViewTransition(() => {
* this.large = !this.large; * this.large = !this.large;
* }); * });
*
* // Async callback
* await withViewTransition(async () => {
* await this.updateData();
* });
* ``` * ```
*/ */
export const withViewTransition = ( export const withViewTransition = (
callback: (viewTransitionAvailable: boolean) => void callback: (viewTransitionAvailable: boolean) => void | Promise<void>
): Promise<void> => { ): Promise<void> => {
if (!document.startViewTransition) { if (document.startViewTransition) {
callback(false); return document.startViewTransition(() => callback(true)).finished;
return Promise.resolve();
} }
let callbackInvoked = false; // Fallback: Execute callback directly without transition
const result = callback(false);
try { return result instanceof Promise ? result : Promise.resolve();
// View Transitions require DOM updates to happen synchronously within
// the callback. Execute the callback immediately (synchronously).
const transition = document.startViewTransition(() => {
callbackInvoked = true;
callback(true);
});
return transition.finished;
} catch (err) {
// eslint-disable-next-line no-console
console.warn(
"View transition failed, falling back to direct execution.",
err
);
// Make sure the callback is invoked exactly once.
if (!callbackInvoked) {
callback(false);
return Promise.resolve();
}
return Promise.reject(err);
}
}; };

View File

@@ -1,207 +0,0 @@
import type { EChartsType } from "echarts/core";
import type { SunburstSeriesOption } from "echarts/types/dist/echarts";
import type { CallbackDataParams } from "echarts/types/src/util/types";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { getGraphColorByIndex } from "../../common/color/colors";
import { filterXSS } from "../../common/util/xss";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant } from "../../types";
import "./ha-chart-base";
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports
let SunburstChart: typeof import("echarts/lib/chart/sunburst/install");
export interface SunburstNode {
id: string;
name?: string;
value: number;
itemStyle?: {
color?: string;
};
children?: SunburstNode[];
}
@customElement("ha-sunburst-chart")
export class HaSunburstChart extends LitElement {
public hass!: HomeAssistant;
@property({ attribute: false }) public data?: SunburstNode;
@property({ type: String, attribute: false }) public valueFormatter?: (
value: number
) => string;
public chart?: EChartsType;
constructor() {
super();
if (!SunburstChart) {
import("echarts/lib/chart/sunburst/install").then((module) => {
SunburstChart = module;
this.requestUpdate();
});
}
}
render() {
if (!SunburstChart || !this.data) {
return nothing;
}
const options = {
tooltip: {
trigger: "item",
formatter: this._renderTooltip,
appendTo: document.body,
},
} as ECOption;
return html`<ha-chart-base
.data=${this._createData(this.data)}
.options=${options}
height="100%"
.extraComponents=${[SunburstChart]}
></ha-chart-base>`;
}
private _renderTooltip = (params: CallbackDataParams) => {
const data = params.data as { name: string; value: number };
const value = this.valueFormatter
? this.valueFormatter(data.value)
: data.value;
return `${params.marker} ${filterXSS(data.name)}<br>${value}`;
};
private _createData = memoizeOne(
(data: SunburstNode): SunburstSeriesOption => {
const computedStyles = getComputedStyle(this);
// Transform to echarts format (uses 'name' instead of 'id')
const transformNode = (
node: SunburstNode,
index: number,
depth: number,
parentColor?: string
) => {
const result = {
...node,
name: node.name || node.id,
};
if (depth > 0 && !node.itemStyle?.color) {
// Don't assign color to root node
result.itemStyle = {
color: parentColor ?? getGraphColorByIndex(index, computedStyles),
};
}
if (node.children && node.children.length > 0) {
result.children = node.children.map((child, i) =>
transformNode(child, i, depth + 1, result.itemStyle?.color)
);
}
return result;
};
const transformedData = transformNode(data, 0, 0);
return {
type: "sunburst",
data: transformedData.children || [transformedData],
radius: [0, "90%"],
sort: undefined, // Keep original order
label: {
show: false,
align: "center",
rotate: "radial",
minAngle: 15,
hideOverlap: true,
},
emphasis: {
focus: "ancestor",
label: {
show: false,
},
},
itemStyle: {
borderRadius: 2,
},
levels: this._generateLevels(this._getMaxDepth(data)),
} as SunburstSeriesOption;
}
);
private _getMaxDepth(node: SunburstNode, currentDepth = 0): number {
if (!node.children || node.children.length === 0) {
return currentDepth;
}
return Math.max(
...node.children.map((child) =>
this._getMaxDepth(child, currentDepth + 1)
)
);
}
private _generateLevels(depth: number): SunburstSeriesOption["levels"] {
const levels: SunburstSeriesOption["levels"] = [];
// Root level (center) - transparent, small fixed size
const rootRadius = 15;
const outerRadius = 95;
const availableRadius = outerRadius - rootRadius;
levels.push({
r0: "0%",
r: `${rootRadius}%`,
itemStyle: {
color: "transparent",
},
});
if (depth === 0) {
return levels;
}
// Distribute remaining radius among data levels using weighted distribution
// First level gets most space, each subsequent level gets progressively smaller
const weights = Array.from({ length: depth }, (_, i) => depth - i);
const totalWeight = weights.reduce((sum, w) => sum + w, 0);
let currentRadius = rootRadius;
for (let i = 0; i < depth; i++) {
const levelRadius = (weights[i] / totalWeight) * availableRadius;
const r0 = currentRadius;
const r = currentRadius + levelRadius;
currentRadius = r;
levels.push({
r0: `${r0}%`,
r: `${r}%`,
// Show labels only on first level
...(i === 0 ? { label: { show: true } } : {}),
});
}
return levels;
}
static styles = css`
:host {
display: block;
flex: 1;
}
ha-chart-base {
width: 100%;
height: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-sunburst-chart": HaSunburstChart;
}
}

View File

@@ -184,11 +184,17 @@ export class StatisticsChart extends LitElement {
} }
private _datasetHidden(ev: CustomEvent) { private _datasetHidden(ev: CustomEvent) {
if (!this._legendData) {
return;
}
this._hiddenStats.add(ev.detail.id); this._hiddenStats.add(ev.detail.id);
this.requestUpdate("_hiddenStats"); this.requestUpdate("_hiddenStats");
} }
private _datasetUnhidden(ev: CustomEvent) { private _datasetUnhidden(ev: CustomEvent) {
if (!this._legendData) {
return;
}
this._hiddenStats.delete(ev.detail.id); this._hiddenStats.delete(ev.detail.id);
this.requestUpdate("_hiddenStats"); this.requestUpdate("_hiddenStats");
} }
@@ -515,9 +521,7 @@ export class StatisticsChart extends LitElement {
`ui.components.statistics_charts.statistic_types.${type}` `ui.components.statistics_charts.statistic_types.${type}`
), ),
symbol: "none", symbol: "none",
// minmax sampling operates independently per series, breaking stacking alignment sampling: "minmax",
// https://github.com/apache/echarts/issues/11879
sampling: band && drawBands ? "lttb" : "minmax",
animationDurationUpdate: 0, animationDurationUpdate: 0,
lineStyle: { lineStyle: {
width: 1.5, width: 1.5,
@@ -535,17 +539,10 @@ export class StatisticsChart extends LitElement {
if (band && this.chartType === "line") { if (band && this.chartType === "line") {
series.stack = `band-${statistic_id}`; series.stack = `band-${statistic_id}`;
series.stackStrategy = "all"; series.stackStrategy = "all";
if (this._hiddenStats.has(`${statistic_id}-${bandBottom}`)) { if (drawBands && type === bandTop) {
// changing the stackOrder forces echarts to render the stacked series that are not hidden #28472 (series as LineSeriesOption).areaStyle = {
series.stackOrder = "seriesDesc"; color: color + "3F",
(series as LineSeriesOption).areaStyle = undefined; };
} else {
series.stackOrder = "seriesAsc";
if (drawBands && type === bandTop) {
(series as LineSeriesOption).areaStyle = {
color: color + "3F",
};
}
} }
} }
if (!this.hideLegend) { if (!this.hideLegend) {
@@ -589,8 +586,7 @@ export class StatisticsChart extends LitElement {
} else if ( } else if (
type === bandTop && type === bandTop &&
this.chartType === "line" && this.chartType === "line" &&
drawBands && drawBands
!this._hiddenStats.has(`${statistic_id}-${bandBottom}`)
) { ) {
const top = stat[bandTop] || 0; const top = stat[bandTop] || 0;
val.push(Math.abs(top - (stat[bandBottom] || 0))); val.push(Math.abs(top - (stat[bandBottom] || 0)));

View File

@@ -3,11 +3,11 @@ import { getGraphColorByIndex } from "../../common/color/colors";
import { hex2rgb, lab2hex, rgb2lab } from "../../common/color/convert-color"; import { hex2rgb, lab2hex, rgb2lab } from "../../common/color/convert-color";
import { labBrighten } from "../../common/color/lab"; import { labBrighten } from "../../common/color/lab";
import { computeDomain } from "../../common/entity/compute_domain"; import { computeDomain } from "../../common/entity/compute_domain";
import { stateColorProperties } from "../../common/entity/state_color";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity";
import { computeCssValue } from "../../resources/css-variables";
import { computeStateDomain } from "../../common/entity/compute_state_domain"; import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { FIXED_DOMAIN_STATES } from "../../common/entity/get_states"; import { FIXED_DOMAIN_STATES } from "../../common/entity/get_states";
import { stateColorProperties } from "../../common/entity/state_color";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import { computeCssValue } from "../../resources/css-variables";
const DOMAIN_STATE_SHADES: Record<string, Record<string, number>> = { const DOMAIN_STATE_SHADES: Record<string, Record<string, number>> = {
media_player: { media_player: {

View File

@@ -6,7 +6,7 @@ import { computeCssColor } 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 { stopPropagation } from "../../common/dom/stop_propagation";
import { stringCompare } from "../../common/string/compare"; import { stringCompare } from "../../common/string/compare";
import type { LabelRegistryEntry } from "../../data/label/label_registry"; import type { LabelRegistryEntry } from "../../data/label_registry";
import "../chips/ha-chip-set"; import "../chips/ha-chip-set";
import "../ha-dropdown"; import "../ha-dropdown";
import "../ha-dropdown-item"; import "../ha-dropdown-item";

View File

@@ -16,10 +16,8 @@ import memoizeOne from "memoize-one";
import { restoreScroll } from "../../common/decorators/restore-scroll"; import { restoreScroll } from "../../common/decorators/restore-scroll";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { stringCompare } from "../../common/string/compare"; import { stringCompare } from "../../common/string/compare";
import type { LocalizeFunc } from "../../common/translations/localize";
import { debounce } from "../../common/util/debounce"; import { debounce } from "../../common/util/debounce";
import { groupBy } from "../../common/util/group-by"; import { groupBy } from "../../common/util/group-by";
import { nextRender } from "../../common/util/render-status";
import { haStyleScrollbar } from "../../resources/styles"; import { haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer"; import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
@@ -28,6 +26,8 @@ import type { HaCheckbox } from "../ha-checkbox";
import "../ha-svg-icon"; import "../ha-svg-icon";
import "../search-input"; import "../search-input";
import { filterData, sortData } from "./sort-filter"; import { filterData, sortData } from "./sort-filter";
import type { LocalizeFunc } from "../../common/translations/localize";
import { nextRender } from "../../common/util/render-status";
export interface RowClickedEvent { export interface RowClickedEvent {
id: string; id: string;
@@ -1402,9 +1402,6 @@ export class HaDataTable extends LitElement {
} }
.secondary { .secondary {
color: var(--secondary-text-color); color: var(--secondary-text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.scroller { .scroller {
height: calc(100% - 57px); height: calc(100% - 57px);

View File

@@ -1,9 +1,9 @@
import { expose } from "comlink"; import { expose } from "comlink";
import Fuse, { type FuseOptionKey } from "fuse.js"; import Fuse from "fuse.js";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { ipCompare, stringCompare } from "../../common/string/compare"; import { ipCompare, stringCompare } from "../../common/string/compare";
import { stripDiacritics } from "../../common/string/strip-diacritics"; import { stripDiacritics } from "../../common/string/strip-diacritics";
import { multiTermSearch } from "../../resources/fuseMultiTerm"; import { HaFuse } from "../../resources/fuse";
import type { import type {
ClonedDataTableColumnData, ClonedDataTableColumnData,
DataTableRowData, DataTableRowData,
@@ -11,10 +11,9 @@ import type {
SortingDirection, SortingDirection,
} from "./ha-data-table"; } from "./ha-data-table";
const getSearchKeys = memoizeOne( const fuseIndex = memoizeOne(
(columns: SortableColumnContainer): FuseOptionKey<DataTableRowData>[] => { (data: DataTableRowData[], columns: SortableColumnContainer) => {
const searchKeys = new Set<string>(); const searchKeys = new Set<string>();
Object.entries(columns).forEach(([key, column]) => { Object.entries(columns).forEach(([key, column]) => {
if (column.filterable) { if (column.filterable) {
searchKeys.add( searchKeys.add(
@@ -24,15 +23,10 @@ const getSearchKeys = memoizeOne(
); );
} }
}); });
return Array.from(searchKeys); return Fuse.createIndex([...searchKeys], data);
} }
); );
const fuseIndex = memoizeOne(
(data: DataTableRowData[], keys: FuseOptionKey<DataTableRowData>[]) =>
Fuse.createIndex(keys, data)
);
const filterData = ( const filterData = (
data: DataTableRowData[], data: DataTableRowData[],
columns: SortableColumnContainer, columns: SortableColumnContainer,
@@ -44,13 +38,21 @@ const filterData = (
return data; return data;
} }
const keys = getSearchKeys(columns); const index = fuseIndex(data, columns);
const index = fuseIndex(data, keys); const fuse = new HaFuse(
data,
{ shouldSort: false, minMatchCharLength: 1 },
index
);
return multiTermSearch<DataTableRowData>(data, filter, keys, index, { const searchResults = fuse.multiTermsSearch(filter);
threshold: 0.2, // reduce fuzzy matches in data tables
}); if (searchResults) {
return searchResults.map((result) => result.item);
}
return data;
}; };
const sortData = ( const sortData = (

View File

@@ -1,9 +1,9 @@
import { customElement } from "lit/decorators"; import { customElement } from "lit/decorators";
import type { DeviceAction } from "../../data/device/device_automation"; import type { DeviceAction } from "../../data/device_automation";
import { import {
fetchDeviceActions, fetchDeviceActions,
localizeDeviceAutomationAction, localizeDeviceAutomationAction,
} from "../../data/device/device_automation"; } from "../../data/device_automation";
import { HaDeviceAutomationPicker } from "./ha-device-automation-picker"; import { HaDeviceAutomationPicker } from "./ha-device-automation-picker";
@customElement("ha-device-action-picker") @customElement("ha-device-action-picker")

View File

@@ -2,17 +2,17 @@ import { consume } from "@lit/context";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators"; import { property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { fullEntitiesContext } from "../../data/context"; import { fullEntitiesContext } from "../../data/context";
import type { DeviceAutomation } from "../../data/device/device_automation"; import type { DeviceAutomation } from "../../data/device_automation";
import { import {
deviceAutomationsEqual, deviceAutomationsEqual,
sortDeviceAutomations, sortDeviceAutomations,
} from "../../data/device/device_automation"; } from "../../data/device_automation";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry"; import type { EntityRegistryEntry } from "../../data/entity_registry";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import "../ha-md-select";
import "../ha-md-select-option"; import "../ha-md-select-option";
import "../ha-md-select";
import { stopPropagation } from "../../common/dom/stop_propagation";
const NO_AUTOMATION_KEY = "NO_AUTOMATION"; const NO_AUTOMATION_KEY = "NO_AUTOMATION";
const UNKNOWN_AUTOMATION_KEY = "UNKNOWN_AUTOMATION"; const UNKNOWN_AUTOMATION_KEY = "UNKNOWN_AUTOMATION";

View File

@@ -1,9 +1,9 @@
import { customElement } from "lit/decorators"; import { customElement } from "lit/decorators";
import type { DeviceCondition } from "../../data/device/device_automation"; import type { DeviceCondition } from "../../data/device_automation";
import { import {
fetchDeviceConditions, fetchDeviceConditions,
localizeDeviceAutomationCondition, localizeDeviceAutomationCondition,
} from "../../data/device/device_automation"; } from "../../data/device_automation";
import { HaDeviceAutomationPicker } from "./ha-device-automation-picker"; import { HaDeviceAutomationPicker } from "./ha-device-automation-picker";
@customElement("ha-device-condition-picker") @customElement("ha-device-condition-picker")

View File

@@ -1,4 +1,4 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize"; import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { HassEntity } from "home-assistant-js-websocket"; import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing, type PropertyValues } from "lit"; import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
@@ -9,11 +9,10 @@ import { computeDeviceName } from "../../common/entity/compute_device_name";
import { getDeviceContext } from "../../common/entity/context/get_device_context"; import { getDeviceContext } from "../../common/entity/context/get_device_context";
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries"; import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
import { import {
deviceComboBoxKeys,
getDevices, getDevices,
type DevicePickerItem, type DevicePickerItem,
} from "../../data/device/device_picker"; type DeviceRegistryEntry,
import type { DeviceRegistryEntry } from "../../data/device/device_registry"; } from "../../data/device_registry";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url"; import { brandsUrl } from "../../util/brands-url";
import "../ha-generic-picker"; import "../ha-generic-picker";
@@ -162,7 +161,7 @@ export class HaDevicePicker extends LitElement {
} }
); );
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => html` private _rowRenderer: ComboBoxLitRenderer<DevicePickerItem> = (item) => html`
<ha-combo-box-item type="button"> <ha-combo-box-item type="button">
${item.domain ${item.domain
? html` ? html`
@@ -205,8 +204,6 @@ export class HaDevicePicker extends LitElement {
<ha-generic-picker <ha-generic-picker
.hass=${this.hass} .hass=${this.hass}
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
.disabled=${this.disabled}
.helper=${this.helper}
.label=${this.label} .label=${this.label}
.searchLabel=${this.searchLabel} .searchLabel=${this.searchLabel}
.notFoundLabel=${this._notFoundLabel} .notFoundLabel=${this._notFoundLabel}
@@ -219,10 +216,6 @@ export class HaDevicePicker extends LitElement {
.getItems=${this._getItems} .getItems=${this._getItems}
.hideClearIcon=${this.hideClearIcon} .hideClearIcon=${this.hideClearIcon}
.valueRenderer=${valueRenderer} .valueRenderer=${valueRenderer}
.searchKeys=${deviceComboBoxKeys}
.unknownItemText=${this.hass.localize(
"ui.components.device-picker.unknown"
)}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
> >
</ha-generic-picker> </ha-generic-picker>

View File

@@ -1,9 +1,9 @@
import { customElement } from "lit/decorators"; import { customElement } from "lit/decorators";
import type { DeviceTrigger } from "../../data/device/device_automation"; import type { DeviceTrigger } from "../../data/device_automation";
import { import {
fetchDeviceTriggers, fetchDeviceTriggers,
localizeDeviceAutomationTrigger, localizeDeviceAutomationTrigger,
} from "../../data/device/device_automation"; } from "../../data/device_automation";
import { HaDeviceAutomationPicker } from "./ha-device-automation-picker"; import { HaDeviceAutomationPicker } from "./ha-device-automation-picker";
@customElement("ha-device-trigger-picker") @customElement("ha-device-trigger-picker")

View File

@@ -61,6 +61,7 @@ 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}
@@ -78,6 +79,7 @@ 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

@@ -4,7 +4,7 @@ import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { isValidEntityId } from "../../common/entity/valid_entity_id"; import { isValidEntityId } from "../../common/entity/valid_entity_id";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity"; import type { HaEntityPickerEntityFilterFunc } from "../../data/entity";
import type { HomeAssistant, ValueChangedEvent } from "../../types"; import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-sortable"; import "../ha-sortable";
import "./ha-entity-picker"; import "./ha-entity-picker";
@@ -99,6 +99,7 @@ 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}
@@ -128,6 +129,7 @@ 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,11 +1,16 @@
import type { PropertyValues } from "lit";
import { LitElement, html, nothing } from "lit"; import { LitElement, html, nothing } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property, query, state } 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-generic-picker"; import "../ha-combo-box";
import type { PickerComboBoxItem } from "../ha-picker-combo-box"; import type { HaComboBox } from "../ha-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 {
@@ -37,44 +42,51 @@ class HaEntityAttributePicker extends LitElement {
@property() public helper?: string; @property() public helper?: string;
private _getItemsMemoized = memoizeOne( @state() private _opened = false;
(
entityId: string | string[] | undefined,
hideAttributes: string[] | undefined,
hass: HomeAssistant
): PickerComboBoxItem[] => {
const entityIds = entityId ? ensureArray(entityId) : [];
const options: PickerComboBoxItem[] = [];
const optionsSet = new Set<string>();
for (const id of entityIds) { @query("ha-combo-box", true) private _comboBox!: HaComboBox;
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) {
continue; return [];
} }
const attributes = Object.keys(stateObj.attributes).filter( const attributes = Object.keys(stateObj.attributes).filter(
(a) => !hideAttributes?.includes(a) (a) => !this.hideAttributes?.includes(a)
); );
for (const attribute of attributes) { return attributes.map((a) => ({
if (!optionsSet.has(attribute)) { value: a,
optionsSet.add(attribute); label: this.hass.formatEntityAttributeName(stateObj, a),
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);
} }
} }
} }
return options; (this._comboBox as any).filteredItems = options;
} }
); }
private _getItems = () =>
this._getItemsMemoized(this.entityId, this.hideAttributes, this.hass);
protected render() { protected render() {
if (!this.hass) { if (!this.hass) {
@@ -82,9 +94,10 @@ class HaEntityAttributePicker extends LitElement {
} }
return html` return html`
<ha-generic-picker <ha-combo-box
.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"
@@ -93,22 +106,39 @@ class HaEntityAttributePicker extends LitElement {
.required=${this.required} .required=${this.required}
.helper=${this.helper} .helper=${this.helper}
.allowCustomValue=${this.allowCustomValue} .allowCustomValue=${this.allowCustomValue}
.getItems=${this._getItems} item-id-path="value"
item-value-path="value"
item-label-path="label"
@opened-changed=${this._openedChanged}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
> >
</ha-generic-picker> </ha-combo-box>
`; `;
} }
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.value = newValue; this._setValue(newValue);
fireEvent(this, "value-changed", { value: newValue });
fireEvent(this, "change");
} }
} }
private _setValue(value: string) {
this.value = value;
setTimeout(() => {
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}, 0);
}
} }
declare global { declare global {

View File

@@ -1,11 +1,15 @@
import "@material/mwc-menu/mwc-menu-surface";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js"; import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize"; import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
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 } from "lit/decorators"; import { customElement, property, query, state } 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";
@@ -14,18 +18,20 @@ 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-item"; import "../ha-combo-box";
import "../ha-generic-picker"; import type { HaComboBox } from "../ha-combo-box";
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";
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html` interface EntityNameOption {
<ha-combo-box-item type="button" compact> primary: string;
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>`
@@ -73,7 +79,11 @@ export class HaEntityNamePicker extends LitElement {
@property({ type: Boolean, reflect: true }) public disabled = false; @property({ type: Boolean, reflect: true }) public disabled = false;
@query("ha-generic-picker", true) private _picker?: HaGenericPicker; @query(".container", true) private _container?: HTMLDivElement;
@query("ha-combo-box", true) private _comboBox!: HaComboBox;
@state() private _opened = false;
private _editIndex?: number; private _editIndex?: number;
@@ -105,7 +115,7 @@ export class HaEntityNamePicker extends LitElement {
return options; return options;
}); });
private _getItems = memoizeOne((entityId?: string) => { private _getOptions = memoizeOne((entityId?: string) => {
if (!entityId) { if (!entityId) {
return []; return [];
} }
@@ -114,7 +124,7 @@ export class HaEntityNamePicker extends LitElement {
const items = ( const items = (
["entity", "device", "area", "floor"] as const ["entity", "device", "area", "floor"] as const
).map<PickerComboBoxItem>((name) => { ).map<EntityNameOption>((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(
@@ -127,39 +137,25 @@ 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,
search_labels: { field_label: primary,
primary, value: formatOptionValue({ type: name }),
secondary: secondary || null,
id,
},
sorting_label: primary,
}; };
}); });
return items; return items;
}); });
private _customNameOption = memoizeOne( private _customNameOption = memoizeOne((text: string) => ({
(text: string): PickerComboBoxItem => ({ primary: this.hass.localize(
id: formatOptionValue({ type: "text", text }), "ui.components.entity.entity-name-picker.custom_name"
primary: this.hass.localize( ),
"ui.components.entity.entity-name-picker.custom_name" secondary: `"${text}"`,
), field_label: text,
secondary: `"${text}"`, value: formatOptionValue({ type: "text", text }),
search_labels: { }));
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") {
@@ -175,80 +171,88 @@ 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}
<ha-generic-picker <div class="container">
.hass=${this.hass} <ha-sortable
.disabled=${this.disabled} no-style
.required=${this.required && !value.length} @item-moved=${this._moveItem}
.getItems=${this._getFilteredItems} .disabled=${this.disabled}
.getAdditionalItems=${this._getAdditionalItems} handle-selector="button.primary.action"
.rowRenderer=${rowRenderer} filter=".add"
.searchFn=${this._searchFn} >
.notFoundLabel=${this.hass.localize( <ha-chip-set>
"ui.components.entity.entity-name-picker.no_match" ${repeat(
)} this._items,
.value=${this._getPickerValue()} (item) => item,
allow-custom-value (item: EntityNameItem, idx) => {
.customValueLabel=${this.hass.localize( const label = this._formatItem(item);
"ui.components.entity.entity-name-picker.custom_name" const isValid = validTypes.has(item.type);
)} return html`
@value-changed=${this._pickerValueChanged} <ha-input-chip
> data-idx=${idx}
<div slot="field" class="container"> @remove=${this._removeItem}
<ha-sortable @click=${this._editItem}
no-style .label=${label}
@item-moved=${this._moveItem} .selected=${!this.disabled}
.disabled=${this.disabled}
class=${!isValid ? "invalid" : ""}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
<span>${label}</span>
</ha-input-chip>
`;
}
)}
${this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this.hass.localize(
"ui.components.entity.entity-name-picker.add"
)}
class="add"
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-assist-chip>
`}
</ha-chip-set>
</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} .disabled=${this.disabled}
handle-selector="button.primary.action" .required=${this.required && !value.length}
filter=".add" .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-chip-set> </ha-combo-box>
${repeat( </mwc-menu-surface>
this._items, </div>
(item) => item,
(item: EntityNameItem, idx) => {
const label = this._formatItem(item);
const isValid = validTypes.has(item.type);
return html`
<ha-input-chip
data-idx=${idx}
@remove=${this._removeItem}
@click=${this._editItem}
.label=${label}
.selected=${!this.disabled}
.disabled=${this.disabled}
class=${!isValid ? "invalid" : ""}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
<span>${label}</span>
</ha-input-chip>
`;
}
)}
${this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this.hass.localize(
"ui.components.entity.entity-name-picker.add"
)}
class="add"
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-assist-chip>
`}
</ha-chip-set>
</ha-sortable>
</div>
</ha-generic-picker>
${this._renderHelper()} ${this._renderHelper()}
`; `;
} }
@@ -263,22 +267,32 @@ export class HaEntityNamePicker extends LitElement {
: nothing; : nothing;
} }
private async _addItem(ev: Event) { private _onClosed(ev) {
ev.stopPropagation(); ev.stopPropagation();
this._opened = false;
this._editIndex = undefined; this._editIndex = undefined;
await this.updateComplete;
await this._picker?.open();
} }
private async _editItem(ev: Event) { private async _onOpened(ev) {
if (!this._opened) {
return;
}
ev.stopPropagation(); ev.stopPropagation();
const idx = parseInt( this._opened = true;
(ev.currentTarget as HTMLElement).dataset.idx || "", await this._comboBox?.focus();
10 await this._comboBox?.open();
); }
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;
await this.updateComplete; this._opened = true;
await this._picker?.open();
} }
private get _items(): EntityNameItem[] { private get _items(): EntityNameItem[] {
@@ -308,80 +322,78 @@ export class HaEntityNamePicker extends LitElement {
} }
); );
private _getPickerValue(): string | undefined { private _openedChanged(ev: ValueChangedEvent<boolean>) {
if (this._editIndex != null) { const open = ev.detail.value;
const item = this._items[this._editIndex]; if (open) {
return item ? formatOptionValue(item) : undefined; const options = this._comboBox.items || [];
const initialItem =
this._editIndex != null ? this._items[this._editIndex] : undefined;
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("");
} }
return undefined;
} }
private _getFilteredItems = ( private _filterSelectedOptions = (
searchString?: string, options: EntityNameOption[],
_section?: string current?: string
): PickerComboBoxItem[] => { ) => {
const items = this._getItems(this.entityId); const items = this._items;
const currentItem =
this._editIndex != null ? this._items[this._editIndex] : undefined;
const currentValue = currentItem ? formatOptionValue(currentItem) : "";
const excludedValues = new Set( const excludedValues = new Set(
this._items items
.filter((item) => UNIQUE_TYPES.has(item.type)) .filter((item) => UNIQUE_TYPES.has(item.type))
.map((item) => formatOptionValue(item)) .map((item) => formatOptionValue(item))
); );
const filteredItems = items.filter( const filteredOptions = options.filter(
(item) => !excludedValues.has(item.id) || item.id === currentValue (option) => !excludedValues.has(option.value) || option.value === current
); );
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 _getAdditionalItems = ( private _filterChanged(ev: ValueChangedEvent<string>) {
searchString?: string const input = ev.detail.value;
): PickerComboBoxItem[] => { const filter = input?.toLowerCase() || "";
if (!searchString) { const options = this._comboBox.items || [];
return [];
}
const currentItem = const currentItem =
this._editIndex != null ? this._items[this._editIndex] : undefined; this._editIndex != null ? this._items[this._editIndex] : undefined;
// Don't add if it's the same as the current item being edited const currentValue = currentItem ? formatOptionValue(currentItem) : "";
if (
currentItem?.type === "text" && let filteredItems = this._filterSelectedOptions(options, currentValue);
currentItem.text &&
currentItem.text === searchString if (!filter) {
) { this._comboBox.filteredItems = filteredItems;
return []; return;
} }
// Always return custom name option when there's a search string const fuseOptions: IFuseOptions<EntityNameOption> = {
// This prevents "No matching items found" from showing keys: ["primary", "secondary", "value"],
return [this._customNameOption(searchString)]; isCaseSensitive: false,
}; minMatchCharLength: Math.min(filter.length, 2),
threshold: 0.2,
ignoreDiacritics: true,
};
private _searchFn = ( const fuse = new Fuse(filteredItems, fuseOptions);
search: string, filteredItems = fuse.search(filter).map((result) => result.item);
filteredItems: PickerComboBoxItem[], filteredItems.push(this._customNameOption(input));
_allItems: PickerComboBoxItem[] this._comboBox.filteredItems = filteredItems;
): 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();
@@ -391,21 +403,25 @@ 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: Event) { private async _removeItem(ev) {
ev.stopPropagation(); ev.stopPropagation();
const value = [...this._items]; const value = [...this._items];
const idx = parseInt((ev.target as HTMLElement).dataset.idx || "", 10); const idx = parseInt(ev.target.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 _pickerValueChanged(ev: ValueChangedEvent<string>): void { private _comboBoxValueChanged(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;
} }
@@ -415,16 +431,11 @@ 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[]) {
@@ -486,6 +497,10 @@ 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

@@ -1,5 +1,5 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiPlus, mdiShape } from "@mdi/js"; import { mdiPlus, mdiShape } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import { html, LitElement, nothing, type PropertyValues } from "lit"; import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query } from "lit/decorators"; import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
@@ -7,12 +7,11 @@ import { fireEvent } from "../../common/dom/fire_event";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display"; import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { isValidEntityId } from "../../common/entity/valid_entity_id"; import { isValidEntityId } from "../../common/entity/valid_entity_id";
import { computeRTL } from "../../common/util/compute_rtl"; import { computeRTL } from "../../common/util/compute_rtl";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity"; import type { HaEntityPickerEntityFilterFunc } from "../../data/entity";
import { import {
entityComboBoxKeys,
getEntities, getEntities,
type EntityComboBoxItem, type EntityComboBoxItem,
} from "../../data/entity/entity_picker"; } from "../../data/entity_registry";
import { domainToName } from "../../data/integration"; import { domainToName } from "../../data/integration";
import { import {
isHelperDomain, isHelperDomain,
@@ -172,9 +171,9 @@ export class HaEntityPicker extends LitElement {
return this.showEntityId || this.hass.userData?.showEntityIdPicker; return this.showEntityId || this.hass.userData?.showEntityIdPicker;
} }
private _rowRenderer: RenderItemFunction<EntityComboBoxItem> = ( private _rowRenderer: ComboBoxLitRenderer<EntityComboBoxItem> = (
item, item,
index { index }
) => { ) => {
const showEntityId = this._showEntityId; const showEntityId = this._showEntityId;
@@ -228,7 +227,7 @@ export class HaEntityPicker extends LitElement {
if (!createDomains?.length) { if (!createDomains?.length) {
return []; return [];
} }
this.hass.loadFragmentTranslation("config");
return createDomains.map((domain) => { return createDomains.map((domain) => {
const primary = localize( const primary = localize(
"ui.components.entity.entity-picker.create_helper", "ui.components.entity.entity-picker.create_helper",
@@ -236,7 +235,7 @@ export class HaEntityPicker extends LitElement {
domain: isHelperDomain(domain) domain: isHelperDomain(domain)
? localize( ? localize(
`ui.panel.config.helpers.types.${domain as HelperDomain}` `ui.panel.config.helpers.types.${domain as HelperDomain}`
) || domain )
: domainToName(localize, domain), : domainToName(localize, domain),
} }
); );
@@ -277,28 +276,22 @@ 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}
.hideClearIcon=${this.hideClearIcon} .hideClearIcon=${this.hideClearIcon}
.searchFn=${this._searchFn} .searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer} .valueRenderer=${this._valueRenderer}
.searchKeys=${entityComboBoxKeys} @value-changed=${this._valueChanged}
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}
.unknownItemText=${this.hass.localize(
"ui.components.entity.entity-picker.unknown"
)}
@value-changed=${this._valueChanged}
> >
</ha-generic-picker> </ha-generic-picker>
`; `;

View File

@@ -1,11 +1,16 @@
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 } from "lit/decorators"; import { customElement, property, query, state } 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,
@@ -15,13 +20,21 @@ 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-item"; import "../ha-combo-box";
import "../ha-generic-picker"; import type { HaComboBox } from "../ha-combo-box";
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",
@@ -98,88 +111,63 @@ export class HaStateContentPicker extends LitElement {
@property() public helper?: string; @property() public helper?: string;
@query("ha-generic-picker", true) private _picker?: HaGenericPicker; @query(".container", true) private _container?: HTMLDivElement;
@query("ha-combo-box", true) private _comboBox!: HaComboBox;
@state() private _opened = false;
private _editIndex?: number; private _editIndex?: number;
private _getItems = memoizeOne( private _options = 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;
const items: PickerComboBoxItem[] = [ return [
{ {
id: "state",
primary: this.hass.localize( primary: this.hass.localize(
"ui.components.state-content-picker.state" "ui.components.state-content-picker.state"
), ),
sorting_label: this.hass.localize( value: "state",
"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"
), ),
sorting_label: this.hass.localize( value: "name",
"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"
), ),
sorting_label: this.hass.localize( value: "last_changed",
"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"
), ),
sorting_label: this.hass.localize( value: "last_updated",
"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( ).map((content) => ({
(content) => primary: this.hass.localize(
({ `ui.components.state-content-picker.${content}`
id: content, ),
primary: this.hass.localize( value: content,
`ui.components.state-content-picker.${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( .map((attribute) => ({
(attribute) => primary: this.hass.formatEntityAttributeName(stateObj!, attribute),
({ value: attribute,
id: attribute, })),
primary: this.hass.formatEntityAttributeName( ] satisfies StateContentOption[];
stateObj!,
attribute
),
sorting_label: this.hass.formatEntityAttributeName(
stateObj!,
attribute
),
}) satisfies PickerComboBoxItem
),
];
return items;
} }
); );
@@ -190,123 +178,122 @@ 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}
<ha-generic-picker <div class="container ${this.disabled ? "disabled" : ""}">
.hass=${this.hass} <ha-sortable
.disabled=${this.disabled} no-style
.required=${this.required && !value.length} @item-moved=${this._moveItem}
.value=${this._getPickerValue()} .disabled=${this.disabled}
.getItems=${this._getFilteredItems} handle-selector="button.primary.action"
.getAdditionalItems=${this._getAdditionalItems} filter=".add"
.notFoundLabel=${this.hass.localize("ui.components.combo-box.no_match")} >
allow-custom-value <ha-chip-set>
.customValueLabel=${this.hass.localize( ${repeat(
"ui.components.entity.entity-state-content-picker.custom_state" this._value,
)} (item) => item,
@value-changed=${this._pickerValueChanged} (item: string, idx) => {
> const label = options.find((o) => o.value === item)?.primary;
<div slot="field" class="container"> const isValid = !!label;
<ha-sortable return html`
no-style <ha-input-chip
@item-moved=${this._moveItem} data-idx=${idx}
.disabled=${this.disabled} @remove=${this._removeItem}
handle-selector="button.primary.action" @click=${this._editItem}
filter=".add" .label=${label || item}
.selected=${!this.disabled}
.disabled=${this.disabled}
class=${!isValid ? "invalid" : ""}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</ha-input-chip>
`;
}
)}
${this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this.hass.localize(
"ui.components.entity.entity-state-content-picker.add"
)}
class="add"
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-assist-chip>
`}
</ha-chip-set>
</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-chip-set> </ha-combo-box>
${repeat( </mwc-menu-surface>
this._value, </div>
(item) => item,
(item: string, idx) => {
const label = this._getItemLabel(item, stateObj);
const isValid = !!label;
return html`
<ha-input-chip
data-idx=${idx}
@remove=${this._removeItem}
@click=${this._editItem}
.label=${label || item}
.selected=${!this.disabled}
.disabled=${this.disabled}
class=${!isValid ? "invalid" : ""}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</ha-input-chip>
`;
}
)}
${this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this.hass.localize(
"ui.components.entity.entity-state-content-picker.add"
)}
class="add"
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-assist-chip>
`}
</ha-chip-set>
</ha-sortable>
</div>
</ha-generic-picker>
${this._renderHelper()}
`; `;
} }
private _renderHelper() { private _onClosed(ev) {
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 _editItem(ev: Event) { private async _onOpened(ev) {
if (!this._opened) {
return;
}
ev.stopPropagation(); ev.stopPropagation();
const idx = parseInt( this._opened = true;
(ev.currentTarget as HTMLElement).dataset.idx || "", await this._comboBox?.focus();
10 await this._comboBox?.open();
); }
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;
await this.updateComplete; this._opened = true;
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;
@@ -317,87 +304,63 @@ export class HaStateContentPicker extends LitElement {
return value; return value;
}); });
private _getPickerValue(): string | undefined { private _openedChanged(ev: ValueChangedEvent<boolean>) {
if (this._editIndex != null) { const open = ev.detail.value;
return this._value[this._editIndex]; if (open) {
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 _customValueOption = memoizeOne( private _filterSelectedOptions = (
(text: string): PickerComboBoxItem => ({ options: StateContentOption[],
id: text, current?: string
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;
const filteredItems = items.filter( return options.filter(
(item) => !value.includes(item.id) || item.id === currentValue (option) => !value.includes(option.value) || option.value === current
); );
// 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 _getAdditionalItems = ( private _filterChanged(ev: ValueChangedEvent<string>) {
searchString?: string const input = ev.detail.value;
): PickerComboBoxItem[] => { const filter = input?.toLowerCase() || "";
if (!searchString) { const options = this._comboBox.items || [];
return [];
}
const currentValue = const currentValue =
this._editIndex != null ? this._value[this._editIndex] : undefined; this._editIndex != null ? this._value[this._editIndex] : "";
// Don't add if it's the same as the current item being edited this._comboBox.filteredItems = this._filterSelectedOptions(
if (currentValue && currentValue === searchString) { options,
return []; currentValue
);
if (!filter) {
return;
} }
// Check if the search string matches an existing item const fuseOptions: IFuseOptions<StateContentOption> = {
const stateObj = this.entityId keys: ["primary", "secondary", "value"],
? this.hass.states[this.entityId] isCaseSensitive: false,
: undefined; minMatchCharLength: Math.min(filter.length, 2),
const items = this._getItems(this.entityId, stateObj, this.allowName); threshold: 0.2,
const existingItem = items.find((item) => item.id === searchString); ignoreDiacritics: true,
};
// Only return custom value option if it doesn't match an existing item const fuse = new Fuse(this._comboBox.filteredItems, fuseOptions);
if (!existingItem) { const filteredItems = fuse.search(filter).map((result) => result.item);
return [this._customValueOption(searchString)];
}
return []; this._comboBox.filteredItems = filteredItems;
}; }
private async _moveItem(ev: CustomEvent) { private async _moveItem(ev: CustomEvent) {
ev.stopPropagation(); ev.stopPropagation();
@@ -407,21 +370,25 @@ 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: Event) { private async _removeItem(ev) {
ev.stopPropagation(); ev.stopPropagation();
const value = [...this._value]; const value = [...this._value];
const idx = parseInt((ev.target as HTMLElement).dataset.idx || "", 10); const idx = parseInt(ev.target.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 _pickerValueChanged(ev: ValueChangedEvent<string>): void { private _comboBoxValueChanged(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;
} }
@@ -429,16 +396,11 @@ 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[]) {
@@ -480,7 +442,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;
} }
:host([disabled]) .container:after { .container.disabled: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)
@@ -500,6 +462,10 @@ 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);
} }
@@ -520,11 +486,6 @@ 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,22 +1,27 @@
import type { PropertyValues } from "lit";
import { LitElement, html, nothing } from "lit"; import { LitElement, html, nothing } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property, query, state } 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-generic-picker"; import "../ha-combo-box";
import type { PickerComboBoxItem } from "../ha-picker-combo-box"; import type { HaComboBox } from "../ha-combo-box";
interface StateOption {
value: string;
label: string;
}
@customElement("ha-entity-state-picker") @customElement("ha-entity-state-picker")
export class HaEntityStatePicker extends LitElement { 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?: PickerComboBoxItem[]; @property({ attribute: false }) public extraOptions?: any[];
// 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;
@@ -37,76 +42,59 @@ export class HaEntityStatePicker extends LitElement {
@property() public helper?: string; @property() public helper?: string;
private _getItems = memoizeOne( @state() private _opened = false;
(
hass: HomeAssistant,
entityId: string | string[] | undefined,
attribute: string | undefined,
hideStates: string[] | undefined,
extraOptions: PickerComboBoxItem[] | undefined
): PickerComboBoxItem[] => {
const entityIds = entityId ? ensureArray(entityId) : [];
const entitiesOptions = entityIds.map<PickerComboBoxItem[]>( @query("ha-combo-box", true) private _comboBox!: HaComboBox;
(entityIdItem) => {
const stateObj = hass.states[entityIdItem] || {
entity_id: entityIdItem,
attributes: {},
};
const states = getStates(hass, stateObj, attribute).filter( protected shouldUpdate(changedProps: PropertyValues) {
(s) => !hideStates?.includes(s) return !(!changedProps.has("_opened") && this._opened);
); }
return states protected updated(changedProps: PropertyValues) {
.map((s) => { if (
const primary = attribute (changedProps.has("_opened") && this._opened) ||
? hass.formatEntityAttributeValue(stateObj, attribute, s) changedProps.has("entityId") ||
: hass.formatEntityState(stateObj, s); changedProps.has("attribute") ||
return { changedProps.has("extraOptions")
id: s, ) {
primary, const entityIds = this.entityId ? ensureArray(this.entityId) : [];
sorting_label: primary,
};
})
.filter((option) => option.id && option.primary);
}
);
const options: PickerComboBoxItem[] = []; const entitiesOptions = entityIds.map<StateOption[]>((entityId) => {
const stateObj = this.hass.states[entityId] || {
entity_id: entityId,
attributes: {},
};
const states = getStates(this.hass, stateObj, this.attribute).filter(
(s) => !this.hideStates?.includes(s)
);
return states.map((s) => ({
value: s,
label: this.attribute
? this.hass.formatEntityAttributeValue(stateObj, this.attribute, s)
: this.hass.formatEntityState(stateObj, s),
}));
});
const options: StateOption[] = [];
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.id)) { if (!optionsSet.has(option.value)) {
optionsSet.add(option.id); optionsSet.add(option.value);
options.push(option); options.push(option);
} }
} }
} }
if (extraOptions) { if (this.extraOptions) {
// Filter out any extraOptions with empty primary or id fields options.unshift(...this.extraOptions);
const validExtraOptions = extraOptions.filter(
(option) => option.id && option.primary
);
options.unshift(...validExtraOptions);
} }
return options; (this._comboBox as any).filteredItems = 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) {
@@ -114,39 +102,48 @@ export class HaEntityStatePicker extends LitElement {
} }
return html` return html`
<ha-generic-picker <ha-combo-box
.hass=${this.hass} .hass=${this.hass}
.allowCustomValue=${this.allowCustomValue} .value=${this._value}
.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}
.value=${this.value} .allowCustomValue=${this.allowCustomValue}
.getItems=${this._getFilteredItems} item-id-path="value"
.notFoundLabel=${this.hass.localize("ui.components.combo-box.no_match")} item-value-path="value"
.customValueLabel=${this.hass.localize( item-label-path="label"
"ui.components.entity.entity-state-picker.add_custom_state" @opened-changed=${this._openedChanged}
)}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
> >
</ha-generic-picker> </ha-combo-box>
`; `;
} }
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 | undefined) { private _setValue(value: string) {
this.value = value; this.value = value;
fireEvent(this, "value-changed", { value }); setTimeout(() => {
fireEvent(this, "change"); fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}, 0);
} }
} }

View File

@@ -6,11 +6,7 @@ import { customElement, property, state } from "lit/decorators";
import { STATES_OFF } from "../../common/const"; import { STATES_OFF } from "../../common/const";
import { computeStateDomain } from "../../common/entity/compute_state_domain"; import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name"; import { computeStateName } from "../../common/entity/compute_state_name";
import { import { UNAVAILABLE, UNKNOWN, isUnavailableState } from "../../data/entity";
UNAVAILABLE,
UNKNOWN,
isUnavailableState,
} from "../../data/entity/entity";
import { forwardHaptic } from "../../data/haptics"; import { forwardHaptic } from "../../data/haptics";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import "../ha-formfield"; import "../ha-formfield";

View File

@@ -14,12 +14,8 @@ import {
getNumberFormatOptions, getNumberFormatOptions,
isNumericState, isNumericState,
} from "../../common/number/format_number"; } from "../../common/number/format_number";
import { import { isUnavailableState, UNAVAILABLE, UNKNOWN } from "../../data/entity";
isUnavailableState, import type { EntityRegistryDisplayEntry } from "../../data/entity_registry";
UNAVAILABLE,
UNKNOWN,
} from "../../data/entity/entity";
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
import { timerTimeRemaining } from "../../data/timer"; import { timerTimeRemaining } from "../../data/timer";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import "../ha-label-badge"; import "../ha-label-badge";

View File

@@ -1,5 +1,5 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiChartLine, mdiHelpCircle, mdiShape } from "@mdi/js"; import { mdiChartLine, mdiHelpCircle, mdiShape } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { HassEntity } from "home-assistant-js-websocket"; import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing, type PropertyValues } from "lit"; import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query } from "lit/decorators"; import { customElement, property, query } from "lit/decorators";
@@ -38,21 +38,9 @@ type StatisticItemType = "entity" | "external" | "no_state";
interface StatisticComboBoxItem extends PickerComboBoxItem { interface StatisticComboBoxItem extends PickerComboBoxItem {
statistic_id?: string; statistic_id?: string;
stateObj?: HassEntity; stateObj?: HassEntity;
domainName?: string;
type?: StatisticItemType; type?: StatisticItemType;
} }
const SEARCH_KEYS = [
{ name: "label", weight: 10 },
{ name: "search_labels.entityName", weight: 10 },
{ name: "search_labels.friendlyName", weight: 9 },
{ name: "search_labels.deviceName", weight: 8 },
{ name: "search_labels.areaName", weight: 6 },
{ name: "search_labels.domainName", weight: 4 },
{ name: "statisticId", weight: 3 },
{ name: "id", weight: 2 },
];
@customElement("ha-statistic-picker") @customElement("ha-statistic-picker")
export class HaStatisticPicker extends LitElement { export class HaStatisticPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@@ -245,6 +233,7 @@ export class HaStatisticPicker extends LitElement {
), ),
type, type,
sorting_label: [sortingPrefix, label].join("_"), sorting_label: [sortingPrefix, label].join("_"),
search_labels: [label, id],
icon_path: mdiShape, icon_path: mdiShape,
}); });
} else if (type === "external") { } else if (type === "external") {
@@ -257,7 +246,7 @@ export class HaStatisticPicker extends LitElement {
secondary: domainName, secondary: domainName,
type, type,
sorting_label: [sortingPrefix, label].join("_"), sorting_label: [sortingPrefix, label].join("_"),
search_labels: { label, domainName }, search_labels: [label, domainName, id],
icon_path: mdiChartLine, icon_path: mdiChartLine,
}); });
} }
@@ -291,12 +280,13 @@ export class HaStatisticPicker extends LitElement {
stateObj: stateObj, stateObj: stateObj,
type: "entity", type: "entity",
sorting_label: [sortingPrefix, deviceName, entityName].join("_"), sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
search_labels: { search_labels: [
entityName: entityName || null, entityName,
deviceName: deviceName || null, deviceName,
areaName: areaName || null, areaName,
friendlyName, friendlyName,
}, id,
].filter(Boolean) as string[],
}); });
}); });
@@ -371,13 +361,13 @@ export class HaStatisticPicker extends LitElement {
stateObj: stateObj, stateObj: stateObj,
type: "entity", type: "entity",
sorting_label: [sortingPrefix, deviceName, entityName].join("_"), sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
search_labels: { search_labels: [
entityName: entityName || null, entityName,
deviceName: deviceName || null, deviceName,
areaName: areaName || null, areaName,
friendlyName, friendlyName,
statisticId, statisticId,
}, ].filter(Boolean) as string[],
}; };
} }
@@ -404,7 +394,7 @@ export class HaStatisticPicker extends LitElement {
secondary: domainName, secondary: domainName,
type: "external", type: "external",
sorting_label: [sortingPrefix, label].join("_"), sorting_label: [sortingPrefix, label].join("_"),
search_labels: { label, domainName, statisticId }, search_labels: [label, domainName, statisticId],
icon_path: mdiChartLine, icon_path: mdiChartLine,
}; };
} }
@@ -419,14 +409,14 @@ export class HaStatisticPicker extends LitElement {
secondary: this.hass.localize("ui.components.statistic-picker.no_state"), secondary: this.hass.localize("ui.components.statistic-picker.no_state"),
type: "no_state", type: "no_state",
sorting_label: [sortingPrefix, label].join("_"), sorting_label: [sortingPrefix, label].join("_"),
search_labels: { label, statisticId }, search_labels: [label, statisticId],
icon_path: mdiShape, icon_path: mdiShape,
}; };
} }
private _rowRenderer: RenderItemFunction<StatisticComboBoxItem> = ( private _rowRenderer: ComboBoxLitRenderer<StatisticComboBoxItem> = (
item, item,
index { index }
) => { ) => {
const showEntityId = this.hass.userData?.showEntityIdPicker; const showEntityId = this.hass.userData?.showEntityIdPicker;
return html` return html`
@@ -471,15 +461,13 @@ export class HaStatisticPicker extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomEntity} .allowCustomValue=${this.allowCustomEntity}
.disabled=${this.disabled}
.label=${this.label} .label=${this.label}
use-top-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}
@@ -487,10 +475,6 @@ export class HaStatisticPicker extends LitElement {
.searchFn=${this._searchFn} .searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer} .valueRenderer=${this._valueRenderer}
.helper=${this.helper} .helper=${this.helper}
.searchKeys=${SEARCH_KEYS}
.unknownItemText=${this.hass.localize(
"ui.components.statistic-picker.unknown"
)}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
> >
</ha-generic-picker> </ha-generic-picker>

View File

@@ -1,8 +1,8 @@
import { mdiClose } from "@mdi/js"; import { mdiClose } from "@mdi/js";
import { css, html, LitElement } from "lit"; import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { listenMediaQuery } from "../common/dom/media_query";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import { listenMediaQuery } from "../common/dom/media_query";
import "./ha-bottom-sheet"; import "./ha-bottom-sheet";
import "./ha-dialog-header"; import "./ha-dialog-header";
import "./ha-icon-button"; import "./ha-icon-button";
@@ -118,29 +118,27 @@ export class HaAdaptiveDialog extends LitElement {
if (this._mode === "bottom-sheet") { if (this._mode === "bottom-sheet") {
return html` return html`
<ha-bottom-sheet .open=${this.open} flexcontent> <ha-bottom-sheet .open=${this.open} flexcontent>
<slot name="header" slot="header"> <ha-dialog-header
<ha-dialog-header slot="header"
slot="header" .subtitlePosition=${this.headerSubtitlePosition}
.subtitlePosition=${this.headerSubtitlePosition} >
> <slot name="headerNavigationIcon" slot="navigationIcon">
<slot name="headerNavigationIcon" slot="navigationIcon"> <ha-icon-button
<ha-icon-button data-drawer="close"
data-drawer="close" .label=${this.hass?.localize("ui.common.close") ?? "Close"}
.label=${this.hass?.localize("ui.common.close") ?? "Close"} .path=${mdiClose}
.path=${mdiClose} ></ha-icon-button>
></ha-icon-button> </slot>
</slot> ${this.headerTitle !== undefined
${this.headerTitle !== undefined ? html`<span slot="title" class="title" id="ha-wa-dialog-title">
? html`<span slot="title" class="title" id="ha-wa-dialog-title"> ${this.headerTitle}
${this.headerTitle} </span>`
</span>` : html`<slot name="headerTitle" slot="title"></slot>`}
: html`<slot name="headerTitle" slot="title"></slot>`} ${this.headerSubtitle !== undefined
${this.headerSubtitle !== undefined ? html`<span slot="subtitle">${this.headerSubtitle}</span>`
? html`<span slot="subtitle">${this.headerSubtitle}</span>` : html`<slot name="headerSubtitle" slot="subtitle"></slot>`}
: html`<slot name="headerSubtitle" slot="subtitle"></slot>`} <slot name="headerActionItems" slot="actionItems"></slot>
<slot name="headerActionItems" slot="actionItems"></slot> </ha-dialog-header>
</ha-dialog-header>
</slot>
<slot></slot> <slot></slot>
<slot name="footer" slot="footer"></slot> <slot name="footer" slot="footer"></slot>
</ha-bottom-sheet> </ha-bottom-sheet>
@@ -159,18 +157,10 @@ export class HaAdaptiveDialog extends LitElement {
.headerSubtitlePosition=${this.headerSubtitlePosition} .headerSubtitlePosition=${this.headerSubtitlePosition}
flexcontent flexcontent
> >
<slot name="header" slot="header"> <slot name="headerNavigationIcon" slot="headerNavigationIcon"></slot>
<slot name="headerNavigationIcon" slot="headerNavigationIcon"> <slot name="headerTitle" slot="headerTitle"></slot>
<ha-icon-button <slot name="headerSubtitle" slot="headerSubtitle"></slot>
data-dialog="close" <slot name="headerActionItems" slot="headerActionItems"></slot>
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
</slot>
<slot name="headerTitle" slot="headerTitle"></slot>
<slot name="headerSubtitle" slot="headerSubtitle"></slot>
<slot name="headerActionItems" slot="headerActionItems"></slot>
</slot>
<slot></slot> <slot></slot>
<slot name="footer" slot="footer"></slot> <slot name="footer" slot="footer"></slot>
</ha-wa-dialog> </ha-wa-dialog>

View File

@@ -1,29 +1,29 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize"; import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
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 SEARCH_KEYS = [ const rowRenderer: ComboBoxLitRenderer<HassioAddonInfo> = (item) => html`
{ 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.primary}</span> <span slot="headline">${item.name}</span>
<span slot="supporting-text">${item.secondary}</span> <span slot="supporting-text">${item.slug}</span>
${item.icon ${item.icon
? html` <img alt="" slot="start" .src=${item.icon} /> ` ? html`
<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?: PickerComboBoxItem[]; @state() private _addons?: HassioAddonInfo[];
@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-generic-picker") private _genericPicker!: HaGenericPicker; @query("ha-combo-box") private _comboBox!: HaComboBox;
@state() private _error?: string; @state() private _error?: string;
public open() { public open() {
this._genericPicker?.open(); this._comboBox?.open();
} }
public focus() { public focus() {
this._genericPicker?.focus(); this._comboBox?.focus();
} }
protected firstUpdated() { protected firstUpdated() {
@@ -61,34 +61,29 @@ 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-generic-picker <ha-combo-box
.hass=${this.hass} .hass=${this.hass}
.autofocus=${this.autofocus} .label=${this.label === undefined && this.hass
.label=${label} ? this.hass.localize("ui.components.addon-picker.addon")
.valueRenderer=${this._valueRenderer} : this.label}
.helper=${this.helper} .value=${this._value}
.disabled=${this.disabled}
.required=${this.required} .required=${this.required}
.value=${this.value} .disabled=${this.disabled}
.getItems=${this._getItems} .helper=${this.helper}
.searchKeys=${SEARCH_KEYS} .renderer=${rowRenderer}
.rowRenderer=${rowRenderer} .items=${this._addons}
item-value-path="slug"
item-id-path="slug"
item-label-path="name"
@value-changed=${this._addonChanged} @value-changed=${this._addonChanged}
> ></ha-combo-box>
</ha-generic-picker>
`; `;
} }
@@ -98,19 +93,9 @@ 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)
.map((addon) => ({ .sort((a, b) =>
id: addon.slug, stringCompare(a.name, b.name, this.hass.locale.language)
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"
@@ -123,8 +108,6 @@ class HaAddonPicker extends LitElement {
} }
} }
private _getItems = () => this._addons!;
private get _value() { private get _value() {
return this.value || ""; return this.value || "";
} }
@@ -145,17 +128,6 @@ 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

@@ -0,0 +1,270 @@
import { mdiTextureBox } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { HassEntity } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeAreaName } from "../common/entity/compute_area_name";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { computeRTL } from "../common/util/compute_rtl";
import {
getAreasAndFloors,
type AreaFloorValue,
type FloorComboBoxItem,
} from "../data/area_floor";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-combo-box-item";
import "./ha-floor-icon";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-icon-button";
import type { PickerValueRenderer } from "./ha-picker-field";
import "./ha-svg-icon";
import "./ha-tree-indicator";
const SEPARATOR = "________";
@customElement("ha-area-floor-picker")
export class HaAreaFloorPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property({ attribute: false }) public value?: AreaFloorValue;
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: String, attribute: "search-label" })
public searchLabel?: string;
/**
* Show only areas with entities from specific domains.
* @type {Array}
* @attr include-domains
*/
@property({ type: Array, attribute: "include-domains" })
public includeDomains?: string[];
/**
* Show no areas with entities of these domains.
* @type {Array}
* @attr exclude-domains
*/
@property({ type: Array, attribute: "exclude-domains" })
public excludeDomains?: string[];
/**
* Show only areas with entities of these device classes.
* @type {Array}
* @attr include-device-classes
*/
@property({ type: Array, attribute: "include-device-classes" })
public includeDeviceClasses?: string[];
/**
* List of areas to be excluded.
* @type {Array}
* @attr exclude-areas
*/
@property({ type: Array, attribute: "exclude-areas" })
public excludeAreas?: string[];
/**
* List of floors to be excluded.
* @type {Array}
* @attr exclude-floors
*/
@property({ type: Array, attribute: "exclude-floors" })
public excludeFloors?: string[];
@property({ attribute: false })
public deviceFilter?: HaDevicePickerDeviceFilterFunc;
@property({ attribute: false })
public entityFilter?: (entity: HassEntity) => boolean;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
public async open() {
await this.updateComplete;
await this._picker?.open();
}
private _valueRenderer: PickerValueRenderer = (value: string) => {
const item = this._parseValue(value);
const area = item.type === "area" && this.hass.areas[value];
if (area) {
const areaName = computeAreaName(area);
return html`
${area.icon
? html`<ha-icon slot="start" .icon=${area.icon}></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${mdiTextureBox}
></ha-svg-icon>`}
<slot name="headline">${areaName}</slot>
`;
}
const floor = item.type === "floor" && this.hass.floors[value];
if (floor) {
const floorName = computeFloorName(floor);
return html`
<ha-floor-icon slot="start" .floor=${floor}></ha-floor-icon>
<span slot="headline">${floorName}</span>
`;
}
return html`
<ha-svg-icon slot="start" .path=${mdiTextureBox}></ha-svg-icon>
<span slot="headline">${value}</span>
`;
};
private _rowRenderer: ComboBoxLitRenderer<FloorComboBoxItem> = (
item,
{ index },
combobox
) => {
const nextItem = combobox.filteredItems?.[index + 1];
const isLastArea =
!nextItem ||
nextItem.type === "floor" ||
(nextItem.type === "area" && !nextItem.area?.floor_id);
const rtl = computeRTL(this.hass);
const hasFloor = item.type === "area" && item.area?.floor_id;
return html`
<ha-combo-box-item
type="button"
style=${item.type === "area" && hasFloor
? "--md-list-item-leading-space: 48px;"
: ""}
>
${item.type === "area" && hasFloor
? html`
<ha-tree-indicator
style=${styleMap({
width: "48px",
position: "absolute",
top: "0px",
left: rtl ? undefined : "4px",
right: rtl ? "4px" : undefined,
transform: rtl ? "scaleX(-1)" : "",
})}
.end=${isLastArea}
slot="start"
></ha-tree-indicator>
`
: nothing}
${item.type === "floor" && item.floor
? html`<ha-floor-icon
slot="start"
.floor=${item.floor}
></ha-floor-icon>`
: item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${item.icon_path || mdiTextureBox}
></ha-svg-icon>`}
${item.primary}
</ha-combo-box-item>
`;
};
private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors);
private _getItems = () =>
this._getAreasAndFloorsMemoized(
this.hass.states,
this.hass.floors,
this.hass.areas,
this.hass.devices,
this.hass.entities,
this._formatValue,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.excludeAreas,
this.excludeFloors
);
private _formatValue = memoizeOne((value: AreaFloorValue): string =>
[value.type, value.id].join(SEPARATOR)
);
private _parseValue = memoizeOne((value: string): AreaFloorValue => {
const [type, id] = value.split(SEPARATOR);
return { id, type: type as "floor" | "area" };
});
protected render(): TemplateResult {
const placeholder =
this.placeholder ?? this.hass.localize("ui.components.area-picker.area");
const value = this.value ? this._formatValue(this.value) : undefined;
return html`
<ha-generic-picker
.hass=${this.hass}
.autofocus=${this.autofocus}
.label=${this.label}
.searchLabel=${this.searchLabel}
.notFoundLabel=${this.hass.localize(
"ui.components.area-picker.no_match"
)}
.placeholder=${placeholder}
.value=${value}
.getItems=${this._getItems}
.valueRenderer=${this._valueRenderer}
.rowRenderer=${this._rowRenderer}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
`;
}
private _valueChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const value = ev.detail.value;
if (!value) {
this._setValue(undefined);
return;
}
const selected = this._parseValue(value);
this._setValue(selected);
}
private _setValue(value?: AreaFloorValue) {
this.value = value;
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-area-floor-picker": HaAreaFloorPicker;
}
}

View File

@@ -13,9 +13,9 @@ import { createAreaRegistryEntry } from "../data/area_registry";
import type { import type {
DeviceEntityDisplayLookup, DeviceEntityDisplayLookup,
DeviceRegistryEntry, DeviceRegistryEntry,
} from "../data/device/device_registry"; } from "../data/device_registry";
import { getDeviceEntityDisplayLookup } from "../data/device/device_registry"; import { getDeviceEntityDisplayLookup } from "../data/device_registry";
import type { EntityRegistryDisplayEntry } from "../data/entity/entity_registry"; import type { EntityRegistryDisplayEntry } from "../data/entity_registry";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box"; import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { showAreaRegistryDetailDialog } from "../panels/config/areas/show-dialog-area-registry-detail"; import { showAreaRegistryDetailDialog } from "../panels/config/areas/show-dialog-area-registry-detail";
import type { HomeAssistant, ValueChangedEvent } from "../types"; import type { HomeAssistant, ValueChangedEvent } from "../types";
@@ -30,12 +30,6 @@ import "./ha-svg-icon";
const ADD_NEW_ID = "___ADD_NEW___"; const ADD_NEW_ID = "___ADD_NEW___";
const SEARCH_KEYS = [
{ name: "search_labels.areaName", weight: 10 },
{ name: "search_labels.aliases", weight: 8 },
{ name: "search_labels.floorName", weight: 6 },
{ name: "search_labels.id", weight: 3 },
];
@customElement("ha-area-picker") @customElement("ha-area-picker")
export class HaAreaPicker extends LitElement { export class HaAreaPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@@ -296,12 +290,13 @@ export class HaAreaPicker extends LitElement {
secondary: floorName, secondary: floorName,
icon: area.icon || undefined, icon: area.icon || undefined,
icon_path: area.icon ? undefined : mdiTextureBox, icon_path: area.icon ? undefined : mdiTextureBox,
search_labels: { sorting_label: areaName,
areaName: areaName || null, search_labels: [
floorName: floorName || null, areaName,
id: area.area_id, floorName,
aliases: area.aliases.join(" "), area.area_id,
}, ...area.aliases,
].filter((v): v is string => Boolean(v)),
}; };
}); });
@@ -363,41 +358,27 @@ export class HaAreaPicker extends LitElement {
}; };
protected render(): TemplateResult { protected render(): TemplateResult {
const baseLabel = const placeholder =
this.label ?? this.hass.localize("ui.components.area-picker.area"); this.placeholder ?? this.hass.localize("ui.components.area-picker.area");
const valueRenderer = this._computeValueRenderer(this.hass.areas);
// Only show label if there's no floor const valueRenderer = this._computeValueRenderer(this.hass.areas);
let label: string | undefined = baseLabel;
if (this.value && baseLabel) {
const area = this.hass.areas[this.value];
if (area) {
const { floor } = getAreaContext(area, this.hass.floors);
if (floor) {
label = undefined;
}
}
}
return html` return html`
<ha-generic-picker <ha-generic-picker
.hass=${this.hass} .hass=${this.hass}
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
.label=${label} .label=${this.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}
.value=${this.value} .value=${this.value}
.getItems=${this._getItems} .getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems} .getAdditionalItems=${this._getAdditionalItems}
.valueRenderer=${valueRenderer} .valueRenderer=${valueRenderer}
.addButtonLabel=${this.addButtonLabel} .addButtonLabel=${this.addButtonLabel}
.searchKeys=${SEARCH_KEYS}
.unknownItemText=${this.hass.localize(
"ui.components.area-picker.unknown"
)}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
> >
</ha-generic-picker> </ha-generic-picker>

View File

@@ -659,7 +659,7 @@ export class HaAssistChat extends LitElement {
--markdown-table-border-color: var(--divider-color); --markdown-table-border-color: var(--divider-color);
--markdown-code-background-color: var(--primary-background-color); --markdown-code-background-color: var(--primary-background-color);
--markdown-code-text-color: var(--primary-text-color); --markdown-code-text-color: var(--primary-text-color);
--markdown-list-indent: 1.15em; --markdown-list-indent: 1rem;
&:not(:has(ha-markdown-element)) { &:not(:has(ha-markdown-element)) {
min-height: 1lh; min-height: 1lh;
min-width: 1lh; min-width: 1lh;

View File

@@ -3,15 +3,15 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { computeAttributeNameDisplay } from "../common/entity/compute_attribute_display"; import { computeAttributeNameDisplay } from "../common/entity/compute_attribute_display";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { import {
STATE_ATTRIBUTES, STATE_ATTRIBUTES,
STATE_ATTRIBUTES_DOMAIN_CLASS, STATE_ATTRIBUTES_DOMAIN_CLASS,
} from "../data/entity/entity_attributes"; } from "../data/entity_attributes";
import { haStyle } from "../resources/styles"; import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import "./ha-attribute-value"; import "./ha-attribute-value";
import "./ha-expansion-panel"; import "./ha-expansion-panel";
import { computeStateDomain } from "../common/entity/compute_state_domain";
@customElement("ha-attributes") @customElement("ha-attributes")
class HaAttributes extends LitElement { class HaAttributes extends LitElement {

View File

@@ -52,9 +52,7 @@ export class HaAutomationRow extends LitElement {
<slot name="leading-icon"></slot> <slot name="leading-icon"></slot>
</div> </div>
<slot class="header" name="header"></slot> <slot class="header" name="header"></slot>
<div class="icons"> <slot name="icons"></slot>
<slot name="icons"></slot>
</div>
</div> </div>
`; `;
} }
@@ -120,11 +118,12 @@ export class HaAutomationRow extends LitElement {
} }
.row { .row {
display: flex; display: flex;
padding: 0 var(--ha-space-3); padding: var(--ha-space-0) var(--ha-space-2);
min-height: 48px; min-height: 48px;
align-items: flex-start; align-items: center;
cursor: pointer; cursor: pointer;
overflow: hidden; overflow: hidden;
font-weight: var(--ha-font-weight-medium);
outline: none; outline: none;
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg)); border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
} }
@@ -141,15 +140,11 @@ export class HaAutomationRow extends LitElement {
background-color: var(--ha-color-fill-neutral-loud-resting); background-color: var(--ha-color-fill-neutral-loud-resting);
border-radius: var(--ha-border-radius-md); border-radius: var(--ha-border-radius-md);
padding: var(--ha-space-1); padding: var(--ha-space-1);
margin-top: 10px;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
transform: rotate(45deg); transform: rotate(45deg);
} }
.leading-icon-wrapper {
padding-top: var(--ha-space-3);
}
::slotted([slot="leading-icon"]) { ::slotted([slot="leading-icon"]) {
color: var(--ha-color-on-neutral-quiet); color: var(--ha-color-on-neutral-quiet);
} }
@@ -175,11 +170,7 @@ export class HaAutomationRow extends LitElement {
::slotted([slot="header"]) { ::slotted([slot="header"]) {
flex: 1; flex: 1;
overflow-wrap: anywhere; overflow-wrap: anywhere;
margin: 0 var(--ha-space-3); margin: var(--ha-space-0) var(--ha-space-3);
}
.icons {
display: flex;
align-items: center;
} }
:host([sort-selected]) .row { :host([sort-selected]) .row {
outline: solid; outline: solid;

View File

@@ -241,7 +241,7 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
} }
slot[name="footer"] { slot[name="footer"] {
display: block; display: block;
padding: 0; padding: var(--ha-space-0);
} }
::slotted([slot="footer"]) { ::slotted([slot="footer"]) {
display: flex; display: flex;

View File

@@ -46,14 +46,14 @@ export class HaCard extends LitElement {
line-height: var(--ha-line-height-expanded); line-height: var(--ha-line-height-expanded);
padding: var(--ha-space-3) var(--ha-space-4) var(--ha-space-4); padding: var(--ha-space-3) var(--ha-space-4) var(--ha-space-4);
display: block; display: block;
margin-block-start: 0; margin-block-start: var(--ha-space-0);
margin-block-end: 0; margin-block-end: var(--ha-space-0);
font-weight: var(--ha-font-weight-normal); font-weight: var(--ha-font-weight-normal);
} }
:host ::slotted(.card-content:not(:first-child)), :host ::slotted(.card-content:not(:first-child)),
slot:not(:first-child)::slotted(.card-content) { slot:not(:first-child)::slotted(.card-content) {
padding-top: 0; padding-top: var(--ha-space-0);
margin-top: calc(var(--ha-space-2) * -1); margin-top: calc(var(--ha-space-2) * -1);
} }

View File

@@ -3,7 +3,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import type { ClimateEntity } from "../data/climate"; import type { ClimateEntity } from "../data/climate";
import { CLIMATE_PRESET_NONE } from "../data/climate"; import { CLIMATE_PRESET_NONE } from "../data/climate";
import { isUnavailableState, OFF } from "../data/entity/entity"; import { isUnavailableState, OFF } from "../data/entity";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
@customElement("ha-climate-state") @customElement("ha-climate-state")

View File

@@ -1,23 +1,25 @@
import { mdiInvertColorsOff, mdiPalette } from "@mdi/js"; import { mdiInvertColorsOff, mdiPalette } from "@mdi/js";
import { html, LitElement } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property, query } 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-generic-picker"; import "./ha-list-item";
import type { PickerComboBoxItem } from "./ha-picker-combo-box"; import "./ha-md-divider";
import type { PickerValueRenderer } from "./ha-picker-field"; import "./ha-select";
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" })
@@ -31,178 +33,137 @@ export class HaColorPicker extends LitElement {
@property({ type: Boolean }) public disabled = false; @property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false; @query("ha-select") private _select?: HaSelect;
render() { connectedCallback(): void {
const effectiveValue = this.value ?? this.defaultColor ?? ""; super.connectedCallback();
// Refresh layout options when the field is connected to the DOM to ensure current value displayed
return html` this._select?.layoutOptions();
<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 _getItems = () => private _valueSelected(ev) {
this._getColors( ev.stopPropagation();
this.includeNone, if (!this.isConnected) return;
this.includeState, const value = ev.target.value;
this.defaultColor, this.value = value === this.defaultColor ? undefined : value;
this.value fireEvent(this, "value-changed", {
); 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,
});
}
if (includeState) {
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,
});
}
Array.from(THEME_COLORS).forEach((color) => {
const themeLabel =
this.hass.localize(
`ui.components.color-picker.colors.${color}` as LocalizeKeys
) || color;
items.push({
id: color,
primary: addDefaultSuffix(themeLabel, defaultColor === color),
sorting_label: themeLabel,
});
}); });
}
const isSpecial = render() {
currentValue === "none" || const value = this.value || this.defaultColor || "";
currentValue === "state" ||
THEME_COLORS.has(currentValue || "");
const hasValue = currentValue && currentValue.length > 0; const isCustom = !(
THEME_COLORS.has(value) ||
if (hasValue && !isSpecial) { value === "none" ||
items.push({ value === "state"
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` return html`
<span slot="start">${this._renderColorCircle(value)}</span> <ha-select
<span slot="headline"> .icon=${Boolean(value)}
${this.hass.localize( .label=${this.label}
`ui.components.color-picker.colors.${value}` as LocalizeKeys .value=${value}
) || value} .helper=${this.helper}
</span> .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
) || color}
${this.defaultColor === color
? ` (${this.hass.localize("ui.components.color-picker.default")})`
: nothing}
<span slot="graphic">${this._renderColorCircle(color)}</span>
</ha-list-item>
`
)}
${isCustom
? html`
<ha-list-item .value=${value} graphic="icon">
${value}
<span slot="graphic">${this._renderColorCircle(value)}</span>
</ha-list-item>
`
: nothing}
</ha-select>
`; `;
}; }
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>
`; `;
} }
private _valueChanged(ev: CustomEvent<{ value?: string }>) { static styles = css`
ev.stopPropagation(); .circle-color {
const selected = ev.detail.value; display: block;
const normalized = background-color: var(--circle-color, var(--divider-color));
selected && selected === this.defaultColor border: 1px solid var(--outline-color);
? undefined border-radius: var(--ha-border-radius-pill);
: (selected ?? undefined); width: 20px;
this.value = normalized; height: 20px;
fireEvent(this, "value-changed", { value: this.value }); box-sizing: border-box;
} }
ha-select {
width: 100%;
}
`;
} }
declare global { declare global {

View File

@@ -20,17 +20,6 @@ export class HaComboBoxItem extends HaMdListItem {
[slot="start"] { [slot="start"] {
--state-icon-color: var(--secondary-text-color); --state-icon-color: var(--secondary-text-color);
} }
[slot="overline"] {
/* mimicing a floating label of mdc-select */
line-height: 1.15rem;
font-size: calc(var(--mdc-typography-subtitle1-font-size, 1rem) * 0.75);
font-weight: var(--mdc-typography-subtitle1-font-weight, 400);
font-family: var(
--mdc-typography-subtitle1-font-family,
var(--mdc-typography-font-family)
);
color: var(--mdc-select-label-ink-color, rgba(0, 0, 0, 0.6));
}
[slot="headline"] { [slot="headline"] {
line-height: var(--ha-line-height-normal); line-height: var(--ha-line-height-normal);
font-size: var(--ha-font-size-m); font-size: var(--ha-font-size-m);

View File

@@ -0,0 +1,433 @@
import { mdiClose, mdiMenuDown, mdiMenuUp } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import { comboBoxRenderer } from "@vaadin/combo-box/lit";
import "@vaadin/combo-box/theme/material/vaadin-combo-box-light";
import type {
ComboBoxDataProvider,
ComboBoxLight,
ComboBoxLightFilterChangedEvent,
ComboBoxLightOpenedChangedEvent,
ComboBoxLightValueChangedEvent,
} from "@vaadin/combo-box/vaadin-combo-box-light";
import { registerStyles } from "@vaadin/vaadin-themable-mixin/register-styles";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event";
import type { HomeAssistant } from "../types";
import "./ha-combo-box-item";
import "./ha-combo-box-textfield";
import "./ha-icon-button";
import "./ha-input-helper-text";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
registerStyles(
"vaadin-combo-box-item",
css`
:host {
padding: 0 !important;
}
:host([focused]:not([disabled])) {
background-color: rgba(var(--rgb-primary-text-color, 0, 0, 0), 0.12);
}
:host([selected]:not([disabled])) {
background-color: transparent;
color: var(--mdc-theme-primary);
--mdc-ripple-color: var(--mdc-theme-primary);
--mdc-theme-text-primary-on-background: var(--mdc-theme-primary);
}
:host([selected]:not([disabled])):before {
background-color: var(--mdc-theme-primary);
opacity: 0.12;
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
:host([selected][focused]:not([disabled])):before {
opacity: 0.24;
}
:host(:hover:not([disabled])) {
background-color: transparent;
}
[part="content"] {
width: 100%;
}
[part="checkmark"] {
display: none;
}
`
);
@customElement("ha-combo-box")
export class HaComboBox extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property() public label?: string;
@property() public value?: string;
@property() public placeholder?: string;
@property({ attribute: false }) public validationMessage?: string;
@property() public helper?: string;
@property({ attribute: "error-message" }) public errorMessage?: string;
@property({ type: Boolean }) public invalid = false;
@property({ type: Boolean }) public icon = false;
@property({ attribute: false }) public items?: any[];
@property({ attribute: false }) public filteredItems?: any[];
@property({ attribute: false })
public dataProvider?: ComboBoxDataProvider<any>;
@property({ attribute: "allow-custom-value", type: Boolean })
public allowCustomValue = false;
@property({ attribute: "item-value-path" }) public itemValuePath = "value";
@property({ attribute: "item-label-path" }) public itemLabelPath = "label";
@property({ attribute: "item-id-path" }) public itemIdPath?: string;
@property({ attribute: false }) public renderer?: ComboBoxLitRenderer<any>;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@property({ type: Boolean, reflect: true }) public opened = false;
@property({ type: Boolean, attribute: "hide-clear-icon" })
public hideClearIcon = false;
@property({ type: Boolean, attribute: "clear-initial-value" })
public clearInitialValue = false;
@query("vaadin-combo-box-light", true) private _comboBox!: ComboBoxLight;
@query("ha-combo-box-textfield", true) private _inputElement!: HaTextField;
@state({ type: Boolean }) private _forceBlankValue = false;
private _overlayMutationObserver?: MutationObserver;
private _bodyMutationObserver?: MutationObserver;
public async open() {
await this.updateComplete;
this._comboBox?.open();
}
public async focus() {
await this.updateComplete;
await this._inputElement?.updateComplete;
this._inputElement?.focus();
}
public disconnectedCallback() {
super.disconnectedCallback();
if (this._overlayMutationObserver) {
this._overlayMutationObserver.disconnect();
this._overlayMutationObserver = undefined;
}
if (this._bodyMutationObserver) {
this._bodyMutationObserver.disconnect();
this._bodyMutationObserver = undefined;
}
}
public get selectedItem() {
return this._comboBox.selectedItem;
}
public setInputValue(value: string) {
this._comboBox.value = value;
}
public setTextFieldValue(value: string) {
this._inputElement.value = value;
}
protected render(): TemplateResult {
return html`
<!-- @ts-ignore Tag definition is not included in theme folder -->
<vaadin-combo-box-light
.itemValuePath=${this.itemValuePath}
.itemIdPath=${this.itemIdPath}
.itemLabelPath=${this.itemLabelPath}
.items=${this.items}
.value=${this.value || ""}
.filteredItems=${this.filteredItems}
.dataProvider=${this.dataProvider}
.allowCustomValue=${this.allowCustomValue}
.disabled=${this.disabled}
.required=${this.required}
${comboBoxRenderer(this.renderer || this._defaultRowRenderer)}
@opened-changed=${this._openedChanged}
@filter-changed=${this._filterChanged}
@value-changed=${this._valueChanged}
attr-for-value="value"
>
<ha-combo-box-textfield
label=${ifDefined(this.label)}
placeholder=${ifDefined(this.placeholder)}
?disabled=${this.disabled}
?required=${this.required}
validationMessage=${ifDefined(this.validationMessage)}
.errorMessage=${this.errorMessage}
class="input"
autocapitalize="none"
autocomplete="off"
.autocorrect=${false}
input-spellcheck="false"
.suffix=${html`<div
style="width: 28px;"
role="none presentation"
></div>`}
.icon=${this.icon}
.invalid=${this.invalid}
.forceBlankValue=${this._forceBlankValue}
>
<slot name="icon" slot="leadingIcon"></slot>
</ha-combo-box-textfield>
${this.value && !this.hideClearIcon
? html`<ha-svg-icon
role="button"
tabindex="-1"
aria-label=${ifDefined(this.hass?.localize("ui.common.clear"))}
class=${`clear-button ${this.label ? "" : "no-label"}`}
.path=${mdiClose}
?disabled=${this.disabled}
@click=${this._clearValue}
></ha-svg-icon>`
: ""}
<ha-svg-icon
role="button"
tabindex="-1"
aria-label=${ifDefined(this.label)}
aria-expanded=${this.opened ? "true" : "false"}
class=${`toggle-button ${this.label ? "" : "no-label"}`}
.path=${this.opened ? mdiMenuUp : mdiMenuDown}
?disabled=${this.disabled}
@click=${this._toggleOpen}
></ha-svg-icon>
</vaadin-combo-box-light>
${this._renderHelper()}
`;
}
private _renderHelper() {
return this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
: "";
}
private _defaultRowRenderer: ComboBoxLitRenderer<
string | Record<string, any>
> = (item) => html`
<ha-combo-box-item type="button">
${this.itemLabelPath ? item[this.itemLabelPath] : item}
</ha-combo-box-item>
`;
private _clearValue(ev: Event) {
ev.stopPropagation();
fireEvent(this, "value-changed", { value: undefined });
}
private _toggleOpen(ev: Event) {
if (this.opened) {
this._comboBox?.close();
ev.stopPropagation();
} else {
this._comboBox?.inputElement.focus();
}
}
private _openedChanged(ev: ComboBoxLightOpenedChangedEvent) {
ev.stopPropagation();
const opened = ev.detail.value;
// delay this so we can handle click event for toggle button before setting _opened
setTimeout(() => {
this.opened = opened;
fireEvent(this, "opened-changed", { value: ev.detail.value });
}, 0);
if (this.clearInitialValue) {
this.setTextFieldValue("");
if (opened) {
// Wait 100ms to be sure vaddin-combo-box-light already tried to set the value
setTimeout(() => {
this._forceBlankValue = false;
}, 100);
} else {
this._forceBlankValue = true;
}
}
if (opened) {
const overlay = document.querySelector<HTMLElement>(
"vaadin-combo-box-overlay"
);
if (overlay) {
this._removeInert(overlay);
}
this._observeBody();
} else {
this._bodyMutationObserver?.disconnect();
this._bodyMutationObserver = undefined;
}
}
private _observeBody() {
if ("MutationObserver" in window && !this._bodyMutationObserver) {
this._bodyMutationObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeName === "VAADIN-COMBO-BOX-OVERLAY") {
this._removeInert(node as HTMLElement);
}
});
mutation.removedNodes.forEach((node) => {
if (node.nodeName === "VAADIN-COMBO-BOX-OVERLAY") {
this._overlayMutationObserver?.disconnect();
this._overlayMutationObserver = undefined;
}
});
});
});
this._bodyMutationObserver.observe(document.body, {
childList: true,
});
}
}
private _removeInert(overlay: HTMLElement) {
if (overlay.inert) {
overlay.inert = false;
this._overlayMutationObserver?.disconnect();
this._overlayMutationObserver = undefined;
return;
}
if ("MutationObserver" in window && !this._overlayMutationObserver) {
this._overlayMutationObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === "inert") {
const target = mutation.target as HTMLElement;
if (target.inert) {
this._overlayMutationObserver?.disconnect();
this._overlayMutationObserver = undefined;
target.inert = false;
}
}
});
});
this._overlayMutationObserver.observe(overlay, {
attributes: true,
});
}
}
private _filterChanged(ev: ComboBoxLightFilterChangedEvent) {
ev.stopPropagation();
fireEvent(this, "filter-changed", { value: ev.detail.value });
}
private _valueChanged(ev: ComboBoxLightValueChangedEvent) {
ev.stopPropagation();
if (!this.allowCustomValue) {
// @ts-ignore
this._comboBox._closeOnBlurIsPrevented = true;
}
if (!this.opened) {
return;
}
const newValue = ev.detail.value;
if (newValue !== this.value) {
fireEvent(this, "value-changed", { value: newValue || undefined });
}
}
static styles = css`
:host {
display: block;
width: 100%;
}
vaadin-combo-box-light {
position: relative;
}
ha-combo-box-textfield {
width: 100%;
}
ha-combo-box-textfield > ha-icon-button {
--mdc-icon-button-size: 24px;
padding: 2px;
color: var(--secondary-text-color);
}
ha-svg-icon {
color: var(--input-dropdown-icon-color);
position: absolute;
cursor: pointer;
}
.toggle-button {
right: 12px;
top: -10px;
inset-inline-start: initial;
inset-inline-end: 12px;
direction: var(--direction);
}
:host([opened]) .toggle-button {
color: var(--primary-color);
}
.toggle-button[disabled],
.clear-button[disabled] {
color: var(--disabled-text-color);
pointer-events: none;
}
.toggle-button.no-label {
top: -3px;
}
.clear-button {
--mdc-icon-size: 20px;
top: -7px;
right: 36px;
inset-inline-start: initial;
inset-inline-end: 36px;
direction: var(--direction);
}
.clear-button.no-label {
top: 0;
}
ha-input-helper-text {
margin-top: 4px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-combo-box": HaComboBox;
}
}
declare global {
interface HASSDomEvents {
"filter-changed": { value: string };
"opened-changed": { value: boolean };
}
}

View File

@@ -1,22 +1,20 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize"; import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
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 { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { ConfigEntry } from "../data/config_entries"; import type { ConfigEntry } from "../data/config_entries";
import { getConfigEntries } from "../data/config_entries"; import { getConfigEntries } from "../data/config_entries";
import { domainToName } from "../data/integration"; import { domainToName } from "../data/integration";
import type { HomeAssistant, ValueChangedEvent } from "../types"; import type { ValueChangedEvent, HomeAssistant } from "../types";
import { brandsUrl } from "../util/brands-url";
import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box";
import "./ha-combo-box-item"; import "./ha-combo-box-item";
import "./ha-domain-icon";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
const SEARCH_KEYS = [ export interface ConfigEntryExtended extends ConfigEntry {
{ name: "primary", weight: 10 }, localized_domain_name?: string;
{ name: "secondary", weight: 8 }, }
{ name: "icon", weight: 5 },
];
@customElement("ha-config-entry-picker") @customElement("ha-config-entry-picker")
class HaConfigEntryPicker extends LitElement { class HaConfigEntryPicker extends LitElement {
@@ -30,106 +28,119 @@ class HaConfigEntryPicker extends LitElement {
@property() public helper?: string; @property() public helper?: string;
@state() private _configEntries?: PickerComboBoxItem[]; @state() private _configEntries?: ConfigEntryExtended[];
@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-generic-picker") private _picker!: HaGenericPicker; @query("ha-combo-box") private _comboBox!: HaComboBox;
public open() { public open() {
this._picker?.open(); this._comboBox?.open();
} }
public focus() { public focus() {
this._picker?.focus(); this._comboBox?.focus();
} }
protected firstUpdated() { protected firstUpdated() {
this._getConfigEntries(); this._getConfigEntries();
} }
private _rowRenderer: ComboBoxLitRenderer<ConfigEntryExtended> = (
item
) => html`
<ha-combo-box-item type="button">
<span slot="headline">
${item.title ||
this.hass.localize(
"ui.panel.config.integrations.config_entry.unnamed_entry"
)}
</span>
<span slot="supporting-text">${item.localized_domain_name}</span>
<img
alt=""
slot="start"
src=${brandsUrl({
domain: item.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
@error=${this._onImageError}
@load=${this._onImageLoad}
/>
</ha-combo-box-item>
`;
protected render() { protected render() {
if (!this._configEntries) { if (!this._configEntries) {
return nothing; return nothing;
} }
return html` return html`
<ha-generic-picker <ha-combo-box
.hass=${this.hass} .hass=${this.hass}
.label=${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}
.value=${this.value} .value=${this._value}
.required=${this.required} .required=${this.required}
.disabled=${this.disabled} .disabled=${this.disabled}
.helper=${this.helper} .helper=${this.helper}
.rowRenderer=${this._rowRenderer} .renderer=${this._rowRenderer}
.getItems=${this._getItems} .items=${this._configEntries}
.searchKeys=${SEARCH_KEYS} item-value-path="entry_id"
.valueRenderer=${this._valueRenderer} item-id-path="entry_id"
item-label-path="title"
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
></ha-generic-picker> ></ha-combo-box>
`; `;
} }
private _rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html` private _onImageLoad(ev) {
<ha-combo-box-item type="button"> ev.target.style.visibility = "initial";
<span slot="headline">${item.primary}</span> }
<span slot="supporting-text">${item.secondary}</span>
<ha-domain-icon private _onImageError(ev) {
slot="start" ev.target.style.visibility = "hidden";
.hass=${this.hass} }
.domain=${item.icon!}
brand-fallback
></ha-domain-icon>
</ha-combo-box-item>
`;
private async _getConfigEntries() { private async _getConfigEntries() {
getConfigEntries(this.hass, { getConfigEntries(this.hass, {
type: ["device", "hub", "service"], type: ["device", "hub", "service"],
domain: this.integration, domain: this.integration,
}).then((configEntries) => { }).then((configEntries) => {
this._configEntries = configEntries.map((entry: ConfigEntry) => { this._configEntries = configEntries
const domainName = domainToName(this.hass.localize, entry.domain); .map(
return { (entry: ConfigEntry): ConfigEntryExtended => ({
id: entry.entry_id, ...entry,
icon: entry.domain, localized_domain_name: domainToName(
primary: this.hass.localize,
entry.title || entry.domain
this.hass.localize(
"ui.panel.config.integrations.config_entry.unnamed_entry"
), ),
secondary: domainName, })
sorting_label: [entry.title, domainName].filter(Boolean).join("_"), )
}; .sort((conf1, conf2) =>
}); caseInsensitiveStringCompare(
conf1.localized_domain_name + conf1.title,
conf2.localized_domain_name + conf2.title,
this.hass.locale.language
)
);
}); });
} }
private _valueRenderer = (itemId: string) => { private get _value() {
const item = this._configEntries!.find((entry) => entry.id === itemId); return this.value || "";
return html`<span }
style="display: flex; align-items: center; gap: var(--ha-space-2);"
slot="headline"
>${item?.icon
? html`<ha-domain-icon
.hass=${this.hass}
.domain=${item.icon!}
brand-fallback
></ha-domain-icon>`
: nothing}${item?.primary || "Unknown"}</span
>`;
};
private _getItems = () => this._configEntries!;
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);
} }
} }

View File

@@ -9,14 +9,14 @@ import type { ConfigEntry, SubEntry } from "../data/config_entries";
import { getConfigEntry, getSubEntries } from "../data/config_entries"; import { getConfigEntry, getSubEntries } from "../data/config_entries";
import type { Agent } from "../data/conversation"; import type { Agent } from "../data/conversation";
import { listAgents } from "../data/conversation"; import { listAgents } from "../data/conversation";
import { getExtendedEntityRegistryEntry } from "../data/entity/entity_registry";
import { fetchIntegrationManifest } from "../data/integration"; import { fetchIntegrationManifest } from "../data/integration";
import { showOptionsFlowDialog } from "../dialogs/config-flow/show-dialog-options-flow"; import { showOptionsFlowDialog } from "../dialogs/config-flow/show-dialog-options-flow";
import { showSubConfigFlowDialog } from "../dialogs/config-flow/show-dialog-sub-config-flow";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import "./ha-list-item"; import "./ha-list-item";
import "./ha-select"; import "./ha-select";
import type { HaSelect } from "./ha-select"; import type { HaSelect } from "./ha-select";
import { getExtendedEntityRegistryEntry } from "../data/entity_registry";
import { showSubConfigFlowDialog } from "../dialogs/config-flow/show-dialog-sub-config-flow";
const NONE = "__NONE_OPTION__"; const NONE = "__NONE_OPTION__";

View File

@@ -101,13 +101,14 @@ export class HaDialog extends DialogBase {
} }
.mdc-dialog__container { .mdc-dialog__container {
align-items: var(--vertical-align-dialog, center); align-items: var(--vertical-align-dialog, center);
padding: var(--dialog-container-padding, 0); padding: var(--dialog-container-padding, var(--ha-space-0));
} }
.mdc-dialog__title { .mdc-dialog__title {
padding: var(--ha-space-4) var(--ha-space-4) 0 var(--ha-space-4); padding: var(--ha-space-4) var(--ha-space-4) var(--ha-space-0)
var(--ha-space-4);
} }
.mdc-dialog__title:has(span) { .mdc-dialog__title:has(span) {
padding: var(--ha-space-3) var(--ha-space-3) 0; padding: var(--ha-space-3) var(--ha-space-3) var(--ha-space-0);
} }
.mdc-dialog__title::before { .mdc-dialog__title::before {
content: unset; content: unset;
@@ -135,7 +136,7 @@ export class HaDialog extends DialogBase {
--ha-dialog-surface-background, --ha-dialog-surface-background,
var(--mdc-theme-surface, #fff) var(--mdc-theme-surface, #fff)
); );
padding: var(--dialog-surface-padding, 0); padding: var(--dialog-surface-padding, var(--ha-space-0));
} }
:host([flexContent]) .mdc-dialog .mdc-dialog__content { :host([flexContent]) .mdc-dialog .mdc-dialog__content {
display: flex; display: flex;
@@ -167,7 +168,7 @@ export class HaDialog extends DialogBase {
} }
.dialog-actions { .dialog-actions {
inset-inline-start: initial !important; inset-inline-start: initial !important;
inset-inline-end: 0 !important; inset-inline-end: var(--ha-space-0) !important;
direction: var(--direction); direction: var(--direction);
} }
`, `,

View File

@@ -47,7 +47,6 @@ export class HaDomainIcon extends LitElement {
if (icn) { if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`; return html`<ha-icon .icon=${icn}></ha-icon>`;
} }
return this._renderFallback(); return this._renderFallback();
}); });

View File

@@ -1,9 +1,6 @@
import DropdownItem from "@home-assistant/webawesome/dist/components/dropdown-item/dropdown-item"; import DropdownItem from "@home-assistant/webawesome/dist/components/dropdown-item/dropdown-item";
import "@home-assistant/webawesome/dist/components/icon/icon"; import { css, type CSSResultGroup } from "lit";
import { css, type CSSResultGroup, html } from "lit";
import { customElement } from "lit/decorators"; import { customElement } from "lit/decorators";
import "./ha-svg-icon";
import { mdiCheckboxBlankOutline, mdiCheckboxMarked } from "@mdi/js";
/** /**
* Home Assistant dropdown item component * Home Assistant dropdown item component
@@ -17,16 +14,6 @@ import { mdiCheckboxBlankOutline, mdiCheckboxMarked } from "@mdi/js";
*/ */
@customElement("ha-dropdown-item") @customElement("ha-dropdown-item")
export class HaDropdownItem extends DropdownItem { export class HaDropdownItem extends DropdownItem {
protected renderCheckboxIcon() {
return html`
<ha-svg-icon
id="check"
part="checkmark"
.path=${this.checked ? mdiCheckboxMarked : mdiCheckboxBlankOutline}
></ha-svg-icon>
`;
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
DropdownItem.styles, DropdownItem.styles,
@@ -35,10 +22,6 @@ export class HaDropdownItem extends DropdownItem {
min-height: var(--ha-space-10); min-height: var(--ha-space-10);
} }
#check {
visibility: visible;
}
#icon ::slotted(*) { #icon ::slotted(*) {
color: var(--ha-color-on-neutral-normal); color: var(--ha-color-on-neutral-normal);
} }

View File

@@ -10,8 +10,8 @@ import { computeCssColor } from "../common/color/compute-color";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { navigate } from "../common/navigate"; import { navigate } from "../common/navigate";
import { stringCompare } from "../common/string/compare"; import { stringCompare } from "../common/string/compare";
import type { LabelRegistryEntry } from "../data/label/label_registry"; import type { LabelRegistryEntry } from "../data/label_registry";
import { subscribeLabelRegistry } from "../data/label/label_registry"; import { subscribeLabelRegistry } from "../data/label_registry";
import { SubscribeMixin } from "../mixins/subscribe-mixin"; import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { haStyleScrollbar } from "../resources/styles"; import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";

View File

@@ -1,5 +1,5 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiPlus, mdiTextureBox } from "@mdi/js"; import { mdiPlus, mdiTextureBox } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { HassEntity } from "home-assistant-js-websocket"; import type { HassEntity } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import { LitElement, html } from "lit"; import { LitElement, html } from "lit";
@@ -12,9 +12,9 @@ import { updateAreaRegistryEntry } from "../data/area_registry";
import type { import type {
DeviceEntityDisplayLookup, DeviceEntityDisplayLookup,
DeviceRegistryEntry, DeviceRegistryEntry,
} from "../data/device/device_registry"; } from "../data/device_registry";
import { getDeviceEntityDisplayLookup } from "../data/device/device_registry"; import { getDeviceEntityDisplayLookup } from "../data/device_registry";
import type { EntityRegistryDisplayEntry } from "../data/entity/entity_registry"; import type { EntityRegistryDisplayEntry } from "../data/entity_registry";
import { import {
createFloorRegistryEntry, createFloorRegistryEntry,
getFloorAreaLookup, getFloorAreaLookup,
@@ -35,12 +35,6 @@ import "./ha-svg-icon";
const ADD_NEW_ID = "___ADD_NEW___"; const ADD_NEW_ID = "___ADD_NEW___";
const SEARCH_KEYS = [
{ name: "search_labels.floorName", weight: 10 },
{ name: "search_labels.aliases", weight: 8 },
{ name: "search_labels.floor_id", weight: 3 },
];
interface FloorComboBoxItem extends PickerComboBoxItem { interface FloorComboBoxItem extends PickerComboBoxItem {
floor?: FloorRegistryEntry; floor?: FloorRegistryEntry;
} }
@@ -291,11 +285,10 @@ export class HaFloorPicker extends LitElement {
id: floor.floor_id, id: floor.floor_id,
primary: floorName, primary: floorName,
floor: floor, floor: floor,
search_labels: { sorting_label: floor.level?.toString() || "zzzzz",
floorName, search_labels: [floorName, floor.floor_id, ...floor.aliases].filter(
floor_id: floor.floor_id, (v): v is string => Boolean(v)
aliases: floor.aliases.join(" "), ),
},
}; };
}); });
@@ -303,7 +296,7 @@ export class HaFloorPicker extends LitElement {
} }
); );
private _rowRenderer: RenderItemFunction<FloorComboBoxItem> = (item) => html` private _rowRenderer: ComboBoxLitRenderer<FloorComboBoxItem> = (item) => html`
<ha-combo-box-item type="button" compact> <ha-combo-box-item type="button" compact>
${item.icon_path ${item.icon_path
? html` ? html`
@@ -389,23 +382,17 @@ 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}
.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}
.valueRenderer=${valueRenderer} .valueRenderer=${valueRenderer}
.rowRenderer=${this._rowRenderer} .rowRenderer=${this._rowRenderer}
.searchKeys=${SEARCH_KEYS}
.unknownItemText=${this.hass.localize(
"ui.components.floor-picker.unknown"
)}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
> >
</ha-generic-picker> </ha-generic-picker>

View File

@@ -4,13 +4,9 @@ import { mdiPlaylistPlus } from "@mdi/js";
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit"; import { css, html, LitElement, nothing, type CSSResultGroup } 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 { ifDefined } from "lit/directives/if-defined";
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 { 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";
@@ -22,21 +18,36 @@ 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 PickerMixin(LitElement) { export class HaGenericPicker extends 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 label?: string;
@property() public value?: 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;
/** To prevent lags, getItems needs to be memoized */ @property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@property({ attribute: false }) @property({ attribute: false })
public getItems!: ( public getItems?: (
searchString?: string, searchString?: string,
section?: string section?: string
) => (PickerComboBoxItem | string)[]; ) => (PickerComboBoxItem | string)[];
@@ -48,10 +59,10 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
public rowRenderer?: RenderItemFunction<PickerComboBoxItem>; public rowRenderer?: RenderItemFunction<PickerComboBoxItem>;
@property({ attribute: false }) @property({ attribute: false })
public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>; public valueRenderer?: PickerValueRenderer;
@property({ attribute: false }) @property({ attribute: false })
public searchKeys?: FuseWeightedKey[]; public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
@property({ attribute: false }) @property({ attribute: false })
public notFoundLabel?: string | ((search: string) => string); public notFoundLabel?: string | ((search: string) => string);
@@ -96,12 +107,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@property({ attribute: "selected-section" }) public selectedSection?: string; @property({ attribute: "selected-section" }) public selectedSection?: 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;
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox; @query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@@ -124,20 +129,14 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
// helper to set new value after closing picker, to avoid flicker // helper to set new value after closing picker, to avoid flicker
private _newValue?: string; private _newValue?: string;
@property({ attribute: "error-message" }) public errorMessage?: string;
@property({ type: Boolean, reflect: true }) public invalid = false;
private _unsubscribeTinyKeys?: () => void; private _unsubscribeTinyKeys?: () => void;
protected render() { protected render() {
// Only show label if it's not a top label and there is a value. return html`
const label = this.useTopLabel && this.value ? undefined : this.label; ${this.label
? html`<label ?disabled=${this.disabled}>${this.label}</label>`
return html`<div class="container"> : nothing}
${this.useTopLabel && this.label <div class="container">
? html`<label ?disabled=${this.disabled}>${this.label}</label>`
: nothing}
<div id="picker"> <div id="picker">
<slot name="field"> <slot name="field">
${this.addButtonLabel && !this.value ${this.addButtonLabel && !this.value
@@ -157,28 +156,16 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
type="button" type="button"
class=${this._opened ? "opened" : ""} class=${this._opened ? "opened" : ""}
compact compact
.unknown=${this._unknownValue(
this.allowCustomValue,
this.value,
this.getItems()
)}
.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}
.image=${this.image}
.label=${label}
.placeholder=${this.placeholder} .placeholder=${this.placeholder}
.helper=${this.helper}
.value=${this.value} .value=${this.value}
.valueRenderer=${this.valueRenderer}
.required=${this.required} .required=${this.required}
.disabled=${this.disabled} .disabled=${this.disabled}
.invalid=${this.invalid}
.hideClearIcon=${this.hideClearIcon} .hideClearIcon=${this.hideClearIcon}
.valueRenderer=${this.valueRenderer}
> >
<slot name="start"></slot>
</ha-picker-field>`} </ha-picker-field>`}
</slot> </slot>
</div> </div>
@@ -217,7 +204,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
</ha-bottom-sheet>` </ha-bottom-sheet>`
: nothing} : nothing}
</div> </div>
${this._renderHelper()}`; ${this._renderHelper()}
`;
} }
private _renderComboBox(dialogMode = false) { private _renderComboBox(dialogMode = false) {
@@ -226,7 +214,6 @@ export class HaGenericPicker extends PickerMixin(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}
@@ -242,59 +229,21 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
.sections=${this.sections} .sections=${this.sections}
.sectionTitleFunction=${this.sectionTitleFunction} .sectionTitleFunction=${this.sectionTitleFunction}
.selectedSection=${this.selectedSection} .selectedSection=${this.selectedSection}
.searchKeys=${this.searchKeys}
.customValueLabel=${this.customValueLabel}
></ha-picker-combo-box> ></ha-picker-combo-box>
`; `;
} }
private _unknownValue = memoizeOne(
(
allowCustomValue: boolean,
value?: string,
items?: (PickerComboBoxItem | string)[]
) => {
if (
allowCustomValue ||
value === undefined ||
value === null ||
value === "" ||
!items
) {
return false;
}
return !items.some(
(item) => typeof item !== "string" && item.id === value
);
}
);
private _renderHelper() { private _renderHelper() {
const showError = this.invalid && this.errorMessage; return this.helper
const showHelper = !showError && this.helper; ? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
if (!showError && !showHelper) { >`
return nothing; : nothing;
}
return html`<ha-input-helper-text .disabled=${this.disabled}>
${showError ? this.errorMessage : this.helper}
</ha-input-helper-text>`;
} }
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();
}); });
}; };
@@ -321,7 +270,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
this._newValue = value; this._newValue = value;
} }
private _clear(e: CustomEvent) { private _clear(e) {
e.stopPropagation(); e.stopPropagation();
this._setValue(undefined); this._setValue(undefined);
} }
@@ -388,20 +337,14 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
display: block; display: block;
margin: var(--ha-space-2) 0 0; margin: var(--ha-space-2) 0 0;
} }
:host([invalid]) ha-input-helper-text {
color: var(--mdc-theme-error, var(--error-color, #b00020));
}
wa-popover { wa-popover {
--wa-space-l: 0; --wa-space-l: var(--ha-space-0);
} }
wa-popover::part(body) { wa-popover::part(body) {
width: max(var(--body-width), 250px); width: max(var(--body-width), 250px);
max-width: var( max-width: max(var(--body-width), 250px);
--ha-generic-picker-max-width,
max(var(--body-width), 250px)
);
max-height: 500px; max-height: 500px;
height: 70vh; height: 70vh;
overflow: hidden; overflow: hidden;
@@ -413,12 +356,18 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
} }
} }
@media (max-height: 1000px) {
wa-popover::part(body) {
max-height: 400px;
}
}
ha-bottom-sheet { ha-bottom-sheet {
--ha-bottom-sheet-height: 90vh; --ha-bottom-sheet-height: 90vh;
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12)); --ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
--ha-bottom-sheet-max-height: var(--ha-bottom-sheet-height); --ha-bottom-sheet-max-height: var(--ha-bottom-sheet-height);
--ha-bottom-sheet-max-width: 600px; --ha-bottom-sheet-max-width: 600px;
--ha-bottom-sheet-padding: 0; --ha-bottom-sheet-padding: var(--ha-space-0);
--ha-bottom-sheet-surface-background: var(--card-background-color); --ha-bottom-sheet-surface-background: var(--card-background-color);
--ha-bottom-sheet-border-radius: var(--ha-border-radius-2xl); --ha-bottom-sheet-border-radius: var(--ha-border-radius-2xl);
} }

View File

@@ -1,7 +1,7 @@
import type { TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit"; import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { isUnavailableState, OFF } from "../data/entity/entity"; import { isUnavailableState, OFF } from "../data/entity";
import type { HumidifierEntity } from "../data/humidifier"; import type { HumidifierEntity } from "../data/humidifier";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";

View File

@@ -1,4 +1,8 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize"; import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type {
ComboBoxDataProviderCallback,
ComboBoxDataProviderParams,
} from "@vaadin/combo-box/vaadin-combo-box-light";
import type { TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import { LitElement, css, html } from "lit"; import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
@@ -6,54 +10,35 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { customIcons } from "../data/custom_icons"; import { customIcons } from "../data/custom_icons";
import type { HomeAssistant, ValueChangedEvent } from "../types"; import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./ha-combo-box-item"; import "./ha-combo-box";
import "./ha-generic-picker";
import "./ha-icon"; import "./ha-icon";
import type { PickerComboBoxItem } from "./ha-picker-combo-box"; import "./ha-combo-box-item";
interface IconItem {
icon: string;
parts: Set<string>;
keywords: string[];
}
interface RankedIcon { interface RankedIcon {
item: PickerComboBoxItem; icon: string;
rank: number; rank: number;
} }
let ICONS: PickerComboBoxItem[] = []; let ICONS: IconItem[] = [];
let ICONS_LOADED = false; let ICONS_LOADED = false;
interface IconData {
name: string;
keywords?: string[];
}
const createIconItem = (icon: IconData, prefix: string): PickerComboBoxItem => {
const iconId = `${prefix}:${icon.name}`;
const iconName = icon.name;
const parts = iconName.split("-");
const keywords = icon.keywords ?? [];
const searchLabels: Record<string, string> = {
iconName,
};
parts.forEach((part, index) => {
searchLabels[`part${index}`] = part;
});
keywords.forEach((keyword, index) => {
searchLabels[`keyword${index}`] = keyword;
});
return {
id: iconId,
primary: iconId,
icon: iconId,
search_labels: searchLabels,
sorting_label: iconId,
};
};
const loadIcons = async () => { const loadIcons = async () => {
ICONS_LOADED = true; ICONS_LOADED = true;
const iconList = await import("../../build/mdi/iconList.json"); const iconList = await import("../../build/mdi/iconList.json");
ICONS = iconList.default.map((icon) => createIconItem(icon, "mdi")); ICONS = iconList.default.map((icon) => ({
icon: `mdi:${icon.name}`,
parts: new Set(icon.name.split("-")),
keywords: icon.keywords,
}));
const customIconLoads: Promise<PickerComboBoxItem[]>[] = []; const customIconLoads: Promise<IconItem[]>[] = [];
Object.keys(customIcons).forEach((iconSet) => { Object.keys(customIcons).forEach((iconSet) => {
customIconLoads.push(loadCustomIconItems(iconSet)); customIconLoads.push(loadCustomIconItems(iconSet));
}); });
@@ -62,16 +47,19 @@ const loadIcons = async () => {
}); });
}; };
const loadCustomIconItems = async ( const loadCustomIconItems = async (iconsetPrefix: string) => {
iconsetPrefix: string
): Promise<PickerComboBoxItem[]> => {
try { try {
const getIconList = customIcons[iconsetPrefix].getIconList; const getIconList = customIcons[iconsetPrefix].getIconList;
if (typeof getIconList !== "function") { if (typeof getIconList !== "function") {
return []; return [];
} }
const iconList = await getIconList(); const iconList = await getIconList();
return iconList.map((icon) => createIconItem(icon, iconsetPrefix)); const customIconItems = iconList.map((icon) => ({
icon: `${iconsetPrefix}:${icon.name}`,
parts: new Set(icon.name.split("-")),
keywords: icon.keywords ?? [],
}));
return customIconItems;
} catch (_err) { } catch (_err) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn(`Unable to load icon list for ${iconsetPrefix} iconset`); console.warn(`Unable to load icon list for ${iconsetPrefix} iconset`);
@@ -79,10 +67,10 @@ const loadCustomIconItems = async (
} }
}; };
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html` const rowRenderer: ComboBoxLitRenderer<IconItem | RankedIcon> = (item) => html`
<ha-combo-box-item type="button"> <ha-combo-box-item type="button">
<ha-icon .icon=${item.id} slot="start"></ha-icon> <ha-icon .icon=${item.icon} slot="start"></ha-icon>
${item.id} ${item.icon}
</ha-combo-box-item> </ha-combo-box-item>
`; `;
@@ -106,99 +94,85 @@ export class HaIconPicker extends LitElement {
@property({ type: Boolean }) public invalid = false; @property({ type: Boolean }) public invalid = false;
private _getIconPickerItems = (): PickerComboBoxItem[] => ICONS;
protected render(): TemplateResult { protected render(): TemplateResult {
return html` return html`
<ha-generic-picker <ha-combo-box
.hass=${this.hass} .hass=${this.hass}
item-value-path="icon"
item-label-path="icon"
.value=${this._value}
allow-custom-value allow-custom-value
.getItems=${this._getIconPickerItems} .dataProvider=${ICONS_LOADED ? this._iconProvider : undefined}
.label=${this.label}
.helper=${this.helper} .helper=${this.helper}
.disabled=${this.disabled} .disabled=${this.disabled}
.required=${this.required} .required=${this.required}
.placeholder=${this.placeholder}
.errorMessage=${this.errorMessage} .errorMessage=${this.errorMessage}
.invalid=${this.invalid} .invalid=${this.invalid}
.rowRenderer=${rowRenderer} .renderer=${rowRenderer}
.icon=${this._icon} icon
.label=${this.label} @opened-changed=${this._openedChanged}
.value=${this._value}
.searchFn=${this._filterIcons}
.notFoundLabel=${this.hass?.localize(
"ui.components.icon-picker.no_match"
)}
popover-placement="bottom-start"
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
> >
<slot name="start"></slot> ${this._value || this.placeholder
</ha-generic-picker> ? html`
<ha-icon .icon=${this._value || this.placeholder} slot="icon">
</ha-icon>
`
: html`<slot slot="icon" name="fallback"></slot>`}
</ha-combo-box>
`; `;
} }
// Filter can take a significant chunk of frame (up to 3-5 ms) // Filter can take a significant chunk of frame (up to 3-5 ms)
private _filterIcons = memoizeOne( private _filterIcons = memoizeOne(
( (filter: string, iconItems: IconItem[] = ICONS) => {
filter: string, if (!filter) {
filteredItems: PickerComboBoxItem[],
allItems: PickerComboBoxItem[]
): PickerComboBoxItem[] => {
const normalizedFilter = filter.toLowerCase().replace(/\s+/g, "-");
const iconItems = allItems?.length ? allItems : filteredItems;
if (!normalizedFilter.length) {
return iconItems; return iconItems;
} }
const rankedItems: RankedIcon[] = []; const filteredItems: RankedIcon[] = [];
const addIcon = (icon: string, rank: number) =>
filteredItems.push({ icon, rank });
// Filter and rank such that exact matches rank higher, and prefer icon name matches over keywords // Filter and rank such that exact matches rank higher, and prefer icon name matches over keywords
for (const item of iconItems) { for (const item of iconItems) {
const iconName = (item.id.split(":")[1] || item.id).toLowerCase(); if (item.parts.has(filter)) {
const parts = iconName.split("-"); addIcon(item.icon, 1);
const keywords = item.search_labels } else if (item.keywords.includes(filter)) {
? Object.values(item.search_labels) addIcon(item.icon, 2);
.filter((v): v is string => v !== null) } else if (item.icon.includes(filter)) {
.map((v) => v.toLowerCase()) addIcon(item.icon, 3);
: []; } else if (item.keywords.some((word) => word.includes(filter))) {
const id = item.id.toLowerCase(); addIcon(item.icon, 4);
if (parts.includes(normalizedFilter)) {
rankedItems.push({ item, rank: 1 });
} else if (keywords.includes(normalizedFilter)) {
rankedItems.push({ item, rank: 2 });
} else if (id.includes(normalizedFilter)) {
rankedItems.push({ item, rank: 3 });
} else if (keywords.some((word) => word.includes(normalizedFilter))) {
rankedItems.push({ item, rank: 4 });
} }
} }
// Allow preview for custom icon not in list // Allow preview for custom icon not in list
if (rankedItems.length === 0) { if (filteredItems.length === 0) {
rankedItems.push({ addIcon(filter, 0);
item: {
id: filter,
primary: filter,
icon: filter,
search_labels: { keyword: filter },
sorting_label: filter,
},
rank: 0,
});
} }
return rankedItems return filteredItems.sort((itemA, itemB) => itemA.rank - itemB.rank);
.sort((itemA, itemB) => itemA.rank - itemB.rank)
.map((item) => item.item);
} }
); );
protected firstUpdated() { private _iconProvider = (
if (!ICONS_LOADED) { params: ComboBoxDataProviderParams,
loadIcons().then(() => { callback: ComboBoxDataProviderCallback<IconItem | RankedIcon>
this._getIconPickerItems = (): PickerComboBoxItem[] => ICONS; ) => {
this.requestUpdate(); const filteredItems = this._filterIcons(params.filter.toLowerCase(), ICONS);
}); const iStart = params.page * params.pageSize;
const iEnd = iStart + params.pageSize;
callback(filteredItems.slice(iStart, iEnd), filteredItems.length);
};
private async _openedChanged(ev: ValueChangedEvent<boolean>) {
const opened = ev.detail.value;
if (opened && !ICONS_LOADED) {
await loadIcons();
this.requestUpdate();
} }
} }
@@ -220,18 +194,20 @@ export class HaIconPicker extends LitElement {
); );
} }
private get _icon() {
return this.value?.length ? this.value : this.placeholder;
}
private get _value() { private get _value() {
return this.value || ""; return this.value || "";
} }
static styles = css` static styles = css`
ha-generic-picker { *[slot="icon"] {
width: 100%; color: var(--primary-text-color);
display: block; position: relative;
bottom: 2px;
}
*[slot="prefix"] {
margin-right: 8px;
margin-inline-end: 8px;
margin-inline-start: initial;
} }
`; `;
} }

View File

@@ -11,12 +11,12 @@ import {
} from "lit/decorators"; } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { getLabels, labelComboBoxKeys } from "../data/label/label_picker"; import type { LabelRegistryEntry } from "../data/label_registry";
import { import {
createLabelRegistryEntry, createLabelRegistryEntry,
getLabels,
subscribeLabelRegistry, subscribeLabelRegistry,
type LabelRegistryEntry, } from "../data/label_registry";
} from "../data/label/label_registry";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box"; import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { SubscribeMixin } from "../mixins/subscribe-mixin"; import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { showLabelDetailDialog } from "../panels/config/labels/show-dialog-label-detail"; import { showLabelDetailDialog } from "../panels/config/labels/show-dialog-label-detail";
@@ -227,7 +227,6 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
.hass=${this.hass} .hass=${this.hass}
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
.label=${this.label} .label=${this.label}
.helper=${this.helper}
.notFoundLabel=${this._notFoundLabel} .notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass.localize( .emptyLabel=${this.hass.localize(
"ui.components.label-picker.no_labels" "ui.components.label-picker.no_labels"
@@ -238,7 +237,6 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
.getItems=${this._getItems} .getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems} .getAdditionalItems=${this._getAdditionalItems}
.valueRenderer=${valueRenderer} .valueRenderer=${valueRenderer}
.searchKeys=${labelComboBoxKeys}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
> >
<slot .slot=${this._slotNodes?.length ? "field" : undefined}></slot> <slot .slot=${this._slotNodes?.length ? "field" : undefined}></slot>

View File

@@ -8,11 +8,11 @@ import memoizeOne from "memoize-one";
import { computeCssColor } from "../common/color/compute-color"; import { computeCssColor } from "../common/color/compute-color";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare"; import { stringCompare } from "../common/string/compare";
import type { LabelRegistryEntry } from "../data/label/label_registry"; import type { LabelRegistryEntry } from "../data/label_registry";
import { import {
subscribeLabelRegistry, subscribeLabelRegistry,
updateLabelRegistryEntry, updateLabelRegistryEntry,
} from "../data/label/label_registry"; } from "../data/label_registry";
import { SubscribeMixin } from "../mixins/subscribe-mixin"; import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { showLabelDetailDialog } from "../panels/config/labels/show-dialog-label-detail"; import { showLabelDetailDialog } from "../panels/config/labels/show-dialog-label-detail";
import type { HomeAssistant, ValueChangedEvent } from "../types"; import type { HomeAssistant, ValueChangedEvent } from "../types";
@@ -231,6 +231,7 @@ 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

@@ -40,12 +40,14 @@ export const getLanguageOptions = (
return { return {
id: lang, id: lang,
primary, primary,
search_labels: [primary],
}; };
}); });
} else if (locale) { } else if (locale) {
options = languages.map((lang) => ({ options = languages.map((lang) => ({
id: lang, id: lang,
primary: formatLanguageCode(lang, locale), primary: formatLanguageCode(lang, locale),
search_labels: [formatLanguageCode(lang, locale)],
})); }));
} }
@@ -116,11 +118,6 @@ 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);
@@ -134,7 +131,9 @@ 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"}
.label=${label} .placeholder=${this.label ??
(this.hass?.localize("ui.components.language-picker.language") ||
"Language")}
.value=${value} .value=${value}
.valueRenderer=${this._valueRenderer} .valueRenderer=${this._valueRenderer}
.disabled=${this.disabled} .disabled=${this.disabled}

View File

@@ -74,6 +74,9 @@ export class HaMarkdown extends LitElement {
background-color: var(--markdown-image-background-color); background-color: var(--markdown-image-background-color);
border-radius: var(--markdown-image-border-radius); border-radius: var(--markdown-image-border-radius);
max-width: 100%; max-width: 100%;
height: auto;
width: auto;
transition: height 0.2s ease-in-out;
} }
p:first-child > img:first-child { p:first-child > img:first-child {
vertical-align: top; vertical-align: top;
@@ -81,7 +84,8 @@ export class HaMarkdown extends LitElement {
p:first-child > img:last-child { p:first-child > img:last-child {
vertical-align: top; vertical-align: top;
} }
ha-markdown-element > :is(ol, ul) { :host > ul,
:host > ol {
padding-inline-start: var(--markdown-list-indent, revert); padding-inline-start: var(--markdown-list-indent, revert);
} }
li { li {
@@ -132,18 +136,6 @@ export class HaMarkdown extends LitElement {
border-bottom: none; border-bottom: none;
margin: var(--ha-space-4) 0; margin: var(--ha-space-4) 0;
} }
table[role="presentation"] {
--markdown-table-border-collapse: separate;
--markdown-table-border-width: attr(border, 0);
--markdown-table-padding-inline: 0;
--markdown-table-padding-block: 0;
th {
vertical-align: attr(align, center);
}
td {
vertical-align: attr(align, left);
}
}
table { table {
border-collapse: var(--markdown-table-border-collapse, collapse); border-collapse: var(--markdown-table-border-collapse, collapse);
} }
@@ -151,15 +143,14 @@ export class HaMarkdown extends LitElement {
overflow: auto; overflow: auto;
} }
th { th {
text-align: var(--markdown-table-text-align, start); text-align: start;
} }
td, td,
th { th {
border-width: var(--markdown-table-border-width, 1px); border-width: var(--markdown-table-border-width, 1px);
border-style: var(--markdown-table-border-style, solid); border-style: var(--markdown-table-border-style, solid);
border-color: var(--markdown-table-border-color, var(--divider-color)); border-color: var(--markdown-table-border-color, var(--divider-color));
padding-inline: var(--markdown-table-padding-inline, 0.5em); padding: 0.25em 0.5em;
padding-block: var(--markdown-table-padding-block, 0.25em);
} }
blockquote { blockquote {
border-left: 4px solid var(--divider-color); border-left: 4px solid var(--divider-color);

View File

@@ -175,10 +175,10 @@ export class HaMdDialog extends Dialog {
} }
.container { .container {
margin-top: var(--safe-area-inset-top, 0); margin-top: var(--safe-area-inset-top, var(--ha-space-0));
margin-bottom: var(--safe-area-inset-bottom, 0); margin-bottom: var(--safe-area-inset-bottom, var(--ha-space-0));
margin-left: var(--safe-area-inset-left, 0); margin-left: var(--safe-area-inset-left, var(--ha-space-0));
margin-right: var(--safe-area-inset-right, 0); margin-right: var(--safe-area-inset-right, var(--ha-space-0));
} }
} }

View File

@@ -1,17 +1,55 @@
import { html, LitElement, nothing } from "lit"; import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import { customElement, property, state } from "lit/decorators"; import type { PropertyValues, TemplateResult } from "lit";
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, ValueChangedEvent } from "../types"; import type { HomeAssistant, PanelInfo, ValueChangedEvent } from "../types";
import "./ha-generic-picker"; import "./ha-combo-box";
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;
@@ -23,51 +61,46 @@ export class HaNavigationPicker extends LitElement {
@property({ type: Boolean }) public required = false; @property({ type: Boolean }) public required = false;
@state() private _loading = true; @state() private _opened = false;
protected firstUpdated() { private navigationItemsLoaded = false;
this._loadNavigationItems();
}
private _navigationItems: PickerComboBoxItem[] = []; private navigationItems: NavigationItem[] = DEFAULT_ITEMS;
protected render() { @query("ha-combo-box", true) private comboBox!: HaComboBox;
protected render(): TemplateResult {
return html` return html`
<ha-generic-picker <ha-combo-box
.hass=${this.hass} .hass=${this.hass}
.value=${this._loading ? undefined : this.value} item-value-path="path"
item-label-path="path"
.value=${this._value}
allow-custom-value allow-custom-value
.placeholder=${this.label} .filteredItems=${this.navigationItems}
.label=${this.label}
.helper=${this.helper} .helper=${this.helper}
.disabled=${this._loading || this.disabled} .disabled=${this.disabled}
.required=${this.required} .required=${this.required}
.getItems=${this._getItems} .renderer=${rowRenderer}
.valueRenderer=${this._valueRenderer} @opened-changed=${this._openedChanged}
.customValueLabel=${this.hass.localize(
"ui.components.navigation-picker.add_custom_path"
)}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
@filter-changed=${this._filterChanged}
> >
</ha-generic-picker> </ha-combo-box>
`; `;
} }
private _valueRenderer = (itemId: string) => { private async _openedChanged(ev: ValueChangedEvent<boolean>) {
const item = this._navigationItems.find((navItem) => navItem.id === itemId); this._opened = ev.detail.value;
return html` if (this._opened && !this.navigationItemsLoaded) {
${item?.icon this._loadNavigationItems();
? 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,
@@ -91,47 +124,27 @@ 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) {
const path = `/${panel.url_path}`; this.navigationItems.push(createPanelNavigationItem(this.hass!, panel));
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) =>
const viewPath = `/${panel.url_path}/${view.path ?? index}`; this.navigationItems.push(
const viewPrimary = createViewNavigationItem(panel.url_path, view, index)
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._loading = false; this.comboBox.filteredItems = this.navigationItems;
}
protected shouldUpdate(changedProps: PropertyValues) {
return !this._opened || changedProps.has("_opened");
} }
private _valueChanged(ev: ValueChangedEvent<string>) { private _valueChanged(ev: ValueChangedEvent<string>) {
@@ -139,18 +152,61 @@ export class HaNavigationPicker extends LitElement {
this._setValue(ev.detail.value); this._setValue(ev.detail.value);
} }
private _setValue(value = "") { private _setValue(value: string) {
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, mdiPlus } from "@mdi/js"; import { mdiMagnify, mdiMinusBoxOutline } 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 {
@@ -15,10 +15,7 @@ import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare"; import { caseInsensitiveStringCompare } from "../common/string/compare";
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin"; import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
import { import { HaFuse } from "../resources/fuse";
multiTermSortedSearch,
type FuseWeightedKey,
} from "../resources/fuseMultiTerm";
import { haStyleScrollbar } from "../resources/styles"; import { haStyleScrollbar } from "../resources/styles";
import { loadVirtualizer } from "../resources/virtualizer"; import { loadVirtualizer } from "../resources/virtualizer";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
@@ -29,32 +26,16 @@ import "./ha-icon";
import "./ha-textfield"; import "./ha-textfield";
import type { HaTextField } from "./ha-textfield"; import type { HaTextField } from "./ha-textfield";
export const DEFAULT_SEARCH_KEYS: FuseWeightedKey[] = [
{
name: "primary",
weight: 10,
},
{
name: "secondary",
weight: 7,
},
{
name: "id",
weight: 3,
},
];
export interface PickerComboBoxItem { export interface PickerComboBoxItem {
id: string; id: string;
primary: string; primary: string;
secondary?: string; secondary?: string;
search_labels?: Record<string, string | null>; search_labels?: string[];
sorting_label?: string; sorting_label?: string;
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
@@ -92,20 +73,14 @@ 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;
@property({ attribute: false })
public searchKeys?: FuseWeightedKey[];
@state() private _listScrolled = false; @state() private _listScrolled = false;
@property({ attribute: false }) @property({ attribute: false })
public getItems!: ( public getItems?: (
searchString?: string, searchString?: string,
section?: string section?: string
) => (PickerComboBoxItem | string)[]; ) => (PickerComboBoxItem | string)[];
@@ -158,8 +133,6 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@state() private _sectionTitle?: string; @state() private _sectionTitle?: string;
@state() private _valuePinned = true;
private _allItems: (PickerComboBoxItem | string)[] = []; private _allItems: (PickerComboBoxItem | string)[] = [];
private _selectedItemIndex = -1; private _selectedItemIndex = -1;
@@ -191,15 +164,10 @@ 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=${searchLabel} .label=${this.label ??
this.hass?.localize("ui.common.search") ??
"Search"}
@input=${this._filterChanged} @input=${this._filterChanged}
></ha-textfield> ></ha-textfield>
${this._renderSectionButtons()} ${this._renderSectionButtons()}
@@ -226,15 +194,6 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
.renderItem=${this._renderItem} .renderItem=${this._renderItem}
style="min-height: 36px;" style="min-height: 36px;"
class=${this._listScrolled ? "scrolled" : ""} class=${this._listScrolled ? "scrolled" : ""}
.layout=${this.value && this._valuePinned
? {
pin: {
index: this._getInitialSelectedIndex(),
block: "center",
},
}
: undefined}
@unpinned=${this._handleUnpinned}
@scroll=${this._onScrollList} @scroll=${this._onScrollList}
@focus=${this._focusList} @focus=${this._focusList}
@visibilityChanged=${this._visibilityChanged} @visibilityChanged=${this._visibilityChanged}
@@ -285,42 +244,24 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
} }
} }
@eventOptions({ passive: true })
private _handleUnpinned() {
this._valuePinned = false;
}
private _getAdditionalItems = (searchString?: string) => private _getAdditionalItems = (searchString?: string) =>
this.getAdditionalItems?.(searchString) || []; this.getAdditionalItems?.(searchString) || [];
private _getItems = () => { private _getItems = () => {
let items = [...this.getItems(this._search, this.selectedSection)]; let items = [
...(this.getItems
? this.getItems(this._search, this.selectedSection)
: []),
];
if (!this.sections?.length) { if (!this.sections?.length) {
items = items.sort((entityA, entityB) => { items = items.sort((entityA, entityB) =>
const sortLabelA = caseInsensitiveStringCompare(
typeof entityA === "string" ? entityA : entityA.sorting_label; (entityA as PickerComboBoxItem).sorting_label!,
const sortLabelB = (entityB as PickerComboBoxItem).sorting_label!,
typeof entityB === "string" ? entityB : entityB.sorting_label;
if (!sortLabelA || !sortLabelB) {
return 0;
}
if (!sortLabelB) {
return -1;
}
if (!sortLabelA) {
return 1;
}
return caseInsensitiveStringCompare(
sortLabelA,
sortLabelB,
this.hass?.locale.language ?? navigator.language this.hass?.locale.language ?? navigator.language
); )
}); );
} }
if (!items.length) { if (!items.length) {
@@ -338,9 +279,6 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
}; };
private _renderItem = (item: PickerComboBoxItem | string, index: number) => { private _renderItem = (item: PickerComboBoxItem | string, index: number) => {
if (!item) {
return nothing;
}
if (item === "padding") { if (item === "padding") {
return html`<div class="bottom-padding"></div>`; return html`<div class="bottom-padding"></div>`;
} }
@@ -401,9 +339,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
fireEvent(this, "value-changed", { value: newValue }); fireEvent(this, "value-changed", { value: newValue });
}; };
private _fuseIndex = memoizeOne( private _fuseIndex = memoizeOne((states: PickerComboBoxItem[]) =>
(states: PickerComboBoxItem[], searchKeys?: FuseWeightedKey[]) => Fuse.createIndex(["search_labels"], states)
Fuse.createIndex(searchKeys || DEFAULT_SEARCH_KEYS, states)
); );
private _filterChanged = (ev: Event) => { private _filterChanged = (ev: Event) => {
@@ -419,26 +356,34 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
return; return;
} }
const index = this._fuseIndex( const index = this._fuseIndex(this._allItems as PickerComboBoxItem[]);
const fuse = new HaFuse(
this._allItems as PickerComboBoxItem[], this._allItems as PickerComboBoxItem[],
this.searchKeys {
shouldSort: false,
minMatchCharLength: Math.min(searchString.length, 2),
},
index
); );
let filteredItems = multiTermSortedSearch<PickerComboBoxItem>( const results = fuse.multiTermsSearch(searchString);
this._allItems as PickerComboBoxItem[], let filteredItems = [...this._allItems];
searchString,
this.searchKeys || DEFAULT_SEARCH_KEYS,
(item) => item.id,
index
) as (PickerComboBoxItem | string)[];
if (!filteredItems.length) { if (results) {
filteredItems.push(NO_ITEMS_AVAILABLE_ID); const items: (PickerComboBoxItem | string)[] = results.map(
(result) => result.item
);
if (!items.length) {
filteredItems.push(NO_ITEMS_AVAILABLE_ID);
}
const additionalItems = this._getAdditionalItems();
items.push(...additionalItems);
filteredItems = items;
} }
const additionalItems = this._getAdditionalItems();
filteredItems.push(...additionalItems);
if (this.searchFn) { if (this.searchFn) {
filteredItems = this.searchFn( filteredItems = this.searchFn(
searchString, searchString,
@@ -447,23 +392,13 @@ 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;
this._valuePinned = true; if (this._virtualizerElement) {
this._virtualizerElement.scrollTo(0, 0);
}
}; };
private _toggleSection(ev: Event) { private _toggleSection(ev: Event) {
@@ -655,25 +590,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
} }
private _keyFunction = (item: PickerComboBoxItem | string) => private _keyFunction = (item: PickerComboBoxItem | string) =>
typeof item === "string" ? item : item?.id; typeof item === "string" ? item : item.id;
private _getInitialSelectedIndex() {
if (!this._virtualizerElement || this._search || !this.value) {
return 0;
}
const index = this._virtualizerElement.items.findIndex(
(item) =>
typeof item !== "string" &&
(item as PickerComboBoxItem).id === this.value
);
if (index === -1) {
return 0;
}
return index;
}
static get styles() { static get styles() {
return [ return [

View File

@@ -1,4 +1,3 @@
import { consume } from "@lit/context";
import { mdiClose, mdiMenuDown } from "@mdi/js"; import { mdiClose, mdiMenuDown } from "@mdi/js";
import { import {
css, css,
@@ -8,15 +7,10 @@ import {
type CSSResultGroup, type CSSResultGroup,
type TemplateResult, type TemplateResult,
} from "lit"; } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query } 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 { PickerMixin } from "../mixins/picker-mixin";
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";
import "./ha-icon-button"; import "./ha-icon-button";
declare global { declare global {
@@ -28,70 +22,45 @@ 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 PickerMixin(LitElement) { export class HaPickerField extends LitElement {
@property({ type: Boolean, reflect: true }) public invalid = false; @property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@property() public value?: string;
@property() public helper?: string;
@property() public placeholder?: string;
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@property({ attribute: false })
public valueRenderer?: PickerValueRenderer;
@query("ha-combo-box-item", true) public item!: HaComboBoxItem; @query("ha-combo-box-item", true) public item!: HaComboBoxItem;
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: HomeAssistant["localize"];
public async focus() { public async focus() {
await this.updateComplete; await this.updateComplete;
await this.item?.focus(); await this.item?.focus();
} }
protected render() { protected render() {
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 =
this.label && hasValue
? html`<span slot="overline"
>${this.label}${this.required ? " *" : ""}</span
>`
: nothing;
const headlineContent = hasValue
? this.valueRenderer
? this.valueRenderer(this.value ?? "")
: html`<span slot="headline">${this.value}</span>`
: placeholderText
? html`<span slot="headline" class="placeholder">
${placeholderText}${this.required ? " *" : ""}
</span>`
: nothing;
return html` return html`
<ha-combo-box-item <ha-combo-box-item .disabled=${this.disabled} type="button" compact>
aria-label=${ifDefined(this.label || this.placeholder)} ${this.value
.disabled=${this.disabled} ? this.valueRenderer
type="button" ? this.valueRenderer(this.value)
compact : html`<slot name="headline">${this.value}</slot>`
> : html`
${this.image <span slot="headline" class="placeholder">
? html`<img ${this.placeholder}
alt=${this.label ?? ""} </span>
slot="start" `}
.src=${this.image}
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>`
: this.icon
? html`<ha-icon slot="start" .icon=${this.icon}></ha-icon>`
: html`<slot name="start"></slot>`}
${overlineLabel}${headlineContent}
${this.unknown
? html`<div slot="supporting-text" class="unknown">
${this.unknownItemText ||
this.localize("ui.components.combo-box.unknown_item")}
</div>`
: nothing}
${showClearIcon ${showClearIcon
? html` ? html`
<ha-icon-button <ha-icon-button
@@ -111,7 +80,7 @@ export class HaPickerField extends PickerMixin(LitElement) {
`; `;
} }
private _clear(e: CustomEvent) { private _clear(e) {
e.stopPropagation(); e.stopPropagation();
fireEvent(this, "clear"); fireEvent(this, "clear");
} }
@@ -173,15 +142,6 @@ export class HaPickerField extends PickerMixin(LitElement) {
background-color: var(--mdc-theme-primary); background-color: var(--mdc-theme-primary);
} }
:host([unknown]) ha-combo-box-item {
background-color: var(--ha-color-fill-warning-quiet-resting);
}
:host([invalid]) ha-combo-box-item:after {
height: 2px;
background-color: var(--mdc-theme-error, var(--error-color, #b00020));
}
.clear { .clear {
margin: 0 -8px; margin: 0 -8px;
--mdc-icon-button-size: 32px; --mdc-icon-button-size: 32px;
@@ -194,14 +154,7 @@ export class HaPickerField extends PickerMixin(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 {
color: var(--ha-color-on-warning-normal);
} }
`, `,
]; ];

View File

@@ -7,7 +7,7 @@ import { fullEntitiesContext } from "../../data/context";
import { import {
subscribeEntityRegistry, subscribeEntityRegistry,
type EntityRegistryEntry, type EntityRegistryEntry,
} from "../../data/entity/entity_registry"; } from "../../data/entity_registry";
import type { Action } from "../../data/script"; import type { Action } from "../../data/script";
import { migrateAutomationAction } from "../../data/script"; import { migrateAutomationAction } from "../../data/script";
import type { ActionSelector } from "../../data/selector"; import type { ActionSelector } from "../../data/selector";

View File

@@ -28,6 +28,7 @@ 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

@@ -4,14 +4,14 @@ import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
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 type { DeviceRegistryEntry } from "../../data/device_registry";
import { getDeviceIntegrationLookup } from "../../data/device_registry";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import type { EntitySources } from "../../data/entity_sources";
import { fetchEntitySourcesWithCache } from "../../data/entity_sources";
import type { AreaSelector } from "../../data/selector";
import type { ConfigEntry } from "../../data/config_entries"; import type { ConfigEntry } from "../../data/config_entries";
import { getConfigEntries } from "../../data/config_entries"; import { getConfigEntries } from "../../data/config_entries";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import { getDeviceIntegrationLookup } from "../../data/device/device_registry";
import type { EntitySources } from "../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources";
import type { AreaSelector } from "../../data/selector";
import { import {
filterSelectorDevices, filterSelectorDevices,
filterSelectorEntities, filterSelectorEntities,

View File

@@ -1,202 +0,0 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { isTemplate } from "../../common/string/has-template";
import type { ChooseSelector, Selector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-button-toggle-group";
import "./ha-selector";
@customElement("ha-selector-choose")
export class HaChooseSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: ChooseSelector;
@property() public value?: any;
@property() public label?: string;
@property() public helper?: string;
@property({ attribute: false })
public localizeValue?: (key: string) => string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
@state() public _activeChoice?: string;
protected willUpdate(changedProperties: PropertyValues): void {
if (
changedProperties.has("selector") &&
(!this._activeChoice ||
!(this._activeChoice in this.selector.choose.choices))
) {
this._setActiveChoice();
}
}
protected render() {
if (!this._activeChoice) {
return nothing;
}
const selector = this._selector(this._activeChoice);
const value = this._value(this._activeChoice);
return html`<div class="multi-header">
<span>${this.label}${this.required ? "*" : ""}</span>
<ha-button-toggle-group
size="small"
.buttons=${this._toggleButtons(
this.selector.choose.choices,
this.selector.choose.translation_key
)}
.active=${this._activeChoice}
@value-changed=${this._choiceChanged}
></ha-button-toggle-group>
</div>
<ha-selector
.hass=${this.hass}
.selector=${selector}
.value=${value}
.disabled=${this.disabled}
.required=${this.required}
@value-changed=${this._handleValueChanged}
.helper=${this.helper}
></ha-selector>`;
}
private _toggleButtons = memoizeOne(
(choices: ChooseSelector["choose"]["choices"], translationKey?: string) =>
Object.keys(choices).map((choice) => ({
label:
this.localizeValue && translationKey
? this.localizeValue(`${translationKey}.choices.${choice}`)
: choice,
value: choice,
}))
);
private _choiceChanged(ev) {
ev.stopPropagation();
const value =
typeof this.value === "object"
? this.value
: {
[this._activeChoice!]: this.value,
};
this._activeChoice = ev.detail?.value || ev.target.value;
fireEvent(this, "value-changed", {
value: {
...value,
active_choice: this._activeChoice,
},
});
}
private _handleValueChanged(ev: CustomEvent) {
ev.stopPropagation();
const value = typeof this.value === "object" ? this.value : {};
fireEvent(this, "value-changed", {
value: {
...value,
[this._activeChoice!]: ev.detail.value,
active_choice: this._activeChoice,
},
});
}
private _selector(choice?: string): Selector {
const choices = this.selector.choose.choices;
choice = choice || this.value?.active_choice;
if (choice && choice in choices) {
return choices[choice].selector;
}
return choices[Object.keys(choices)[0]].selector;
}
private _value(choice?: string): any {
if (!this.value) {
return undefined;
}
return typeof this.value === "object"
? this.value[choice || this.value.active_choice]
: this.value;
}
private _setActiveChoice() {
if (this.value) {
if (typeof this.value === "object") {
if (this.value.active_choice in this.selector.choose.choices) {
this._activeChoice = this.value.active_choice;
return;
}
} else {
const typeofValue = typeof this.value;
const selectorTypes = Object.values(this.selector.choose.choices).map(
(choice) => Object.keys(choice.selector)[0]
);
if (typeofValue === "number" && selectorTypes.includes("number")) {
this._activeChoice = Object.keys(this.selector.choose.choices)[
selectorTypes.indexOf("number")
];
return;
}
if (
typeofValue === "string" &&
isTemplate(this.value) &&
selectorTypes.includes("template")
) {
this._activeChoice = Object.keys(this.selector.choose.choices)[
selectorTypes.indexOf("template")
];
return;
}
if (
typeofValue === "string" &&
this.value.includes(".") &&
selectorTypes.includes("entity")
) {
this._activeChoice = Object.keys(this.selector.choose.choices)[
selectorTypes.indexOf("entity")
];
return;
}
if (typeofValue === "string" && selectorTypes.includes("text")) {
this._activeChoice = Object.keys(this.selector.choose.choices)[
selectorTypes.indexOf("text")
];
return;
}
}
}
this._activeChoice = Object.keys(this.selector.choose.choices)[0];
}
static styles = css`
.multi-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--ha-space-2);
}
ha-button-toggle-group {
display: block;
justify-self: end;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-choose": HaChooseSelector;
}
}

View File

@@ -29,6 +29,7 @@ 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

@@ -5,13 +5,13 @@ import { customElement, property, state } from "lit/decorators";
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 type { DeviceRegistryEntry } from "../../data/device_registry";
import { getDeviceIntegrationLookup } from "../../data/device_registry";
import type { EntitySources } from "../../data/entity_sources";
import { fetchEntitySourcesWithCache } from "../../data/entity_sources";
import type { DeviceSelector } from "../../data/selector";
import type { ConfigEntry } from "../../data/config_entries"; import type { ConfigEntry } from "../../data/config_entries";
import { getConfigEntries } from "../../data/config_entries"; import { getConfigEntries } from "../../data/config_entries";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import { getDeviceIntegrationLookup } from "../../data/device/device_registry";
import type { EntitySources } from "../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources";
import type { DeviceSelector } from "../../data/selector";
import { import {
filterSelectorDevices, filterSelectorDevices,
filterSelectorEntities, filterSelectorEntities,
@@ -107,6 +107,7 @@ 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

@@ -4,12 +4,12 @@ import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
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 { EntitySources } from "../../data/entity/entity_sources"; import type { EntitySources } from "../../data/entity_sources";
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources"; import { fetchEntitySourcesWithCache } from "../../data/entity_sources";
import type { EntitySelector } from "../../data/selector"; import type { EntitySelector } from "../../data/selector";
import { import {
computeCreateDomains,
filterSelectorEntities, filterSelectorEntities,
computeCreateDomains,
} from "../../data/selector"; } from "../../data/selector";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import "../entity/ha-entities-picker"; import "../entity/ha-entities-picker";
@@ -66,14 +66,15 @@ 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>`;
} }
@@ -82,13 +83,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

@@ -4,14 +4,14 @@ import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
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 type { DeviceRegistryEntry } from "../../data/device_registry";
import { getDeviceIntegrationLookup } from "../../data/device_registry";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import type { EntitySources } from "../../data/entity_sources";
import { fetchEntitySourcesWithCache } from "../../data/entity_sources";
import type { FloorSelector } from "../../data/selector";
import type { ConfigEntry } from "../../data/config_entries"; import type { ConfigEntry } from "../../data/config_entries";
import { getConfigEntries } from "../../data/config_entries"; import { getConfigEntries } from "../../data/config_entries";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import { getDeviceIntegrationLookup } from "../../data/device/device_registry";
import type { EntitySources } from "../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources";
import type { FloorSelector } from "../../data/selector";
import { import {
filterSelectorDevices, filterSelectorDevices,
filterSelectorEntities, filterSelectorEntities,

View File

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

View File

@@ -36,7 +36,6 @@ export class HaLabelSelector extends LitElement {
.required=${this.required} .required=${this.required}
.disabled=${this.disabled} .disabled=${this.disabled}
.label=${this.label} .label=${this.label}
.helper=${this.helper}
@value-changed=${this._handleChange} @value-changed=${this._handleChange}
> >
</ha-labels-picker> </ha-labels-picker>
@@ -50,7 +49,6 @@ export class HaLabelSelector extends LitElement {
.required=${this.required} .required=${this.required}
.disabled=${this.disabled} .disabled=${this.disabled}
.label=${this.label} .label=${this.label}
.helper=${this.helper}
@value-changed=${this._handleChange} @value-changed=${this._handleChange}
> >
</ha-label-picker> </ha-label-picker>

View File

@@ -1,8 +1,7 @@
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 } 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 { 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";
@@ -12,8 +11,9 @@ 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,6 +40,8 @@ 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;
@@ -57,8 +59,15 @@ export class HaSelectSelector extends LitElement {
}); });
} }
private _filter = "";
protected render() { protected render() {
const options = this._getOptions(this.selector); const options =
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;
@@ -156,6 +165,10 @@ 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`
@@ -199,33 +212,50 @@ export class HaSelectSelector extends LitElement {
` `
: nothing} : nothing}
<ha-generic-picker <ha-combo-box
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=${""}
.addButtonLabel=${this.label} .items=${optionItems}
.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}
></ha-generic-picker> @opened-changed=${this._openedChanged}
></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-generic-picker <ha-combo-box
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}
.getItems=${this._getItems(options)} .items=${optionItems}
.value=${this.value as string | undefined} .value=${this.value}
@filter-changed=${this._filterChanged}
@value-changed=${this._comboBoxValueChanged} @value-changed=${this._comboBoxValueChanged}
allow-custom-value @opened-changed=${this._openedChanged}
></ha-generic-picker> ></ha-combo-box>
`; `;
} }
@@ -244,7 +274,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
> >
` `
@@ -261,30 +291,6 @@ 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 ||
@@ -349,6 +355,8 @@ 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 {
@@ -366,17 +374,49 @@ export class HaSelectSelector extends LitElement {
return; return;
} }
const currentValue = !this.value ? [] : ensureArray(this.value); const currentValue =
!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

@@ -7,15 +7,15 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
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 type { DeviceRegistryEntry } from "../../data/device/device_registry"; import type { DeviceRegistryEntry } from "../../data/device_registry";
import { getDeviceIntegrationLookup } from "../../data/device/device_registry"; import { getDeviceIntegrationLookup } from "../../data/device_registry";
import type { EntitySources } from "../../data/entity/entity_sources"; import type { EntitySources } from "../../data/entity_sources";
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources"; import { fetchEntitySourcesWithCache } from "../../data/entity_sources";
import type { TargetSelector } from "../../data/selector"; import type { TargetSelector } from "../../data/selector";
import { import {
computeCreateDomains,
filterSelectorDevices, filterSelectorDevices,
filterSelectorEntities, filterSelectorEntities,
computeCreateDomains,
} from "../../data/selector"; } from "../../data/selector";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import "../ha-target-picker"; import "../ha-target-picker";

Some files were not shown because too many files have changed in this diff Show More