Compare commits

..

4 Commits

Author SHA1 Message Date
Petar Petrov
bb36e4aa42 Exclude conditional elements 2025-12-18 17:05:54 +02:00
Petar Petrov
5c9e052030 improve typing 2025-12-18 12:18:51 +02:00
Petar Petrov
392a87f33a refactor 2025-12-18 09:15:16 +02:00
Petar Petrov
72cd243ee3 Picture elements position by click 2025-12-18 09:07:15 +02:00
153 changed files with 2628 additions and 2400 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

@@ -19,11 +19,8 @@ jobs:
release: release:
name: Release name: Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: pypi
permissions: permissions:
contents: write # Required to upload release assets contents: write # Required to upload release assets
id-token: write # For "Trusted Publisher" to PyPi
if: github.repository_owner == 'home-assistant'
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
@@ -49,18 +46,14 @@ jobs:
run: ./script/translations_download run: ./script/translations_download
env: env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
- name: Build and release package - name: Build and release package
run: | run: |
python3 -m pip install build python3 -m pip install twine build
export TWINE_USERNAME="__token__"
export TWINE_PASSWORD="${{ secrets.TWINE_TOKEN }}"
export SKIP_FETCH_NIGHTLY_TRANSLATIONS=1 export SKIP_FETCH_NIGHTLY_TRANSLATIONS=1
script/release script/release
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
with:
skip-existing: true
- name: Upload release assets - name: Upload release assets
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with: with:

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

@@ -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

@@ -37,15 +37,15 @@
"@codemirror/view": "6.39.4", "@codemirror/view": "6.39.4",
"@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.1", "@formatjs/intl-datetimeformat": "6.18.2",
"@formatjs/intl-displaynames": "7.0.1", "@formatjs/intl-displaynames": "6.8.13",
"@formatjs/intl-durationformat": "0.8.1", "@formatjs/intl-durationformat": "0.7.6",
"@formatjs/intl-getcanonicallocales": "3.0.1", "@formatjs/intl-getcanonicallocales": "2.5.6",
"@formatjs/intl-listformat": "8.0.1", "@formatjs/intl-listformat": "7.7.13",
"@formatjs/intl-locale": "5.0.1", "@formatjs/intl-locale": "4.2.13",
"@formatjs/intl-numberformat": "9.0.2", "@formatjs/intl-numberformat": "8.15.6",
"@formatjs/intl-pluralrules": "6.0.1", "@formatjs/intl-pluralrules": "5.4.6",
"@formatjs/intl-relativetimeformat": "12.0.2", "@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",
@@ -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.1", "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",
@@ -155,11 +157,11 @@
"@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.15",
"@rspack/core": "1.6.7", "@rspack/core": "1.6.7",
"@rspack/dev-server": "1.1.4", "@rspack/dev-server": "1.1.4",
"@types/babel__plugin-transform-runtime": "7.9.5", "@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.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,7 +178,7 @@
"@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",
@@ -215,9 +217,9 @@
"terser-webpack-plugin": "5.3.16", "terser-webpack-plugin": "5.3.16",
"ts-lit-plugin": "2.0.2", "ts-lit-plugin": "2.0.2",
"typescript": "5.9.3", "typescript": "5.9.3",
"typescript-eslint": "8.50.0", "typescript-eslint": "8.49.0",
"vite-tsconfig-paths": "6.0.1", "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"

View File

@@ -1,4 +1,5 @@
#!/bin/sh #!/bin/sh
# Pushes a new version to PyPi.
# Stop on errors # Stop on errors
set -e set -e
@@ -11,4 +12,5 @@ yarn install
script/build_frontend script/build_frontend
rm -rf dist home_assistant_frontend.egg-info rm -rf dist home_assistant_frontend.egg-info
python3 -m build -q python3 -m build
python3 -m twine upload dist/*.whl --skip-existing

View File

@@ -1,6 +1,16 @@
// From https://github.com/epoberezkin/fast-deep-equal // From https://github.com/epoberezkin/fast-deep-equal
// MIT License - Copyright (c) 2017 Evgeny Poberezkin // MIT License - Copyright (c) 2017 Evgeny Poberezkin
export const deepEqual = (a: any, b: any): boolean => {
interface DeepEqualOptions {
/** Compare Symbol properties in addition to string keys */
compareSymbols?: boolean;
}
export const deepEqual = (
a: any,
b: any,
options?: DeepEqualOptions
): boolean => {
if (a === b) { if (a === b) {
return true; return true;
} }
@@ -18,7 +28,7 @@ export const deepEqual = (a: any, b: any): boolean => {
return false; return false;
} }
for (i = length; i-- !== 0; ) { for (i = length; i-- !== 0; ) {
if (!deepEqual(a[i], b[i])) { if (!deepEqual(a[i], b[i], options)) {
return false; return false;
} }
} }
@@ -35,7 +45,7 @@ export const deepEqual = (a: any, b: any): boolean => {
} }
} }
for (i of a.entries()) { for (i of a.entries()) {
if (!deepEqual(i[1], b.get(i[0]))) { if (!deepEqual(i[1], b.get(i[0]), options)) {
return false; return false;
} }
} }
@@ -93,11 +103,28 @@ export const deepEqual = (a: any, b: any): boolean => {
for (i = length; i-- !== 0; ) { for (i = length; i-- !== 0; ) {
const key = keys[i]; const key = keys[i];
if (!deepEqual(a[key], b[key])) { if (!deepEqual(a[key], b[key], options)) {
return false; return false;
} }
} }
// Compare Symbol properties if requested
if (options?.compareSymbols) {
const symbolsA = Object.getOwnPropertySymbols(a);
const symbolsB = Object.getOwnPropertySymbols(b);
if (symbolsA.length !== symbolsB.length) {
return false;
}
for (const sym of symbolsA) {
if (!Object.prototype.hasOwnProperty.call(b, sym)) {
return false;
}
if (!deepEqual(a[sym], b[sym], options)) {
return false;
}
}
}
return true; return true;
} }

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";
@@ -162,7 +162,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`

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

@@ -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";
@@ -172,9 +172,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;
@@ -277,13 +277,12 @@ 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}
@@ -291,7 +290,6 @@ export class HaEntityPicker extends LitElement {
.searchFn=${this._searchFn} .searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer} .valueRenderer=${this._valueRenderer}
.searchKeys=${entityComboBoxKeys} .searchKeys=${entityComboBoxKeys}
use-top-label
.addButtonLabel=${this.addButton .addButtonLabel=${this.addButton
? this.hass.localize("ui.components.entity.entity-picker.add") ? this.hass.localize("ui.components.entity.entity-picker.add")
: undefined} : undefined}

View File

@@ -1,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

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

View File

@@ -1,29 +1,29 @@
import type { 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

@@ -51,6 +51,9 @@ export class HaAreaPicker extends LitElement {
@property({ type: Boolean, attribute: "no-add" }) @property({ type: Boolean, attribute: "no-add" })
public noAdd = false; public noAdd = false;
@property({ type: Boolean, attribute: "show-label" })
public showLabel = false;
/** /**
* Show only areas with entities from specific domains. * Show only areas with entities from specific domains.
* @type {Array} * @type {Array}
@@ -363,19 +366,16 @@ 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); const valueRenderer = this._computeValueRenderer(this.hass.areas);
// Only show label if there's no floor let showLabel = this.showLabel;
let label: string | undefined = baseLabel; if (this.value) {
if (this.value && baseLabel) {
const area = this.hass.areas[this.value]; const area = this.hass.areas[this.value];
if (area) { if (area) {
const { floor } = getAreaContext(area, this.hass.floors); const { floor } = getAreaContext(area, this.hass.floors);
if (floor) { showLabel = !floor && this.showLabel;
label = undefined;
}
} }
} }
@@ -383,12 +383,14 @@ export class HaAreaPicker extends LitElement {
<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}
.showLabel=${showLabel}
.value=${this.value} .value=${this.value}
.getItems=${this._getItems} .getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems} .getAdditionalItems=${this._getAdditionalItems}

View File

@@ -175,7 +175,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 { .icons {
display: flex; display: flex;

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

@@ -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

@@ -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

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

View File

@@ -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

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

View File

@@ -7,10 +7,8 @@ import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { tinykeys } from "tinykeys"; import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { PickerMixin } from "../mixins/picker-mixin";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm"; import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import { isIosApp } from "../util/is_ios";
import "./ha-bottom-sheet"; import "./ha-bottom-sheet";
import "./ha-button"; import "./ha-button";
import "./ha-combo-box-item"; import "./ha-combo-box-item";
@@ -22,18 +20,39 @@ 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 value?: string;
@property() public icon?: string;
@property() public label?: string;
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: String, attribute: "search-label" }) @property({ type: String, attribute: "search-label" })
public searchLabel?: string; public searchLabel?: string;
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@property({ attribute: "show-label", type: Boolean })
public showLabel = false;
/** To prevent lags, getItems needs to be memoized */ /** To prevent lags, getItems needs to be memoized */
@property({ attribute: false }) @property({ attribute: false })
public getItems!: ( public getItems!: (
@@ -47,6 +66,9 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@property({ attribute: false }) @property({ attribute: false })
public rowRenderer?: RenderItemFunction<PickerComboBoxItem>; public rowRenderer?: RenderItemFunction<PickerComboBoxItem>;
@property({ attribute: false })
public valueRenderer?: PickerValueRenderer;
@property({ attribute: false }) @property({ attribute: false })
public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>; public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
@@ -96,11 +118,7 @@ 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" }) @property({ attribute: "unknown-item-text" }) public unknownItemText?: string;
public useTopLabel = false;
@property({ attribute: "custom-value-label" })
public customValueLabel?: string;
@query(".container") private _containerElement?: HTMLDivElement; @query(".container") private _containerElement?: HTMLDivElement;
@@ -131,13 +149,11 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
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,20 +173,14 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
type="button" type="button"
class=${this._opened ? "opened" : ""} class=${this._opened ? "opened" : ""}
compact compact
.unknown=${this._unknownValue( .unknown=${this._unknownValue(this.value, this.getItems())}
this.allowCustomValue,
this.value,
this.getItems()
)}
.unknownItemText=${this.unknownItemText} .unknownItemText=${this.unknownItemText}
aria-label=${ifDefined(this.label)} aria-label=${ifDefined(this.label)}
@click=${this.open} @click=${this.open}
@clear=${this._clear} @clear=${this._clear}
.icon=${this.icon} .icon=${this.icon}
.image=${this.image} .showLabel=${this.showLabel}
.label=${label}
.placeholder=${this.placeholder} .placeholder=${this.placeholder}
.helper=${this.helper}
.value=${this.value} .value=${this.value}
.valueRenderer=${this.valueRenderer} .valueRenderer=${this.valueRenderer}
.required=${this.required} .required=${this.required}
@@ -178,7 +188,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
.invalid=${this.invalid} .invalid=${this.invalid}
.hideClearIcon=${this.hideClearIcon} .hideClearIcon=${this.hideClearIcon}
> >
<slot name="start"></slot>
</ha-picker-field>`} </ha-picker-field>`}
</slot> </slot>
</div> </div>
@@ -217,7 +226,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 +236,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}
@@ -243,24 +252,13 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
.sectionTitleFunction=${this.sectionTitleFunction} .sectionTitleFunction=${this.sectionTitleFunction}
.selectedSection=${this.selectedSection} .selectedSection=${this.selectedSection}
.searchKeys=${this.searchKeys} .searchKeys=${this.searchKeys}
.customValueLabel=${this.customValueLabel}
></ha-picker-combo-box> ></ha-picker-combo-box>
`; `;
} }
private _unknownValue = memoizeOne( private _unknownValue = memoizeOne(
( (value?: string, items?: (PickerComboBoxItem | string)[]) => {
allowCustomValue: boolean, if (value === undefined || value === null || value === "" || !items) {
value?: string,
items?: (PickerComboBoxItem | string)[]
) => {
if (
allowCustomValue ||
value === undefined ||
value === null ||
value === "" ||
!items
) {
return false; return false;
} }
@@ -286,15 +284,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
private _dialogOpened = () => { private _dialogOpened = () => {
this._opened = true; this._opened = true;
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (this.hass && isIosApp(this.hass)) {
this.hass.auth.external!.fireMessage({
type: "focus_element",
payload: {
element_id: "combo-box",
},
});
return;
}
this._comboBox?.focus(); this._comboBox?.focus();
}); });
}; };
@@ -321,7 +310,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);
} }
@@ -393,7 +382,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
} }
wa-popover { wa-popover {
--wa-space-l: 0; --wa-space-l: var(--ha-space-0);
} }
wa-popover::part(body) { wa-popover::part(body) {
@@ -418,7 +407,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
--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

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

View File

@@ -231,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

@@ -116,11 +116,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 +129,10 @@ 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")}
show-label
.value=${value} .value=${value}
.valueRenderer=${this._valueRenderer} .valueRenderer=${this._valueRenderer}
.disabled=${this.disabled} .disabled=${this.disabled}

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 {
@@ -53,8 +53,7 @@ export interface PickerComboBoxItem {
icon_path?: string; icon_path?: string;
icon?: string; icon?: string;
} }
const NO_ITEMS_AVAILABLE_ID = "___no_items_available___";
export const NO_ITEMS_AVAILABLE_ID = "___no_items_available___";
const DEFAULT_ROW_RENDERER: RenderItemFunction<PickerComboBoxItem> = ( const DEFAULT_ROW_RENDERER: RenderItemFunction<PickerComboBoxItem> = (
item item
@@ -92,9 +91,6 @@ 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;
@@ -191,15 +187,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()}
@@ -447,23 +438,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) {
@@ -658,7 +639,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
typeof item === "string" ? item : item?.id; typeof item === "string" ? item : item?.id;
private _getInitialSelectedIndex() { private _getInitialSelectedIndex() {
if (!this._virtualizerElement || this._search || !this.value) { if (!this._virtualizerElement || !this.value) {
return 0; return 0;
} }

View File

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

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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -18,7 +18,6 @@ const LOAD_ELEMENTS = {
attribute: () => import("./ha-selector-attribute"), attribute: () => import("./ha-selector-attribute"),
assist_pipeline: () => import("./ha-selector-assist-pipeline"), assist_pipeline: () => import("./ha-selector-assist-pipeline"),
boolean: () => import("./ha-selector-boolean"), boolean: () => import("./ha-selector-boolean"),
choose: () => import("./ha-selector-choose"),
color_rgb: () => import("./ha-selector-color-rgb"), color_rgb: () => import("./ha-selector-color-rgb"),
condition: () => import("./ha-selector-condition"), condition: () => import("./ha-selector-condition"),
config_entry: () => import("./ha-selector-config-entry"), config_entry: () => import("./ha-selector-config-entry"),

View File

@@ -726,7 +726,6 @@ export class HaServiceControl extends LitElement {
: undefined} : undefined}
.placeholder=${dataField.default} .placeholder=${dataField.default}
.localizeValue=${this._localizeValueCallback} .localizeValue=${this._localizeValueCallback}
.required=${dataField.required}
></ha-selector> ></ha-selector>
</ha-settings-row>` </ha-settings-row>`
: ""; : "";

View File

@@ -1,5 +1,5 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiRoomService } from "@mdi/js"; import { mdiRoomService } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import { html, LitElement, nothing, type TemplateResult } from "lit"; import { html, LitElement, nothing, type TemplateResult } 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";
@@ -56,9 +56,9 @@ class HaServicePicker extends LitElement {
getServiceIcons(this.hass); getServiceIcons(this.hass);
} }
private _rowRenderer: RenderItemFunction<ServiceComboBoxItem> = ( private _rowRenderer: ComboBoxLitRenderer<ServiceComboBoxItem> = (
item, item,
index { index }
) => html` ) => html`
<ha-combo-box-item type="button" .borderTop=${index !== 0}> <ha-combo-box-item type="button" .borderTop=${index !== 0}>
<ha-service-icon <ha-service-icon
@@ -135,6 +135,7 @@ class HaServicePicker extends LitElement {
<ha-generic-picker <ha-generic-picker
.hass=${this.hass} .hass=${this.hass}
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
allow-custom-value
.notFoundLabel=${this.hass.localize( .notFoundLabel=${this.hass.localize(
"ui.components.service-picker.no_match" "ui.components.service-picker.no_match"
)} )}

View File

@@ -681,7 +681,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
tooltip.style.display = "block"; tooltip.style.display = "block";
tooltip.style.position = "fixed"; tooltip.style.position = "fixed";
tooltip.style.top = `${top}px`; tooltip.style.top = `${top}px`;
tooltip.style.left = `calc(${item.offsetLeft + item.clientWidth + 8}px + var(--safe-area-inset-left, 0px))`; tooltip.style.left = `calc(${item.offsetLeft + item.clientWidth + 8}px + var(--safe-area-inset-left, var(--ha-space-0)))`;
} }
private _hideTooltip() { private _hideTooltip() {
@@ -720,10 +720,14 @@ class HaSidebar extends SubscribeMixin(LitElement) {
background-color: var(--sidebar-background-color); background-color: var(--sidebar-background-color);
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
padding-bottom: calc(14px + var(--safe-area-inset-bottom, 0px)); padding-bottom: calc(
14px + var(--safe-area-inset-bottom, var(--ha-space-0))
);
} }
.menu { .menu {
height: calc(var(--header-height) + var(--safe-area-inset-top, 0px)); height: calc(
var(--header-height) + var(--safe-area-inset-top, var(--ha-space-0))
);
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
padding: 0 var(--ha-space-1); padding: 0 var(--ha-space-1);
@@ -742,16 +746,16 @@ class HaSidebar extends SubscribeMixin(LitElement) {
font-size: var(--ha-font-size-xl); font-size: var(--ha-font-size-xl);
align-items: center; align-items: center;
padding-left: calc( padding-left: calc(
var(--ha-space-1) + var(--safe-area-inset-left, 0px) var(--ha-space-1) + var(--safe-area-inset-left, var(--ha-space-0))
); );
padding-inline-start: calc( padding-inline-start: calc(
var(--ha-space-1) + var(--safe-area-inset-left, 0px) var(--ha-space-1) + var(--safe-area-inset-left, var(--ha-space-0))
); );
padding-inline-end: initial; padding-inline-end: initial;
padding-top: var(--safe-area-inset-top, 0px); padding-top: var(--safe-area-inset-top, var(--ha-space-0));
} }
:host([expanded]) .menu { :host([expanded]) .menu {
width: calc(256px + var(--safe-area-inset-left, 0px)); width: calc(256px + var(--safe-area-inset-left, var(--ha-space-0)));
} }
:host([narrow][expanded]) .menu { :host([narrow][expanded]) .menu {
width: 100%; width: 100%;
@@ -767,8 +771,8 @@ class HaSidebar extends SubscribeMixin(LitElement) {
display: none; display: none;
} }
:host([narrow]) .title { :host([narrow]) .title {
margin: 0; margin: var(--ha-space-0);
padding: 0 var(--ha-space-4); padding: var(--ha-space-0) var(--ha-space-4);
} }
:host([expanded]) .title { :host([expanded]) .title {
display: initial; display: initial;
@@ -780,13 +784,16 @@ class HaSidebar extends SubscribeMixin(LitElement) {
ha-fade-in, ha-fade-in,
ha-md-list { ha-md-list {
height: calc( height: calc(
100% - var(--header-height) - var(--safe-area-inset-top, 0px) - 100% - var(--header-height) - var(
--safe-area-inset-top,
var(--ha-space-0)
) -
132px 132px
); );
} }
ha-fade-in { ha-fade-in {
padding: var(--ha-space-1) 0; padding: var(--ha-space-1) var(--ha-space-0);
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -796,7 +803,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
ha-md-list { ha-md-list {
overflow-x: hidden; overflow-x: hidden;
background: none; background: none;
margin-left: var(--safe-area-inset-left, 0px); margin-left: var(--safe-area-inset-left, var(--ha-space-0));
} }
ha-md-list-item { ha-md-list-item {
@@ -818,7 +825,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
width: 248px; width: 248px;
} }
:host([narrow][expanded]) ha-md-list-item { :host([narrow][expanded]) ha-md-list-item {
width: calc(240px - var(--safe-area-inset-left, 0px)); width: calc(240px - var(--safe-area-inset-left, var(--ha-space-0)));
} }
ha-md-list-item.selected { ha-md-list-item.selected {
@@ -891,7 +898,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
border-radius: var(--ha-border-radius-md); border-radius: var(--ha-border-radius-md);
font-size: 0.65em; font-size: 0.65em;
line-height: var(--ha-line-height-expanded); line-height: var(--ha-line-height-expanded);
padding: 0 var(--ha-space-1); padding: var(--ha-space-0) var(--ha-space-1);
} }
ha-md-list-item.user { ha-md-list-item.user {

View File

@@ -988,7 +988,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
style=${styleMap({ style=${styleMap({
width: "var(--ha-space-12)", width: "var(--ha-space-12)",
position: "absolute", position: "absolute",
top: "0", top: "var(--ha-space-0)",
left: rtl ? undefined : "var(--ha-space-1)", left: rtl ? undefined : "var(--ha-space-1)",
right: rtl ? "var(--ha-space-1)" : undefined, right: rtl ? "var(--ha-space-1)" : undefined,
transform: rtl ? "scaleX(-1)" : "", transform: rtl ? "scaleX(-1)" : "",
@@ -1092,7 +1092,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
z-index: 2; z-index: 2;
} }
.mdc-chip-set { .mdc-chip-set {
padding: var(--ha-space-1) 0; padding: var(--ha-space-1) var(--ha-space-0);
gap: var(--ha-space-2); gap: var(--ha-space-2);
} }

View File

@@ -235,12 +235,6 @@ export class HaWaDialog extends ScrollableFadeMixin(LitElement) {
); );
max-width: var(--ha-dialog-max-width, var(--safe-width)); max-width: var(--ha-dialog-max-width, var(--safe-width));
} }
@media (prefers-reduced-motion: reduce) {
wa-dialog {
--show-duration: 0ms;
--hide-duration: 0ms;
}
}
:host([width="small"]) wa-dialog { :host([width="small"]) wa-dialog {
--width: min(var(--ha-dialog-width-sm, 320px), var(--full-width)); --width: min(var(--ha-dialog-width-sm, 320px), var(--full-width));
@@ -266,15 +260,15 @@ export class HaWaDialog extends ScrollableFadeMixin(LitElement) {
/* Used to offset the dialog from the safe areas when space is limited */ /* Used to offset the dialog from the safe areas when space is limited */
transform: translate( transform: translate(
calc( calc(
var(--safe-area-offset-left, 0px) - var( var(--safe-area-offset-left, var(--ha-space-0)) - var(
--safe-area-offset-right, --safe-area-offset-right,
0px var(--ha-space-0)
) )
), ),
calc( calc(
var(--safe-area-offset-top, 0px) - var( var(--safe-area-offset-top, var(--ha-space-0)) - var(
--safe-area-offset-bottom, --safe-area-offset-bottom,
0px var(--ha-space-0)
) )
) )
); );
@@ -285,7 +279,7 @@ export class HaWaDialog extends ScrollableFadeMixin(LitElement) {
@media all and (max-width: 450px), all and (max-height: 500px) { @media all and (max-width: 450px), all and (max-height: 500px) {
:host([type="standard"]) { :host([type="standard"]) {
--ha-dialog-border-radius: 0; --ha-dialog-border-radius: var(--ha-space-0);
wa-dialog { wa-dialog {
/* Make the container fill the whole screen width and not the safe width */ /* Make the container fill the whole screen width and not the safe width */
@@ -372,7 +366,7 @@ export class HaWaDialog extends ScrollableFadeMixin(LitElement) {
} }
wa-dialog::part(footer) { wa-dialog::part(footer) {
padding: 0; padding: var(--ha-space-0);
} }
::slotted([slot="footer"]) { ::slotted([slot="footer"]) {

View File

@@ -89,7 +89,7 @@ export class HaTargetPickerItemGroup extends LitElement {
static styles = css` static styles = css`
:host { :host {
display: block; display: block;
--expansion-panel-content-padding: 0; --expansion-panel-content-padding: var(--ha-space-0);
} }
ha-expansion-panel::part(summary) { ha-expansion-panel::part(summary) {
background-color: var(--ha-color-fill-neutral-quiet-resting); background-color: var(--ha-color-fill-neutral-quiet-resting);
@@ -101,7 +101,7 @@ export class HaTargetPickerItemGroup extends LitElement {
min-height: unset; min-height: unset;
} }
ha-md-list { ha-md-list {
padding: 0; padding: var(--ha-space-0);
} }
`; `;
} }

View File

@@ -605,8 +605,8 @@ export class HaTargetPickerItemRow extends LitElement {
buttonLinkStyle, buttonLinkStyle,
css` css`
:host { :host {
--md-list-item-top-space: 0; --md-list-item-top-space: var(--ha-space-0);
--md-list-item-bottom-space: 0; --md-list-item-bottom-space: var(--ha-space-0);
--md-list-item-leading-space: var(--ha-space-2); --md-list-item-leading-space: var(--ha-space-2);
--md-list-item-trailing-space: var(--ha-space-2); --md-list-item-trailing-space: var(--ha-space-2);
--md-list-item-two-line-container-height: 56px; --md-list-item-two-line-container-height: 56px;

View File

@@ -278,8 +278,8 @@ export class HaTargetPickerValueChip extends LitElement {
direction: var(--direction); direction: var(--direction);
} }
.expand-btn { .expand-btn {
margin-right: 0; margin-right: var(--ha-space-0);
margin-inline-end: 0; margin-inline-end: var(--ha-space-0);
margin-inline-start: initial; margin-inline-start: initial;
} }
.mdc-chip.area:not(.add), .mdc-chip.area:not(.add),

View File

@@ -1,4 +1,4 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize"; import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit"; import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
@@ -73,7 +73,7 @@ class HaUserPicker extends LitElement {
`; `;
}; };
private _rowRenderer: RenderItemFunction<UserComboBoxItem> = (item) => { private _rowRenderer: ComboBoxLitRenderer<UserComboBoxItem> = (item) => {
const user = item.user; const user = item.user;
if (!user) { if (!user) {
return html`<ha-combo-box-item type="button" compact> return html`<ha-combo-box-item type="button" compact>
@@ -132,9 +132,9 @@ class HaUserPicker extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
.label=${this.label} .label=${this.label}
.notFoundLabel=${this._notFoundLabel}
.placeholder=${placeholder} .placeholder=${placeholder}
.value=${this.value} .value=${this.value}
.notFoundLabel=${this._notFoundLabel}
.getItems=${this._getItems} .getItems=${this._getItems}
.valueRenderer=${this._valueRenderer} .valueRenderer=${this._valueRenderer}
.rowRenderer=${this._rowRenderer} .rowRenderer=${this._rowRenderer}

View File

@@ -27,7 +27,6 @@ export type Selector =
| AttributeSelector | AttributeSelector
| BooleanSelector | BooleanSelector
| ButtonToggleSelector | ButtonToggleSelector
| ChooseSelector
| ColorRGBSelector | ColorRGBSelector
| ColorTempSelector | ColorTempSelector
| ConditionSelector | ConditionSelector
@@ -117,13 +116,6 @@ export interface ButtonToggleSelector {
} | null; } | null;
} }
export interface ChooseSelector {
choose: {
choices: Record<string, { selector: Selector }>;
translation_key?: string;
};
}
export interface ColorRGBSelector { export interface ColorRGBSelector {
color_rgb: {} | null; color_rgb: {} | null;
} }

View File

@@ -37,8 +37,8 @@ export class HaMoreInfoControlSelectContainer extends LitElement {
overflow: auto; overflow: auto;
-ms-overflow-style: none; /* IE and Edge */ -ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */ scrollbar-width: none; /* Firefox */
margin: -2px calc(var(--ha-space-6) * -1); margin: -2px -24px;
padding: 2px var(--ha-space-6); padding: 2px 24px;
} }
.controls-scroll::-webkit-scrollbar { .controls-scroll::-webkit-scrollbar {
display: none; display: none;

View File

@@ -84,8 +84,8 @@ export class HaMoreInfoStateHeader extends LitElement {
font-weight: var(--ha-font-weight-medium); font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-normal); line-height: var(--ha-line-height-normal);
letter-spacing: 0.1px; letter-spacing: 0.1px;
padding: var(--ha-space-1) 0; padding: 4px 0;
margin-bottom: var(--ha-space-5); margin-bottom: 20px;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;

View File

@@ -268,14 +268,14 @@ class DialogLightColorFavorite extends LitElement {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: var(--ha-space-6); padding: 24px;
flex: 1; flex: 1;
} }
.modes { .modes {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-end; justify-content: flex-end;
padding: 0 var(--ha-space-6); padding: 0 24px;
} }
.wheel { .wheel {
width: 30px; width: 30px;

View File

@@ -254,14 +254,14 @@ export class HaMoreInfoLightFavoriteColors extends LitElement {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin: calc(var(--ha-space-2) * -1); margin: -8px;
flex-wrap: wrap; flex-wrap: wrap;
max-width: 250px; max-width: 250px;
user-select: none; user-select: none;
} }
.container > * { .container > * {
margin: var(--ha-space-2); margin: 8px;
} }
.color { .color {

View File

@@ -515,7 +515,7 @@ class LightRgbColorPicker extends LitElement {
hr { hr {
border-color: var(--divider-color); border-color: var(--divider-color);
border-bottom: none; border-bottom: none;
margin: var(--ha-space-4) 0; margin: 16px 0;
} }
`, `,
]; ];

View File

@@ -15,22 +15,22 @@ export const moreInfoControlStyle = css`
} }
.controls:not(:last-child) { .controls:not(:last-child) {
margin-bottom: var(--ha-space-6); margin-bottom: 24px;
} }
.controls > *:not(:last-child) { .controls > *:not(:last-child) {
margin-bottom: var(--ha-space-6); margin-bottom: 24px;
} }
.buttons { .buttons {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-bottom: var(--ha-space-3); margin-bottom: 12px;
} }
.buttons > * { .buttons > * {
margin: var(--ha-space-2); margin: 8px;
} }
ha-attributes { ha-attributes {
@@ -38,6 +38,6 @@ export const moreInfoControlStyle = css`
width: 100%; width: 100%;
} }
ha-more-info-control-select-container + ha-attributes:not([empty]) { ha-more-info-control-select-container + ha-attributes:not([empty]) {
margin-top: var(--ha-space-4); margin-top: 16px;
} }
`; `;

View File

@@ -209,7 +209,7 @@ class MoreInfoSirenAdvancedControls extends LitElement {
flex-direction: row; flex-direction: row;
justify-content: center; justify-content: center;
gap: var(--ha-space-4); gap: var(--ha-space-4);
margin-top: var(--ha-space-4); margin-top: 16px;
} }
ha-control-button { ha-control-button {
--control-button-border-radius: var(--ha-border-radius-xl); --control-button-border-radius: var(--ha-border-radius-xl);

View File

@@ -48,7 +48,7 @@ class MoreInfoViewVoiceAssistants extends LitElement {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: var(--ha-space-6); padding: 24px;
flex: 1; flex: 1;
} }
`, `,

View File

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

View File

@@ -52,7 +52,7 @@ class MoreInfoAutomation extends LitElement {
justify-content: space-between; justify-content: space-between;
} }
.actions { .actions {
margin: var(--ha-space-2) 0; margin: 8px 0;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
@@ -60,7 +60,7 @@ class MoreInfoAutomation extends LitElement {
hr { hr {
border-color: var(--divider-color); border-color: var(--divider-color);
border-bottom: none; border-bottom: none;
margin: var(--ha-space-4) 0; margin: 16px 0;
} }
`; `;
} }

View File

@@ -106,7 +106,7 @@ class MoreInfoCamera extends LitElement {
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
box-sizing: border-box; box-sizing: border-box;
padding: var(--ha-space-4); padding: 16px;
z-index: 1; z-index: 1;
gap: var(--ha-space-2); gap: var(--ha-space-2);
} }

View File

@@ -511,7 +511,7 @@ class MoreInfoClimate extends LitElement {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
text-align: center; text-align: center;
margin-bottom: var(--ha-space-10); margin-bottom: 40px;
} }
.current div { .current div {
@@ -534,7 +534,7 @@ class MoreInfoClimate extends LitElement {
font-size: var(--ha-font-size-m); font-size: var(--ha-font-size-m);
line-height: var(--ha-line-height-condensed); line-height: var(--ha-line-height-condensed);
letter-spacing: 0.4px; letter-spacing: 0.4px;
margin-bottom: var(--ha-space-1); margin-bottom: 4px;
} }
.current .value { .current .value {
@@ -545,7 +545,7 @@ class MoreInfoClimate extends LitElement {
} }
ha-select { ha-select {
width: 100%; width: 100%;
margin-top: var(--ha-space-2); margin-top: 8px;
} }
.container-humidity .single-row { .container-humidity .single-row {
@@ -561,7 +561,7 @@ class MoreInfoClimate extends LitElement {
} }
.single-row { .single-row {
padding: var(--ha-space-2) 0; padding: 8px 0;
} }
`, `,
]; ];

View File

@@ -87,7 +87,7 @@ export class MoreInfoConfigurator extends LitElement {
flex-direction: column; flex-direction: column;
} }
p { p {
margin: var(--ha-space-2) 0; margin: 8px 0;
} }
a { a {

View File

@@ -62,7 +62,7 @@ class MoreInfoCounter extends LitElement {
static styles = css` static styles = css`
.actions { .actions {
margin: var(--ha-space-2) 0; margin: 8px 0;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;

View File

@@ -194,7 +194,7 @@ class MoreInfoCover extends LitElement {
align-items: center; align-items: center;
} }
.main-control > * { .main-control > * {
margin: 0 var(--ha-space-2); margin: 0 8px;
} }
`, `,
]; ];

View File

@@ -72,8 +72,8 @@ class MoreInfoDatetime extends LitElement {
justify-content: flex-end; justify-content: flex-end;
} }
ha-date-input + ha-time-input { ha-date-input + ha-time-input {
margin-left: var(--ha-space-1); margin-left: 4px;
margin-inline-start: var(--ha-space-1); margin-inline-start: 4px;
margin-inline-end: initial; margin-inline-end: initial;
} }
`; `;

View File

@@ -100,7 +100,7 @@ class MoreInfoGroup extends LitElement {
css` css`
state-card-content { state-card-content {
display: block; display: block;
margin-top: var(--ha-space-2); margin-top: 8px;
} }
`, `,
]; ];

View File

@@ -217,7 +217,7 @@ class MoreInfoHumidifier extends LitElement {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
text-align: center; text-align: center;
margin-bottom: var(--ha-space-10); margin-bottom: 40px;
} }
.current div { .current div {
display: flex; display: flex;
@@ -237,7 +237,7 @@ class MoreInfoHumidifier extends LitElement {
font-size: var(--ha-font-size-m); font-size: var(--ha-font-size-m);
line-height: var(--ha-line-height-condensed); line-height: var(--ha-line-height-condensed);
letter-spacing: 0.4px; letter-spacing: 0.4px;
margin-bottom: var(--ha-space-1); margin-bottom: 4px;
} }
.current .value { .current .value {
font-size: var(--ha-font-size-xl); font-size: var(--ha-font-size-xl);

View File

@@ -84,8 +84,8 @@ class MoreInfoInputDatetime extends LitElement {
justify-content: flex-end; justify-content: flex-end;
} }
ha-date-input + ha-time-input { ha-date-input + ha-time-input {
margin-left: var(--ha-space-1); margin-left: 4px;
margin-inline-start: var(--ha-space-1); margin-inline-start: 4px;
margin-inline-end: initial; margin-inline-end: initial;
} }
`; `;

View File

@@ -192,7 +192,7 @@ class MoreInfoLock extends LitElement {
margin: 0 auto; margin: 0 auto;
} }
ha-control-button-group + ha-attributes:not([empty]) { ha-control-button-group + ha-attributes:not([empty]) {
margin-top: var(--ha-space-4); margin-top: 16px;
} }
@keyframes pulse { @keyframes pulse {
0% { 0% {

View File

@@ -528,11 +528,11 @@ class MoreInfoMediaPlayer extends LitElement {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--ha-space-3); gap: var(--ha-space-3);
margin-left: var(--ha-space-2); margin-left: 8px;
} }
.volume ha-svg-icon { .volume ha-svg-icon {
padding: var(--ha-space-1); padding: 4px;
height: 16px; height: 16px;
width: 16px; width: 16px;
} }
@@ -545,17 +545,17 @@ class MoreInfoMediaPlayer extends LitElement {
.badge { .badge {
position: absolute; position: absolute;
top: -10px; top: -10px;
left: var(--ha-space-4); left: 16px;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
height: 16px; height: 16px;
min-width: var(--ha-space-2); min-width: 8px;
border-radius: var(--ha-border-radius-md); border-radius: var(--ha-border-radius-md);
font-weight: var(--ha-font-weight-normal); font-weight: var(--ha-font-weight-normal);
font-size: var(--ha-font-size-xs); font-size: var(--ha-font-size-xs);
background-color: var(--primary-color); background-color: var(--primary-color);
padding: 0 var(--ha-space-1); padding: 0 4px;
color: var(--text-primary-color); color: var(--text-primary-color);
} }
@@ -572,13 +572,13 @@ class MoreInfoMediaPlayer extends LitElement {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
margin: var(--ha-space-2) 0 var(--ha-space-2) var(--ha-space-2); margin: 8px 0 8px 8px;
} }
.media-title { .media-title {
font-size: var(--ha-font-size-xl); font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-bold); font-weight: var(--ha-font-weight-bold);
margin-bottom: var(--ha-space-1); margin-bottom: 4px;
} }
.media-artist { .media-artist {

View File

@@ -72,12 +72,12 @@ class MoreInfoPerson extends LitElement {
justify-content: space-between; justify-content: space-between;
} }
.actions { .actions {
margin: var(--ha-space-2) 0; margin: 8px 0;
text-align: right; text-align: right;
} }
ha-map { ha-map {
margin-top: var(--ha-space-4); margin-top: 16px;
margin-bottom: var(--ha-space-4); margin-bottom: 16px;
} }
`; `;
} }

View File

@@ -227,24 +227,24 @@ class MoreInfoScript extends LitElement {
visibility: hidden; visibility: hidden;
color: var(--secondary-text-color); color: var(--secondary-text-color);
text-align: center; text-align: center;
margin-bottom: var(--ha-space-4); margin-bottom: 16px;
height: 21px; height: 21px;
} }
.queue.has-queue { .queue.has-queue {
visibility: visible; visibility: visible;
} }
.fields { .fields {
padding: var(--ha-space-4); padding: 16px;
border: 1px solid var(--divider-color); border: 1px solid var(--divider-color);
border-radius: var(--ha-border-radius-md); border-radius: var(--ha-border-radius-md);
margin-bottom: var(--ha-space-4); margin-bottom: 16px;
} }
.fields .title { .fields .title {
font-weight: var(--ha-font-weight-bold); font-weight: var(--ha-font-weight-bold);
} }
ha-control-button ha-svg-icon { ha-control-button ha-svg-icon {
z-index: -1; z-index: -1;
margin-right: var(--ha-space-1); margin-right: 4px;
} }
ha-service-control { ha-service-control {
--service-control-padding: 0; --service-control-padding: 0;
@@ -252,7 +252,7 @@ class MoreInfoScript extends LitElement {
} }
ha-markdown { ha-markdown {
text-align: center; text-align: center;
padding: 0 var(--ha-space-4); padding: 0 16px;
} }
`; `;
} }

View File

@@ -83,7 +83,7 @@ class MoreInfoSun extends LitElement {
hr { hr {
border-color: var(--divider-color); border-color: var(--divider-color);
border-bottom: none; border-bottom: none;
margin: var(--ha-space-4) 0; margin: 16px 0;
} }
`; `;
} }

View File

@@ -80,7 +80,7 @@ class MoreInfoTimer extends LitElement {
static styles = css` static styles = css`
.actions { .actions {
margin: var(--ha-space-2) 0; margin: 8px 0;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;

View File

@@ -448,14 +448,14 @@ class MoreInfoUpdate extends LitElement {
hr { hr {
border-color: var(--divider-color); border-color: var(--divider-color);
border-bottom: none; border-bottom: none;
margin: var(--ha-space-4) 0; margin: 16px 0;
} }
ha-expansion-panel { ha-expansion-panel {
margin: var(--ha-space-4) 0; margin: 16px 0;
} }
.summary { .summary {
margin-bottom: var(--ha-space-4); margin-bottom: 16px;
} }
.row { .row {
@@ -473,10 +473,8 @@ class MoreInfoUpdate extends LitElement {
); );
position: sticky; position: sticky;
bottom: 0; bottom: 0;
margin: 0 calc(var(--ha-space-6) * -1) 0 calc(var(--ha-space-6) * -1); margin: 0 -24px 0 -24px;
margin-bottom: calc( margin-bottom: calc(-1 * max(var(--safe-area-inset-bottom), 24px));
-1 * max(var(--safe-area-inset-bottom), var(--ha-space-6))
);
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -488,8 +486,8 @@ class MoreInfoUpdate extends LitElement {
ha-md-list { ha-md-list {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
margin-bottom: calc(var(--ha-space-4) * -1); margin-bottom: -16px;
margin-top: calc(var(--ha-space-1) * -1); margin-top: -4px;
--md-sys-color-surface: var( --md-sys-color-surface: var(
--ha-dialog-surface-background, --ha-dialog-surface-background,
var(--mdc-theme-surface, #fff) var(--mdc-theme-surface, #fff)
@@ -497,8 +495,8 @@ class MoreInfoUpdate extends LitElement {
} }
ha-md-list-item { ha-md-list-item {
--md-list-item-leading-space: var(--ha-space-6); --md-list-item-leading-space: 24px;
--md-list-item-trailing-space: var(--ha-space-6); --md-list-item-trailing-space: 24px;
} }
.actions { .actions {
@@ -508,7 +506,7 @@ class MoreInfoUpdate extends LitElement {
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
box-sizing: border-box; box-sizing: border-box;
padding: var(--ha-space-4); padding: 16px;
z-index: 1; z-index: 1;
gap: var(--ha-space-2); gap: var(--ha-space-2);
} }
@@ -522,12 +520,12 @@ class MoreInfoUpdate extends LitElement {
align-items: center; align-items: center;
} }
mwc-linear-progress { mwc-linear-progress {
margin-bottom: calc(var(--ha-space-2) * -1); margin-bottom: -8px;
margin-top: var(--ha-space-1); margin-top: 4px;
} }
ha-markdown { ha-markdown {
direction: ltr; direction: ltr;
padding-bottom: var(--ha-space-4); padding-bottom: 16px;
box-sizing: border-box; box-sizing: border-box;
} }
ha-markdown.hidden { ha-markdown.hidden {
@@ -536,7 +534,7 @@ class MoreInfoUpdate extends LitElement {
.loader { .loader {
height: 80px; height: 80px;
box-sizing: border-box; box-sizing: border-box;
padding-bottom: var(--ha-space-4); padding-bottom: 16px;
} }
`; `;
} }

View File

@@ -173,7 +173,7 @@ class MoreInfoValve extends LitElement {
align-items: center; align-items: center;
} }
.main-control > * { .main-control > * {
margin: 0 var(--ha-space-2); margin: 0 8px;
} }
`, `,
]; ];

View File

@@ -214,7 +214,7 @@ class MoreInfoWaterHeater extends LitElement {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
text-align: center; text-align: center;
margin-bottom: var(--ha-space-10); margin-bottom: 40px;
} }
.current div { .current div {
@@ -237,7 +237,7 @@ class MoreInfoWaterHeater extends LitElement {
font-size: var(--ha-font-size-m); font-size: var(--ha-font-size-m);
line-height: var(--ha-line-height-condensed); line-height: var(--ha-line-height-condensed);
letter-spacing: 0.4px; letter-spacing: 0.4px;
margin-bottom: var(--ha-space-1); margin-bottom: 4px;
} }
.current .value { .current .value {

View File

@@ -440,13 +440,13 @@ class MoreInfoWeather extends LitElement {
css` css`
ha-svg-icon { ha-svg-icon {
color: var(--state-icon-color); color: var(--state-icon-color);
margin-left: var(--ha-space-2); margin-left: 8px;
margin-inline-start: var(--ha-space-2); margin-inline-start: 8px;
margin-inline-end: initial; margin-inline-end: initial;
} }
.section { .section {
margin: var(--ha-space-4) 0 var(--ha-space-2) 0; margin: 16px 0 8px 0;
font-size: 1.2em; font-size: 1.2em;
} }
@@ -470,14 +470,14 @@ class MoreInfoWeather extends LitElement {
.main { .main {
flex: 1; flex: 1;
margin-left: var(--ha-space-6); margin-left: 24px;
margin-inline-start: var(--ha-space-6); margin-inline-start: 24px;
margin-inline-end: initial; margin-inline-end: initial;
} }
.attribution { .attribution {
text-align: center; text-align: center;
margin-top: var(--ha-space-4); margin-top: 16px;
} }
.time-ago, .time-ago,
@@ -500,15 +500,15 @@ class MoreInfoWeather extends LitElement {
flex-wrap: nowrap; flex-wrap: nowrap;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: var(--ha-space-4); margin-bottom: 16px;
} }
.icon-image { .icon-image {
display: flex; display: flex;
align-items: center; align-items: center;
min-width: 64px; min-width: 64px;
margin-right: var(--ha-space-4); margin-right: 16px;
margin-inline-end: var(--ha-space-4); margin-inline-end: 16px;
margin-inline-start: initial; margin-inline-start: initial;
} }
@@ -534,7 +534,7 @@ class MoreInfoWeather extends LitElement {
.temp-attribute .temp { .temp-attribute .temp {
position: relative; position: relative;
margin-right: var(--ha-space-6); margin-right: 24px;
direction: ltr; direction: ltr;
} }
@@ -557,8 +557,8 @@ class MoreInfoWeather extends LitElement {
.name-state { .name-state {
overflow: hidden; overflow: hidden;
padding-right: var(--ha-space-3); padding-right: 12px;
padding-inline-end: var(--ha-space-3); padding-inline-end: 12px;
padding-inline-start: initial; padding-inline-start: initial;
width: 100%; width: 100%;
} }
@@ -572,7 +572,7 @@ class MoreInfoWeather extends LitElement {
.forecast { .forecast {
display: flex; display: flex;
justify-content: space-around; justify-content: space-around;
padding: var(--ha-space-4); padding: 16px;
padding-bottom: 0px; padding-bottom: 0px;
overflow-x: auto; overflow-x: auto;
scrollbar-color: var(--scrollbar-thumb-color) transparent; scrollbar-color: var(--scrollbar-thumb-color) transparent;
@@ -634,8 +634,8 @@ class MoreInfoWeather extends LitElement {
} }
.forecast-image-icon { .forecast-image-icon {
padding-top: var(--ha-space-1); padding-top: 4px;
padding-bottom: var(--ha-space-1); padding-bottom: 4px;
display: flex; display: flex;
justify-content: center; justify-content: center;
} }

View File

@@ -802,7 +802,8 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
margin: 0 0 calc(var(--ha-space-2) * -1) 0; margin: var(--ha-space-0) var(--ha-space-0)
calc(var(--ha-space-2) * -1) var(--ha-space-0);
} }
.title p { .title p {

View File

@@ -58,7 +58,7 @@ export class MoreInfoHistoryAndLogbook extends LitElement {
display: block; display: block;
} }
ha-more-info-history + ha-more-info-logbook { ha-more-info-history + ha-more-info-logbook {
margin-top: var(--ha-space-4); margin-top: 16px;
} }
`; `;
} }

View File

@@ -264,7 +264,7 @@ export class MoreInfoHistory extends LitElement {
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: var(--ha-space-2); margin-bottom: 8px;
} }
.header > a, .header > a,
a:visited { a:visited {

View File

@@ -130,8 +130,8 @@ export class MoreInfoInfo extends LitElement {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
padding: var(--ha-space-6); padding: 24px;
padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-6)); padding-bottom: max(var(--safe-area-inset-bottom), 24px);
} }
[data-domain="camera"] .content { [data-domain="camera"] .content {
@@ -153,7 +153,7 @@ export class MoreInfoInfo extends LitElement {
ha-more-info-history, ha-more-info-history,
ha-more-info-logbook:not(:last-child) { ha-more-info-logbook:not(:last-child) {
display: block; display: block;
margin-bottom: var(--ha-space-4); margin-bottom: 16px;
} }
ha-alert { ha-alert {

View File

@@ -82,7 +82,7 @@ export class MoreInfoLogbook extends LitElement {
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: var(--ha-space-2); margin-bottom: 8px;
} }
.header > a, .header > a,
a:visited { a:visited {

View File

@@ -83,8 +83,7 @@ export class HaMoreInfoSettings extends LitElement {
return [ return [
css` css`
.content { .content {
padding: var(--ha-space-2) var(--ha-space-6) var(--ha-space-6) padding: 8px 24px 24px 24px;
var(--ha-space-6);
} }
`, `,
]; ];

View File

@@ -119,7 +119,7 @@ class MoreInfoContent extends LitElement {
hui-section { hui-section {
width: 100%; width: 100%;
display: block; display: block;
margin-top: var(--ha-space-4); margin-top: 16px;
} }
`; `;
} }

View File

@@ -1120,7 +1120,7 @@ export class QuickBar extends LitElement {
} }
.nothing-found { .nothing-found {
padding: var(--ha-space-4) 0; padding: var(--ha-space-4) var(--ha-space-0);
text-align: center; text-align: center;
} }

View File

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

View File

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

View File

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

View File

@@ -123,8 +123,8 @@ export const ScrollableFadeMixin = <T extends Constructor<LitElement>>(
.fade-top, .fade-top,
.fade-bottom { .fade-bottom {
position: absolute; position: absolute;
left: 0; left: var(--ha-space-0);
right: 0; right: var(--ha-space-0);
height: var(--ha-space-4); height: var(--ha-space-4);
pointer-events: none; pointer-events: none;
transition: opacity 180ms ease-in-out; transition: opacity 180ms ease-in-out;
@@ -137,10 +137,10 @@ export const ScrollableFadeMixin = <T extends Constructor<LitElement>>(
opacity: 0; opacity: 0;
} }
.fade-top { .fade-top {
top: 0; top: var(--ha-space-0);
} }
.fade-bottom { .fade-bottom {
bottom: 0; bottom: var(--ha-space-0);
transform: rotate(180deg); transform: rotate(180deg);
} }

View File

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

View File

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

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