Compare commits

..

48 Commits

Author SHA1 Message Date
Aidan Timson
12b70c402d [workaround] pass aria attributes to dialog 2025-10-29 10:00:28 +00:00
Aidan Timson
85c1f69d5e Refactor header slot logic 2025-10-29 09:20:00 +00:00
Aidan Timson
54c5c7ae26 Cleanup 2025-10-27 14:20:31 +00:00
Aidan Timson
e7d6f44bdf Fix typing 2025-10-27 11:38:40 +00:00
Aidan Timson
35d5df5746 Allow for custom slots for header title and subtitle, using boolean to enable 2025-10-27 11:26:05 +00:00
Aidan Timson
cc9b429a25 Migrate dialog-repairs-issue to ha-wa-dialog 2025-10-27 11:23:53 +00:00
ildar170975
a1e2ac1d99 Label picker/selector: add a decription to a list (#27635)
add a decription to getLabels()
2025-10-27 11:11:53 +02:00
Simon Lamon
8ecddbc42c Adjust primary action buttons for create/update category/area dialogs (#27651)
Adjust primary action buttons
2025-10-27 10:52:02 +02:00
dependabot[bot]
6f70ef52a5 Bump actions/upload-artifact from 4.6.2 to 5.0.0 (#27660)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.2 to 5.0.0.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](ea165f8d65...330a01c490)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 10:49:12 +02:00
renovate[bot]
7dff02d7c8 Update dependency @types/sortablejs to v1.15.9 (#27659)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-27 06:35:59 +00:00
Simon Lamon
8bbd7a6a06 Add min and max values for hardware graphs (#27649) 2025-10-27 08:31:24 +02:00
karwosts
5c73a06f76 Regenerate service-picker valueRenderer on localize update (#27640)
* Regenerate service-picker valueRenderer on localize update

* memoize services
2025-10-27 08:30:17 +02:00
Simon Lamon
9943dae82c Adjust line height in weather card (#27653) 2025-10-27 08:29:17 +02:00
renovate[bot]
70bf049df0 Update babel monorepo to v7.28.5 (#27655)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-27 08:28:29 +02:00
renovate[bot]
f9d9fbb7f0 Update vitest monorepo to v4.0.2 (#27656)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-27 08:28:00 +02:00
dependabot[bot]
9cb84d3f37 Bump github/codeql-action from 4.30.9 to 4.31.0 (#27661)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.30.9 to 4.31.0.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](16140ae1a1...4e94bd11f7)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.31.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 08:27:30 +02:00
renovate[bot]
c1bcf27cf8 Update dependency @types/qrcode to v1.5.6 (#27658)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-27 08:26:41 +02:00
renovate[bot]
164ec2a9b5 Update vitest monorepo to v4 (major) (#27638)
Update vitest monorepo to v4

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-26 15:34:28 +01:00
renovate[bot]
20001a551c Update CodeMirror (#27647)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-26 15:28:28 +01:00
renovate[bot]
b7f85bf733 Update dependency @rsdoctor/rspack-plugin to v1.3.4 (#27648)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-26 15:28:00 +01:00
renovate[bot]
b303e9441b Update dependency lint-staged to v16.2.6 (#27644)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-26 07:19:35 +02:00
ildar170975
8f4bd0f620 Fix horiz spacings in ha-select (#27634)
* fix horiz paddings

* prettier

* prettier
2025-10-25 13:37:25 +03:00
Petar Petrov
596346bf59 Improve label layout for pie chart on mobile (#27632) 2025-10-24 16:34:38 +02:00
Paul Bottein
769cea92aa Don't force ratio for area picture (#27630) 2025-10-24 16:12:14 +03:00
Wendelin
f825016514 Add color and icon defaults for config labels table (#27622)
* Add color and icon defaults for config labels table

* use space vars
2025-10-24 14:00:06 +02:00
Aidan Timson
c6fd45bd6a Use space tokens in card features editor (#27625) 2025-10-24 13:34:10 +02:00
Aidan Timson
6c4f4af75c Use space tokens in tile card (#27626) 2025-10-24 13:33:54 +02:00
Aidan Timson
cd5c3ef2f6 Use space tokens in area card (#27627) 2025-10-24 13:33:39 +02:00
dependabot[bot]
636a6fa02e Bump vite from 7.1.6 to 7.1.12 (#27628)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.1.6 to 7.1.12.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.1.12/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.1.12/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.1.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-24 13:17:37 +02:00
Wendelin
21b83426d6 Migrate generic-picker to new design (#27594)
* WIP new combo box

* Use new combo box for generic picker

* Fix esc close and clean up

* Fix empty search list item

* Fix picker usages

* Improve labels picker

* Patch WA to make esc on popover work correctly

* Apply suggestion from @piitaya

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>

* Fix NO_MATCHING_ITEMS_FOUND_ID

* Fix possible undefined boolean props

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2025-10-24 12:52:56 +02:00
Wendelin
c139ec22f9 Use space vars (#27623)
Co-authored-by: Aidan Timson <aidan@timmo.dev>
2025-10-24 09:43:49 +00:00
ildar170975
a6ef3a26da Fix padding for "search-input-outlined" in filters (#27621) 2025-10-24 09:41:50 +00:00
Wendelin
221ca56121 Add automation element dialog: fix blocks only search result (#27618)
Fix exact block search
2025-10-24 11:25:39 +03:00
ildar170975
4e6e3629a8 target picker: use slugify() for tooltips (#27619) 2025-10-24 08:59:14 +01:00
ildar170975
fe94ae0243 ha-media-player-browse: use slugify() for tooltips (#27617) 2025-10-24 09:18:10 +02:00
Wendelin
8a1a22d4bd New design for automation add trigger/condition/action dialog (#27529)
* WIP new add automation element

* WIP new add dialog

* revert merge

* Add tabs

* fix height

* Add max-height

* Add keybindings and blocks search separation

* Fix device translation

* fix translations, scroll issues, RTL

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-10-24 05:53:19 +00:00
renovate[bot]
153a578986 Update dependency typescript-eslint to v8.46.2 (#27610)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-23 21:18:34 +02:00
renovate[bot]
04bb10d0a2 Update dependency lint-staged to v16.2.5 (#27609)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-23 21:18:20 +02:00
Bram Kragten
35e52de2c1 Handle service description might be undefined (#27606) 2025-10-23 21:15:43 +02:00
Petar Petrov
b0862fddaa Fix resizing in pie chart (#27608) 2025-10-23 17:39:43 +03:00
Wendelin
77735f5310 Fix automation sidebar editor rerendering (#27607) 2025-10-23 16:06:53 +02:00
Wendelin
e388756533 ha-wa-dialog show header border on scroll (#27605) 2025-10-23 15:55:46 +02:00
Wendelin
e9ca9bb781 Target picker fix entities count for labels (#27603) 2025-10-23 15:53:48 +02:00
Tobias Bieniek
e48918442c Invert floor sort order to match physical layout (#27580) 2025-10-23 15:39:58 +02:00
Wendelin
52f37f41f0 Revert "Sidebar profile picture fix alignment in RTL languages" (#27604) 2025-10-23 14:31:59 +01:00
Paul Bottein
4687006fec Add description support to fields in object selector (#27602)
* Add description support to fields in object selector

* Use object

* Rename helper to description
2025-10-23 12:23:28 +00:00
Paul Bottein
aca4ca3066 Align state content picker with entity name picker (#27530)
* Align state content picker with entity name picker

* Fix state content property
2025-10-23 15:16:56 +03:00
Wendelin
3a2c00622a Sidebar profile picture fix alignment in RTL languages (#27578)
* Fix floor entities count

* Fix rtl profile picture
2025-10-23 15:09:15 +03:00
84 changed files with 3161 additions and 2408 deletions

View File

@@ -89,7 +89,7 @@ jobs:
env:
IS_TEST: "true"
- name: Upload bundle stats
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: frontend-bundle-stats
path: build/stats/*.json
@@ -113,7 +113,7 @@ jobs:
env:
IS_TEST: "true"
- name: Upload bundle stats
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: supervisor-bundle-stats
path: build/stats/*.json

View File

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

View File

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

View File

@@ -28,8 +28,8 @@
"dependencies": {
"@babel/runtime": "7.28.4",
"@braintree/sanitize-url": "7.1.1",
"@codemirror/autocomplete": "6.19.0",
"@codemirror/commands": "6.9.0",
"@codemirror/autocomplete": "6.19.1",
"@codemirror/commands": "6.10.0",
"@codemirror/language": "6.11.3",
"@codemirror/legacy-modes": "6.5.2",
"@codemirror/search": "6.5.11",
@@ -52,7 +52,7 @@
"@fullcalendar/list": "6.1.19",
"@fullcalendar/luxon3": "6.1.19",
"@fullcalendar/timegrid": "6.1.19",
"@home-assistant/webawesome": "3.0.0-beta.6.ha.5",
"@home-assistant/webawesome": "3.0.0-beta.6.ha.6",
"@lezer/highlight": "1.2.2",
"@lit-labs/motion": "1.0.9",
"@lit-labs/observers": "2.0.6",
@@ -148,16 +148,16 @@
"xss": "1.0.15"
},
"devDependencies": {
"@babel/core": "7.28.4",
"@babel/core": "7.28.5",
"@babel/helper-define-polyfill-provider": "0.6.5",
"@babel/plugin-transform-runtime": "7.28.3",
"@babel/preset-env": "7.28.3",
"@babel/plugin-transform-runtime": "7.28.5",
"@babel/preset-env": "7.28.5",
"@bundle-stats/plugin-webpack-filter": "4.21.5",
"@lokalise/node-api": "15.3.1",
"@octokit/auth-oauth-device": "8.0.2",
"@octokit/plugin-retry": "8.0.2",
"@octokit/rest": "22.0.0",
"@rsdoctor/rspack-plugin": "1.3.3",
"@rsdoctor/rspack-plugin": "1.3.4",
"@rspack/core": "1.5.8",
"@rspack/dev-server": "1.1.4",
"@types/babel__plugin-transform-runtime": "7.9.5",
@@ -173,12 +173,12 @@
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.7.1",
"@types/mocha": "10.0.10",
"@types/qrcode": "1.5.5",
"@types/sortablejs": "1.15.8",
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "6.1.13",
"@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "3.2.4",
"@vitest/coverage-v8": "4.0.2",
"babel-loader": "10.0.0",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
@@ -203,7 +203,7 @@
"husky": "9.1.7",
"jsdom": "27.0.1",
"jszip": "3.10.1",
"lint-staged": "16.2.4",
"lint-staged": "16.2.6",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.5.0",
@@ -217,9 +217,9 @@
"terser-webpack-plugin": "5.3.14",
"ts-lit-plugin": "2.0.2",
"typescript": "5.9.3",
"typescript-eslint": "8.46.1",
"typescript-eslint": "8.46.2",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.4",
"vitest": "4.0.2",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"

View File

@@ -88,9 +88,19 @@ export class HaChartBase extends LitElement {
private _lastTapTime?: number;
private _shouldResizeChart = false;
// @ts-ignore
private _resizeController = new ResizeController(this, {
callback: () => this.chart?.resize(),
callback: () => {
if (this.chart) {
if (!this.chart.getZr().animation.isFinished()) {
this._shouldResizeChart = true;
} else {
this.chart.resize();
}
}
},
});
private _loading = false;
@@ -366,6 +376,7 @@ export class HaChartBase extends LitElement {
if (!this.options?.dataZoom) {
this.chart.getZr().on("dblclick", this._handleClickZoom);
}
this.chart.on("finished", this._handleChartRenderFinished);
if (this._isTouchDevice) {
this.chart.getZr().on("click", (e: ECElementEvent) => {
if (!e.zrByTouch) {
@@ -945,6 +956,13 @@ export class HaChartBase extends LitElement {
});
}
private _handleChartRenderFinished = () => {
if (this._shouldResizeChart) {
this.chart?.resize();
this._shouldResizeChart = false;
}
};
static styles = css`
:host {
display: block;

View File

@@ -147,6 +147,7 @@ class HaEntitiesPicker extends LitElement {
.createDomains=${this.createDomains}
.required=${this.required && !currentEntities.length}
@value-changed=${this._addEntity}
add-button
></ha-entity-picker>
</div>
`;

View File

@@ -113,6 +113,9 @@ export class HaEntityPicker extends LitElement {
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@property({ attribute: "add-button", type: Boolean })
public addButton = false;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
protected firstUpdated(changedProperties: PropertyValues): void {
@@ -281,7 +284,7 @@ export class HaEntityPicker extends LitElement {
.searchLabel=${this.searchLabel}
.notFoundLabel=${notFoundLabel}
.placeholder=${placeholder}
.value=${this.value}
.value=${this.addButton ? undefined : this.value}
.rowRenderer=${this._rowRenderer}
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
@@ -289,6 +292,9 @@ export class HaEntityPicker extends LitElement {
.searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer}
@value-changed=${this._valueChanged}
.addButtonLabel=${this.addButton
? this.hass.localize("ui.components.entity.entity-picker.add")
: undefined}
>
</ha-generic-picker>
`;

View File

@@ -1,23 +1,39 @@
import { mdiDragHorizontalVariant } from "@mdi/js";
import "@material/mwc-menu/mwc-menu-surface";
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 { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { computeDomain } from "../../common/entity/compute_domain";
import {
STATE_DISPLAY_SPECIAL_CONTENT,
STATE_DISPLAY_SPECIAL_CONTENT_DOMAINS,
} from "../../state-display/state-display";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-combo-box";
import "../ha-sortable";
import "../chips/ha-input-chip";
import "../chips/ha-assist-chip";
import "../chips/ha-chip-set";
import "../chips/ha-input-chip";
import "../ha-combo-box";
import type { HaComboBox } from "../ha-combo-box";
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 = [
"access_token",
@@ -74,7 +90,7 @@ const HIDDEN_ATTRIBUTES = [
];
@customElement("ha-entity-state-content-picker")
class HaEntityStatePicker extends LitElement {
export class HaStateContentPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId?: string;
@@ -95,26 +111,28 @@ class HaEntityStatePicker extends LitElement {
@property() public helper?: string;
@state() private _opened = false;
@query(".container", true) private _container?: HTMLDivElement;
@query("ha-combo-box", true) private _comboBox!: HaComboBox;
protected shouldUpdate(changedProps: PropertyValues) {
return !(!changedProps.has("_opened") && this._opened);
}
@state() private _opened = false;
private options = memoizeOne(
private _editIndex?: number;
private _options = memoizeOne(
(entityId?: string, stateObj?: HassEntity, allowName?: boolean) => {
const domain = entityId ? computeDomain(entityId) : undefined;
return [
{
label: this.hass.localize("ui.components.state-content-picker.state"),
primary: this.hass.localize(
"ui.components.state-content-picker.state"
),
value: "state",
},
...(allowName
? [
{
label: this.hass.localize(
primary: this.hass.localize(
"ui.components.state-content-picker.name"
),
value: "name",
@@ -122,13 +140,13 @@ class HaEntityStatePicker extends LitElement {
]
: []),
{
label: this.hass.localize(
primary: this.hass.localize(
"ui.components.state-content-picker.last_changed"
),
value: "last_changed",
},
{
label: this.hass.localize(
primary: this.hass.localize(
"ui.components.state-content-picker.last_updated"
),
value: "last_updated",
@@ -137,7 +155,7 @@ class HaEntityStatePicker extends LitElement {
? STATE_DISPLAY_SPECIAL_CONTENT.filter((content) =>
STATE_DISPLAY_SPECIAL_CONTENT_DOMAINS[domain]?.includes(content)
).map((content) => ({
label: this.hass.localize(
primary: this.hass.localize(
`ui.components.state-content-picker.${content}`
),
value: content,
@@ -146,108 +164,201 @@ class HaEntityStatePicker extends LitElement {
...Object.keys(stateObj?.attributes ?? {})
.filter((a) => !HIDDEN_ATTRIBUTES.includes(a))
.map((attribute) => ({
primary: this.hass.formatEntityAttributeName(stateObj!, attribute),
value: attribute,
label: this.hass.formatEntityAttributeName(stateObj!, attribute),
})),
];
] satisfies StateContentOption[];
}
);
private _filter = "";
protected render() {
if (!this.hass) {
return nothing;
}
const value = this._value;
const stateObj = this.entityId
? this.hass.states[this.entityId]
: undefined;
const options = this.options(this.entityId, stateObj, this.allowName);
const optionItems = options.filter(
(option) => !this._value.includes(option.value)
);
const options = this._options(this.entityId, stateObj, this.allowName);
return html`
${value?.length
? html`
<ha-sortable
no-style
@item-moved=${this._moveItem}
.disabled=${this.disabled}
handle-selector="button.primary.action"
>
<ha-chip-set>
${repeat(
this._value,
(item) => item,
(item, idx) => {
const label =
options.find((option) => option.value === item)?.label ||
item;
return html`
<ha-input-chip
.idx=${idx}
@remove=${this._removeItem}
.label=${label}
selected
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
${label}
</ha-input-chip>
`;
}
)}
</ha-chip-set>
</ha-sortable>
`
: nothing}
${this.label ? html`<label>${this.label}</label>` : nothing}
<div class="container ${this.disabled ? "disabled" : ""}">
<ha-sortable
no-style
@item-moved=${this._moveItem}
.disabled=${this.disabled}
handle-selector="button.primary.action"
filter=".add"
>
<ha-chip-set>
${repeat(
this._value,
(item) => item,
(item: string, idx) => {
const label = options.find((o) => o.value === item)?.primary;
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>
<ha-combo-box
item-value-path="value"
item-label-path="label"
.hass=${this.hass}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.value=${""}
.items=${optionItems}
allow-custom-value
@filter-changed=${this._filterChanged}
@value-changed=${this._comboBoxValueChanged}
@opened-changed=${this._openedChanged}
></ha-combo-box>
<mwc-menu-surface
.open=${this._opened}
@closed=${this._onClosed}
@opened=${this._onOpened}
@input=${stopPropagation}
.anchor=${this._container}
>
<ha-combo-box
.hass=${this.hass}
.value=${""}
.autofocus=${this.autofocus}
.disabled=${this.disabled || !this.entityId}
.required=${this.required && !value.length}
.helper=${this.helper}
.items=${options}
allow-custom-value
item-id-path="value"
item-value-path="value"
item-label-path="primary"
.renderer=${rowRenderer}
@opened-changed=${this._openedChanged}
@value-changed=${this._comboBoxValueChanged}
@filter-changed=${this._filterChanged}
>
</ha-combo-box>
</mwc-menu-surface>
</div>
`;
}
private _onClosed(ev) {
ev.stopPropagation();
this._opened = false;
this._editIndex = undefined;
}
private async _onOpened(ev) {
if (!this._opened) {
return;
}
ev.stopPropagation();
this._opened = true;
await this._comboBox?.focus();
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._opened = true;
}
private get _value() {
return !this.value ? [] : ensureArray(this.value);
}
private _toValue = memoizeOne((value: string[]): typeof this.value => {
if (value.length === 0) {
return undefined;
}
if (value.length === 1) {
return value[0];
}
return value;
});
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
this._comboBox.filteredItems = this._comboBox.items;
const open = ev.detail.value;
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;
}
}
private _filterChanged(ev?: CustomEvent): void {
this._filter = ev?.detail.value || "";
private _filterSelectedOptions = (
options: StateContentOption[],
current?: string
) => {
const value = this._value;
const filteredItems = this._comboBox.items?.filter((item) => {
const label = item.label || item.value;
return label.toLowerCase().includes(this._filter?.toLowerCase());
});
return options.filter(
(option) => !value.includes(option.value) || option.value === current
);
};
if (this._filter) {
filteredItems?.unshift({ label: this._filter, value: this._filter });
private _filterChanged(ev: ValueChangedEvent<string>) {
const input = ev.detail.value;
const filter = input?.toLowerCase() || "";
const options = this._comboBox.items || [];
const currentValue =
this._editIndex != null ? this._value[this._editIndex] : "";
this._comboBox.filteredItems = this._filterSelectedOptions(
options,
currentValue
);
if (!filter) {
return;
}
const fuseOptions: IFuseOptions<StateContentOption> = {
keys: ["primary", "secondary", "value"],
isCaseSensitive: false,
minMatchCharLength: Math.min(filter.length, 2),
threshold: 0.2,
ignoreDiacritics: true,
};
const fuse = new Fuse(this._comboBox.filteredItems, fuseOptions);
const filteredItems = fuse.search(filter).map((result) => result.item);
this._comboBox.filteredItems = filteredItems;
}
@@ -260,43 +371,40 @@ class HaEntityStatePicker extends LitElement {
newValue.splice(newIndex, 0, element);
this._setValue(newValue);
await this.updateComplete;
this._filterChanged();
this._filterChanged({ detail: { value: "" } } as ValueChangedEvent<string>);
}
private async _removeItem(ev) {
ev.stopPropagation();
const value: string[] = [...this._value];
value.splice(ev.target.idx, 1);
const value = [...this._value];
const idx = parseInt(ev.target.dataset.idx, 10);
value.splice(idx, 1);
this._setValue(value);
await this.updateComplete;
this._filterChanged();
this._filterChanged({ detail: { value: "" } } as ValueChangedEvent<string>);
}
private _comboBoxValueChanged(ev: CustomEvent): void {
private _comboBoxValueChanged(ev: ValueChangedEvent<string>): void {
ev.stopPropagation();
const newValue = ev.detail.value;
const value = ev.detail.value;
if (this.disabled || newValue === "") {
if (this.disabled || value === "") {
return;
}
const currentValue = this._value;
const newValue = [...this._value];
if (currentValue.includes(newValue)) {
return;
if (this._editIndex != null) {
newValue[this._editIndex] = value;
} else {
newValue.push(value);
}
setTimeout(() => {
this._filterChanged();
this._comboBox.setInputValue("");
}, 0);
this._setValue([...currentValue, newValue]);
this._setValue(newValue);
}
private _setValue(value: string[]) {
const newValue =
value.length === 0 ? undefined : value.length === 1 ? value[0] : value;
const newValue = this._toValue(value);
this.value = newValue;
fireEvent(this, "value-changed", {
value: newValue,
@@ -306,10 +414,64 @@ class HaEntityStatePicker extends LitElement {
static styles = css`
:host {
position: relative;
width: 100%;
}
.container {
position: relative;
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-radius: var(--ha-border-radius-sm);
border-end-end-radius: var(--ha-border-radius-square);
border-end-start-radius: var(--ha-border-radius-square);
}
.container:after {
display: block;
content: "";
position: absolute;
pointer-events: none;
bottom: 0;
left: 0;
right: 0;
height: 1px;
width: 100%;
background-color: var(
--mdc-text-field-idle-line-color,
rgba(0, 0, 0, 0.42)
);
transform:
height 180ms ease-in-out,
background-color 180ms ease-in-out;
}
.container.disabled:after {
background-color: var(
--mdc-text-field-disabled-line-color,
rgba(0, 0, 0, 0.42)
);
}
.container:focus-within:after {
height: 2px;
background-color: var(--mdc-theme-primary);
}
label {
display: block;
margin: 0 0 var(--ha-space-2);
}
.add {
order: 1;
}
mwc-menu-surface {
--mdc-menu-min-width: 100%;
}
ha-chip-set {
padding: 8px 0;
padding: var(--ha-space-2) var(--ha-space-2);
}
.invalid {
text-decoration: line-through;
}
.sortable-fallback {
@@ -329,6 +491,6 @@ class HaEntityStatePicker extends LitElement {
declare global {
interface HTMLElementTagNameMap {
"ha-entity-state-content-picker": HaEntityStatePicker;
"ha-entity-state-content-picker": HaStateContentPicker;
}
}

View File

@@ -46,7 +46,7 @@ export class HaAnalytics extends LitElement {
</span>
<ha-switch
@change=${this._handleRowClick}
.checked=${baseEnabled}
.checked=${!!baseEnabled}
.preference=${"base"}
.disabled=${loading}
name="base"
@@ -70,7 +70,7 @@ export class HaAnalytics extends LitElement {
<ha-switch
.id="switch-${preference}"
@change=${this._handleRowClick}
.checked=${this.analytics?.preferences[preference]}
.checked=${!!this.analytics?.preferences[preference]}
.preference=${preference}
name=${preference}
>
@@ -102,7 +102,7 @@ export class HaAnalytics extends LitElement {
</span>
<ha-switch
@change=${this._handleRowClick}
.checked=${this.analytics?.preferences.diagnostics}
.checked=${!!this.analytics?.preferences.diagnostics}
.preference=${"diagnostics"}
.disabled=${loading}
name="diagnostics"

View File

@@ -1,6 +1,7 @@
import "@home-assistant/webawesome/dist/components/drawer/drawer";
import { css, html, LitElement, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { haStyleScrollbar } from "../resources/styles";
export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300;
@@ -37,49 +38,61 @@ export class HaBottomSheet extends LitElement {
@wa-after-hide=${this._handleAfterHide}
without-header
>
<slot></slot>
<slot name="header"></slot>
<div class="body ha-scrollbar">
<slot></slot>
</div>
</wa-drawer>
`;
}
static styles = css`
wa-drawer {
--wa-color-surface-raised: transparent;
--spacing: 0;
--size: var(--ha-bottom-sheet-height, auto);
--show-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
--hide-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
}
wa-drawer::part(dialog) {
max-height: var(--ha-bottom-sheet-max-height, 90vh);
align-items: center;
}
wa-drawer::part(body) {
max-width: var(--ha-bottom-sheet-max-width);
width: 100%;
border-top-left-radius: var(
--ha-bottom-sheet-border-radius,
var(--ha-dialog-border-radius, var(--ha-border-radius-2xl))
);
border-top-right-radius: var(
--ha-bottom-sheet-border-radius,
var(--ha-dialog-border-radius, var(--ha-border-radius-2xl))
);
background-color: var(
--ha-bottom-sheet-surface-background,
var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)),
);
padding: var(
--ha-bottom-sheet-padding,
0 var(--safe-area-inset-right) var(--safe-area-inset-bottom)
var(--safe-area-inset-left)
);
}
:host([flexcontent]) wa-drawer::part(body) {
display: flex;
}
`;
static styles = [
haStyleScrollbar,
css`
wa-drawer {
--wa-color-surface-raised: transparent;
--spacing: 0;
--size: var(--ha-bottom-sheet-height, auto);
--show-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
--hide-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
}
wa-drawer::part(dialog) {
max-height: var(--ha-bottom-sheet-max-height, 90vh);
align-items: center;
}
wa-drawer::part(body) {
max-width: var(--ha-bottom-sheet-max-width);
width: 100%;
border-top-left-radius: var(
--ha-bottom-sheet-border-radius,
var(--ha-dialog-border-radius, var(--ha-border-radius-2xl))
);
border-top-right-radius: var(
--ha-bottom-sheet-border-radius,
var(--ha-dialog-border-radius, var(--ha-border-radius-2xl))
);
background-color: var(
--ha-bottom-sheet-surface-background,
var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)),
);
padding: var(
--ha-bottom-sheet-padding,
0 var(--safe-area-inset-right) var(--safe-area-inset-bottom)
var(--safe-area-inset-left)
);
}
:host([flexcontent]) wa-drawer::part(body) {
display: flex;
flex-direction: column;
}
:host([flexcontent]) .body {
flex: 1;
max-width: 100%;
display: flex;
flex-direction: column;
}
`,
];
}
declare global {

View File

@@ -31,6 +31,9 @@ export class HaButtonToggleGroup extends LitElement {
@property({ type: Boolean, reflect: true, attribute: "no-wrap" })
public nowrap = false;
@property({ type: Boolean, reflect: true, attribute: "full-width" })
public fullWidth = false;
@property() public variant:
| "brand"
| "neutral"
@@ -38,6 +41,13 @@ export class HaButtonToggleGroup extends LitElement {
| "warning"
| "danger" = "brand";
@property({ attribute: "active-variant" }) public activeVariant?:
| "brand"
| "neutral"
| "success"
| "warning"
| "danger";
protected render(): TemplateResult {
return html`
<wa-button-group childSelector="ha-button">
@@ -46,7 +56,9 @@ export class HaButtonToggleGroup extends LitElement {
html`<ha-button
iconTag="ha-svg-icon"
class="icon"
.variant=${this.variant}
.variant=${this.active !== button.value || !this.activeVariant
? this.variant
: this.activeVariant}
.size=${this.size}
.value=${button.value}
@click=${this._handleClick}
@@ -78,6 +90,19 @@ export class HaButtonToggleGroup extends LitElement {
:host([no-wrap]) wa-button-group::part(base) {
flex-wrap: nowrap;
}
wa-button-group {
padding: var(--ha-button-toggle-group-padding);
}
:host([full-width]) wa-button-group,
:host([full-width]) wa-button-group::part(base) {
width: 100%;
}
:host([full-width]) ha-button {
flex: 1;
}
`;
}

View File

@@ -6,6 +6,9 @@ export class HaDialogHeader extends LitElement {
@property({ type: String, attribute: "subtitle-position" })
public subtitlePosition: "above" | "below" = "below";
@property({ type: Boolean, reflect: true, attribute: "show-border" })
public showBorder = false;
protected render() {
const titleSlot = html`<div class="header-title">
<slot name="title"></slot>

View File

@@ -248,7 +248,7 @@ export class HaFilterDevices extends LitElement {
}
search-input-outlined {
display: block;
padding: 0 8px;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`,
];

View File

@@ -199,7 +199,7 @@ export class HaFilterDomains extends LitElement {
}
search-input-outlined {
display: block;
padding: 0 8px;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`,
];

View File

@@ -264,7 +264,7 @@ export class HaFilterEntities extends LitElement {
}
search-input-outlined {
display: block;
padding: 0 8px;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`,
];

View File

@@ -217,7 +217,7 @@ export class HaFilterIntegrations extends LitElement {
}
search-input-outlined {
display: block;
padding: 0 8px;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`,
];

View File

@@ -256,7 +256,7 @@ export class HaFilterLabels extends SubscribeMixin(LitElement) {
}
search-input-outlined {
display: block;
padding: 0 8px;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`,
];

View File

@@ -1,10 +1,14 @@
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light";
import "@home-assistant/webawesome/dist/components/popover/popover";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiPlaylistPlus } from "@mdi/js";
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event";
import type { HomeAssistant } from "../types";
import "./ha-bottom-sheet";
import "./ha-button";
import "./ha-combo-box-item";
import "./ha-icon-button";
import "./ha-input-helper-text";
@@ -15,7 +19,7 @@ import type {
PickerComboBoxSearchFn,
} from "./ha-picker-combo-box";
import "./ha-picker-field";
import type { HaPickerField, PickerValueRenderer } from "./ha-picker-field";
import type { PickerValueRenderer } from "./ha-picker-field";
import "./ha-svg-icon";
@customElement("ha-generic-picker")
@@ -53,7 +57,7 @@ export class HaGenericPicker extends LitElement {
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
@property({ attribute: false })
public rowRenderer?: ComboBoxLitRenderer<PickerComboBoxItem>;
public rowRenderer?: RenderItemFunction<PickerComboBoxItem>;
@property({ attribute: false })
public valueRenderer?: PickerValueRenderer;
@@ -64,59 +68,130 @@ export class HaGenericPicker extends LitElement {
@property({ attribute: "not-found-label", type: String })
public notFoundLabel?: string;
@query("ha-picker-field") private _field?: HaPickerField;
/** If set picker shows an add button instead of textbox when value isn't set */
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
@query(".container") private _containerElement?: HTMLDivElement;
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@state() private _opened = false;
@state() private _pickerWrapperOpen = false;
@state() private _popoverWidth = 0;
@state() private _openedNarrow = false;
private _narrow = false;
// helper to set new value after closing picker, to avoid flicker
private _newValue?: string;
private _unsubscribeTinyKeys?: () => void;
protected render() {
return html`
${this.label
? html`<label ?disabled=${this.disabled}>${this.label}</label>`
: nothing}
<div class="container">
${!this._opened
<div id="picker">
<slot name="field">
${this.addButtonLabel && !this.value
? html`<ha-button
size="small"
appearance="filled"
@click=${this.open}
.disabled=${this.disabled}
>
<ha-svg-icon
.path=${mdiPlaylistPlus}
slot="start"
></ha-svg-icon>
${this.addButtonLabel}
</ha-button>`
: html`<ha-picker-field
type="button"
class=${this._opened ? "opened" : ""}
compact
aria-label=${ifDefined(this.label)}
@click=${this.open}
@clear=${this._clear}
.placeholder=${this.placeholder}
.value=${this.value}
.required=${this.required}
.disabled=${this.disabled}
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${this.valueRenderer}
>
</ha-picker-field>`}
</slot>
</div>
${!this._openedNarrow && (this._pickerWrapperOpen || this._opened)
? html`
<ha-picker-field
id="picker"
type="button"
compact
aria-label=${ifDefined(this.label)}
@click=${this.open}
@clear=${this._clear}
.placeholder=${this.placeholder}
.value=${this.value}
.required=${this.required}
.disabled=${this.disabled}
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${this.valueRenderer}
<wa-popover
.open=${this._pickerWrapperOpen}
style="--body-width: ${this._popoverWidth}px;"
without-arrow
distance="-4"
placement="bottom-start"
for="picker"
auto-size="vertical"
auto-size-padding="16"
@wa-after-show=${this._dialogOpened}
@wa-after-hide=${this._hidePicker}
trap-focus
role="dialog"
aria-modal="true"
aria-label=${this.hass.localize(
"ui.components.target-picker.add_target"
)}
>
</ha-picker-field>
${this._renderComboBox()}
</wa-popover>
`
: html`
<ha-picker-combo-box
.hass=${this.hass}
.autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomValue}
.label=${this.searchLabel ??
this.hass.localize("ui.common.search")}
.value=${this.value}
hide-clear-icon
@opened-changed=${this._openedChanged}
@value-changed=${this._valueChanged}
.rowRenderer=${this.rowRenderer}
.notFoundLabel=${this.notFoundLabel}
.getItems=${this.getItems}
.getAdditionalItems=${this.getAdditionalItems}
.searchFn=${this.searchFn}
></ha-picker-combo-box>
`}
: this._pickerWrapperOpen || this._opened
? html`<ha-bottom-sheet
flexcontent
.open=${this._pickerWrapperOpen}
@wa-after-show=${this._dialogOpened}
@closed=${this._hidePicker}
role="dialog"
aria-modal="true"
aria-label=${this.hass.localize(
"ui.components.target-picker.add_target"
)}
>
${this._renderComboBox(true)}
</ha-bottom-sheet>`
: nothing}
</div>
${this._renderHelper()}
`;
}
private _renderComboBox(dialogMode = false) {
if (!this._opened) {
return nothing;
}
return html`
<ha-picker-combo-box
.hass=${this.hass}
.allowCustomValue=${this.allowCustomValue}
.label=${this.searchLabel ?? this.hass.localize("ui.common.search")}
.value=${this.value}
@value-changed=${this._valueChanged}
.rowRenderer=${this.rowRenderer}
.notFoundLabel=${this.notFoundLabel}
.getItems=${this.getItems}
.getAdditionalItems=${this.getAdditionalItems}
.searchFn=${this.searchFn}
.mode=${dialogMode ? "dialog" : "popover"}
></ha-picker-combo-box>
`;
}
private _renderHelper() {
return this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
@@ -125,13 +200,33 @@ export class HaGenericPicker extends LitElement {
: nothing;
}
private _dialogOpened = () => {
this._opened = true;
requestAnimationFrame(() => {
this._comboBox?.focus();
});
};
private _hidePicker(ev) {
ev.stopPropagation();
if (this._newValue) {
fireEvent(this, "value-changed", { value: this._newValue });
this._newValue = undefined;
}
this._opened = false;
this._pickerWrapperOpen = false;
this._unsubscribeTinyKeys?.();
}
private _valueChanged(ev: CustomEvent) {
ev.stopPropagation();
const value = ev.detail.value;
if (!value) {
return;
}
fireEvent(this, "value-changed", { value });
this._pickerWrapperOpen = false;
this._newValue = value;
}
private _clear(e) {
@@ -144,25 +239,45 @@ export class HaGenericPicker extends LitElement {
fireEvent(this, "value-changed", { value });
}
public async open() {
public async open(ev?: Event) {
ev?.stopPropagation();
if (this.disabled) {
return;
}
this._opened = true;
await this.updateComplete;
this._comboBox?.focus();
this._comboBox?.open();
this._openedNarrow = this._narrow;
this._popoverWidth = this._containerElement?.offsetWidth || 250;
this._pickerWrapperOpen = true;
this._unsubscribeTinyKeys = tinykeys(this, {
Escape: this._handleEscClose,
});
}
private async _openedChanged(ev: ComboBoxLightOpenedChangedEvent) {
const opened = ev.detail.value;
if (this._opened && !opened) {
this._opened = false;
await this.updateComplete;
this._field?.focus();
}
connectedCallback() {
super.connectedCallback();
this._handleResize();
window.addEventListener("resize", this._handleResize);
}
public disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("resize", this._handleResize);
this._unsubscribeTinyKeys?.();
}
private _handleResize = () => {
this._narrow =
window.matchMedia("(max-width: 870px)").matches ||
window.matchMedia("(max-height: 500px)").matches;
if (!this._openedNarrow && this._pickerWrapperOpen) {
this._popoverWidth = this._containerElement?.offsetWidth || 250;
}
};
private _handleEscClose = (ev: KeyboardEvent) => {
ev.stopPropagation();
};
static get styles(): CSSResultGroup {
return [
css`
@@ -181,6 +296,44 @@ export class HaGenericPicker extends LitElement {
display: block;
margin: var(--ha-space-2) 0 0;
}
wa-popover {
--wa-space-l: var(--ha-space-0);
}
wa-popover::part(body) {
width: max(var(--body-width), 250px);
max-width: max(var(--body-width), 250px);
max-height: 500px;
height: 70vh;
overflow: hidden;
}
@media (max-height: 1000px) {
wa-popover::part(body) {
max-height: 400px;
}
}
@media (max-height: 1000px) {
wa-popover::part(body) {
max-height: 400px;
}
}
ha-bottom-sheet {
--ha-bottom-sheet-height: 90vh;
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
--ha-bottom-sheet-max-height: var(--ha-bottom-sheet-height);
--ha-bottom-sheet-max-width: 600px;
--ha-bottom-sheet-padding: var(--ha-space-0);
--ha-bottom-sheet-surface-background: var(--card-background-color);
--ha-bottom-sheet-border-radius: var(--ha-border-radius-2xl);
}
ha-picker-field.opened {
--mdc-text-field-idle-line-color: var(--primary-color);
}
`,
];
}

View File

@@ -2,7 +2,13 @@ import { mdiLabel, mdiPlus } from "@mdi/js";
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { LitElement, html } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import {
customElement,
property,
query,
queryAssignedElements,
state,
} from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import type { LabelRegistryEntry } from "../data/label_registry";
@@ -84,6 +90,9 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
@state() private _labels?: LabelRegistryEntry[];
@queryAssignedElements({ flatten: true })
private _slotNodes?: NodeListOf<HTMLElement>;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
public async open() {
@@ -211,12 +220,14 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
return html`
<ha-generic-picker
.disabled=${this.disabled}
.hass=${this.hass}
.autofocus=${this.autofocus}
.label=${this.label}
.notFoundLabel=${this.hass.localize(
"ui.components.label-picker.no_match"
)}
.addButtonLabel=${this.hass.localize("ui.components.label-picker.add")}
.placeholder=${placeholder}
.value=${this.value}
.getItems=${this._getItems}
@@ -224,6 +235,7 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
.valueRenderer=${valueRenderer}
@value-changed=${this._valueChanged}
>
<slot .slot=${this._slotNodes?.length ? "field" : undefined}></slot>
</ha-generic-picker>
`;
}

View File

@@ -1,3 +1,4 @@
import { mdiPlaylistPlus } from "@mdi/js";
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
@@ -123,36 +124,6 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
);
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
${labels?.length
? html`<ha-chip-set>
${repeat(
labels,
(label) => label?.label_id,
(label) => {
const color = label?.color
? computeCssColor(label.color)
: undefined;
return html`
<ha-input-chip
.item=${label}
@remove=${this._removeItem}
@click=${this._openDetail}
.label=${label?.name}
selected
style=${color ? `--color: ${color}` : ""}
>
${label?.icon
? html`<ha-icon
slot="icon"
.icon=${label.icon}
></ha-icon>`
: nothing}
</ha-input-chip>
`;
}
)}
</ha-chip-set>`
: nothing}
<ha-label-picker
.hass=${this.hass}
.helper=${this.helper}
@@ -162,6 +133,47 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
.excludeLabels=${this.value}
@value-changed=${this._labelChanged}
>
<ha-chip-set>
${labels?.length
? repeat(
labels,
(label) => label?.label_id,
(label) => {
const color = label?.color
? computeCssColor(label.color)
: undefined;
return html`
<ha-input-chip
.item=${label}
@remove=${this._removeItem}
@click=${this._openDetail}
.disabled=${this.disabled}
.label=${label?.name}
selected
style=${color ? `--color: ${color}` : ""}
>
${label?.icon
? html`<ha-icon
slot="icon"
.icon=${label.icon}
></ha-icon>`
: nothing}
</ha-input-chip>
`;
}
)
: nothing}
<ha-button
id="picker"
size="small"
appearance="filled"
@click=${this._openPicker}
.disabled=${this.disabled}
>
<ha-svg-icon .path=${mdiPlaylistPlus} slot="start"></ha-svg-icon>
${this.hass.localize("ui.components.label-picker.add")}
</ha-button>
</ha-chip-set>
</ha-label-picker>
`;
}
@@ -203,9 +215,25 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
}, 0);
}
private _openPicker(ev: Event) {
ev.stopPropagation();
this.labelPicker.open();
}
static styles = css`
ha-chip-set {
margin-bottom: 8px;
background-color: var(--mdc-text-field-fill-color);
border-bottom: 1px solid var(--ha-color-border-neutral-normal);
border-top-right-radius: var(--ha-border-radius-sm);
border-top-left-radius: var(--ha-border-radius-sm);
padding: var(--ha-space-3);
}
.placeholder {
color: var(--mdc-text-field-label-ink-color);
display: flex;
align-items: center;
height: var(--ha-space-8);
}
ha-input-chip {
--md-input-chip-selected-container-color: var(--color, var(--grey-color));

View File

@@ -1,19 +1,28 @@
import type { LitVirtualizer } from "@lit-labs/virtualizer";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiMagnify } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import Fuse from "fuse.js";
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { css, html, LitElement, nothing } from "lit";
import {
customElement,
eventOptions,
property,
query,
state,
} from "lit/decorators";
import memoizeOne from "memoize-one";
import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { HaFuse } from "../resources/fuse";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box";
import { haStyleScrollbar } from "../resources/styles";
import { loadVirtualizer } from "../resources/virtualizer";
import type { HomeAssistant } from "../types";
import "./ha-combo-box-item";
import "./ha-icon";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
export interface PickerComboBoxItem {
id: string;
@@ -33,10 +42,13 @@ export interface PickerComboBoxItemWithLabel extends PickerComboBoxItem {
const NO_MATCHING_ITEMS_FOUND_ID = "___no_matching_items_found___";
const DEFAULT_ROW_RENDERER: ComboBoxLitRenderer<PickerComboBoxItem> = (
const DEFAULT_ROW_RENDERER: RenderItemFunction<PickerComboBoxItem> = (
item
) => html`
<ha-combo-box-item type="button" compact>
<ha-combo-box-item
.type=${item.id === NO_MATCHING_ITEMS_FOUND_ID ? "text" : "button"}
compact
>
${item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: item.icon_path
@@ -73,7 +85,7 @@ export class HaPickerComboBox extends LitElement {
@property() public value?: string;
@property() public helper?: string;
@state() private _listScrolled = false;
@property({ attribute: false, type: Array })
public getItems?: () => PickerComboBoxItem[];
@@ -82,10 +94,7 @@ export class HaPickerComboBox extends LitElement {
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
@property({ attribute: false })
public rowRenderer?: ComboBoxLitRenderer<PickerComboBoxItem>;
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
public rowRenderer?: RenderItemFunction<PickerComboBoxItem>;
@property({ attribute: "not-found-label", type: String })
public notFoundLabel?: string;
@@ -93,23 +102,59 @@ export class HaPickerComboBox extends LitElement {
@property({ attribute: false })
public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
@state() private _opened = false;
@property({ reflect: true }) public mode: "popover" | "dialog" = "popover";
@query("ha-combo-box", true) public comboBox!: HaComboBox;
@query("lit-virtualizer") private _virtualizerElement?: LitVirtualizer;
public async open() {
await this.updateComplete;
await this.comboBox?.open();
@query("ha-textfield") private _searchFieldElement?: HaTextField;
@state() private _items: PickerComboBoxItemWithLabel[] = [];
private _allItems: PickerComboBoxItemWithLabel[] = [];
private _selectedItemIndex = -1;
static shadowRootOptions = {
...LitElement.shadowRootOptions,
delegatesFocus: true,
};
private _removeKeyboardShortcuts?: () => void;
protected firstUpdated() {
this._registerKeyboardShortcuts();
}
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
public willUpdate() {
if (!this.hasUpdated) {
loadVirtualizer();
this._allItems = this._getItems();
this._items = this._allItems;
}
}
private _initialItems = false;
disconnectedCallback() {
super.disconnectedCallback();
this._removeKeyboardShortcuts?.();
}
private _items: PickerComboBoxItemWithLabel[] = [];
protected render() {
return html`<ha-textfield
.label=${this.label ?? this.hass.localize("ui.common.search")}
@input=${this._filterChanged}
></ha-textfield>
<lit-virtualizer
@scroll=${this._onScrollList}
tabindex="0"
scroller
.items=${this._items}
.renderItem=${this._renderItem}
style="min-height: 36px;"
class=${this._listScrolled ? "scrolled" : ""}
@focus=${this._focusList}
>
</lit-virtualizer> `;
}
private _defaultNotFoundItem = memoizeOne(
(
@@ -159,94 +204,56 @@ export class HaPickerComboBox extends LitElement {
return sortedItems;
};
protected shouldUpdate(changedProps: PropertyValues) {
if (
changedProps.has("value") ||
changedProps.has("label") ||
changedProps.has("disabled")
) {
return true;
}
return !(!changedProps.has("_opened") && this._opened);
}
private _renderItem = (item: PickerComboBoxItem, index: number) => {
const renderer = this.rowRenderer || DEFAULT_ROW_RENDERER;
return html`<div
id=${`list-item-${index}`}
class="combo-box-row ${this._value === item.id ? "current-value" : ""}"
.value=${item.id}
.index=${index}
@click=${this._valueSelected}
>
${item.id === NO_MATCHING_ITEMS_FOUND_ID
? DEFAULT_ROW_RENDERER(item, index)
: renderer(item, index)}
</div>`;
};
public willUpdate(changedProps: PropertyValues) {
if (changedProps.has("_opened") && this._opened) {
this._items = this._getItems();
if (this._initialItems) {
this.comboBox.filteredItems = this._items;
}
this._initialItems = true;
}
}
protected render(): TemplateResult {
return html`
<ha-combo-box
item-id-path="id"
item-value-path="id"
item-label-path="a11y_label"
clear-initial-value
.hass=${this.hass}
.value=${this._value}
.label=${this.label}
.helper=${this.helper}
.allowCustomValue=${this.allowCustomValue}
.filteredItems=${this._items}
.renderer=${this.rowRenderer || DEFAULT_ROW_RENDERER}
.required=${this.required}
.disabled=${this.disabled}
.hideClearIcon=${this.hideClearIcon}
@opened-changed=${this._openedChanged}
@value-changed=${this._valueChanged}
@filter-changed=${this._filterChanged}
>
</ha-combo-box>
`;
@eventOptions({ passive: true })
private _onScrollList(ev) {
const top = ev.target.scrollTop ?? 0;
this._listScrolled = top > 0;
}
private get _value() {
return this.value || "";
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
private _valueSelected = (ev: Event) => {
ev.stopPropagation();
if (ev.detail.value !== this._opened) {
this._opened = ev.detail.value;
fireEvent(this, "opened-changed", { value: this._opened });
}
}
private _valueChanged(ev: ValueChangedEvent<string | undefined>) {
ev.stopPropagation();
// Clear the input field to prevent showing the old value next time
this.comboBox.setTextFieldValue("");
const newValue = ev.detail.value?.trim();
const value = (ev.currentTarget as any).value as string;
const newValue = value?.trim();
if (newValue === NO_MATCHING_ITEMS_FOUND_ID) {
return;
}
if (newValue !== this._value) {
this._setValue(newValue);
}
}
fireEvent(this, "value-changed", { value: newValue });
};
private _fuseIndex = memoizeOne((states: PickerComboBoxItem[]) =>
Fuse.createIndex(["search_labels"], states)
);
private _filterChanged(ev: CustomEvent): void {
if (!this._opened) return;
private _filterChanged = (ev: Event) => {
const textfield = ev.target as HaTextField;
const searchString = textfield.value.trim();
const target = ev.target as HaComboBox;
const searchString = ev.detail.value.trim() as string;
const index = this._fuseIndex(this._items);
const fuse = new HaFuse(this._items, { shouldSort: false }, index);
const index = this._fuseIndex(this._allItems);
const fuse = new HaFuse(this._allItems, { shouldSort: false }, index);
const results = fuse.multiTermsSearch(searchString);
let filteredItems = this._items as PickerComboBoxItem[];
let filteredItems = this._allItems as PickerComboBoxItem[];
if (results) {
const items = results.map((result) => result.item);
if (items.length === 0) {
@@ -260,17 +267,266 @@ export class HaPickerComboBox extends LitElement {
}
if (this.searchFn) {
filteredItems = this.searchFn(searchString, filteredItems, this._items);
filteredItems = this.searchFn(
searchString,
filteredItems,
this._allItems
);
}
target.filteredItems = filteredItems;
this._items = filteredItems as PickerComboBoxItemWithLabel[];
this._selectedItemIndex = -1;
if (this._virtualizerElement) {
this._virtualizerElement.scrollTo(0, 0);
}
};
private _registerKeyboardShortcuts() {
this._removeKeyboardShortcuts = tinykeys(this, {
ArrowUp: this._selectPreviousItem,
ArrowDown: this._selectNextItem,
Home: this._selectFirstItem,
End: this._selectLastItem,
Enter: this._pickSelectedItem,
});
}
private _setValue(value: string | undefined) {
setTimeout(() => {
fireEvent(this, "value-changed", { value });
}, 0);
private _focusList() {
if (this._selectedItemIndex === -1) {
this._selectNextItem();
}
}
private _selectNextItem = (ev?: KeyboardEvent) => {
ev?.stopPropagation();
ev?.preventDefault();
if (!this._virtualizerElement) {
return;
}
this._searchFieldElement?.focus();
const items = this._virtualizerElement.items as PickerComboBoxItem[];
const maxItems = items.length - 1;
if (maxItems === -1) {
this._resetSelectedItem();
return;
}
const nextIndex =
maxItems === this._selectedItemIndex
? this._selectedItemIndex
: this._selectedItemIndex + 1;
if (!items[nextIndex]) {
return;
}
if (items[nextIndex].id === NO_MATCHING_ITEMS_FOUND_ID) {
// Skip titles, padding and empty search
if (nextIndex === maxItems) {
return;
}
this._selectedItemIndex = nextIndex + 1;
} else {
this._selectedItemIndex = nextIndex;
}
this._scrollToSelectedItem();
};
private _selectPreviousItem = (ev: KeyboardEvent) => {
ev.stopPropagation();
ev.preventDefault();
if (!this._virtualizerElement) {
return;
}
if (this._selectedItemIndex > 0) {
const nextIndex = this._selectedItemIndex - 1;
const items = this._virtualizerElement.items as PickerComboBoxItem[];
if (!items[nextIndex]) {
return;
}
if (items[nextIndex]?.id === NO_MATCHING_ITEMS_FOUND_ID) {
// Skip titles, padding and empty search
if (nextIndex === 0) {
return;
}
this._selectedItemIndex = nextIndex - 1;
} else {
this._selectedItemIndex = nextIndex;
}
this._scrollToSelectedItem();
}
};
private _selectFirstItem = (ev: KeyboardEvent) => {
ev.stopPropagation();
if (!this._virtualizerElement || !this._virtualizerElement.items.length) {
return;
}
const nextIndex = 0;
if (
(this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id ===
NO_MATCHING_ITEMS_FOUND_ID
) {
return;
}
if (typeof this._virtualizerElement.items[nextIndex] === "string") {
this._selectedItemIndex = nextIndex + 1;
} else {
this._selectedItemIndex = nextIndex;
}
this._scrollToSelectedItem();
};
private _selectLastItem = (ev: KeyboardEvent) => {
ev.stopPropagation();
if (!this._virtualizerElement || !this._virtualizerElement.items.length) {
return;
}
const nextIndex = this._virtualizerElement.items.length - 1;
if (
(this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id ===
NO_MATCHING_ITEMS_FOUND_ID
) {
return;
}
if (typeof this._virtualizerElement.items[nextIndex] === "string") {
this._selectedItemIndex = nextIndex - 1;
} else {
this._selectedItemIndex = nextIndex;
}
this._scrollToSelectedItem();
};
private _scrollToSelectedItem = () => {
this._virtualizerElement
?.querySelector(".selected")
?.classList.remove("selected");
this._virtualizerElement?.scrollToIndex(this._selectedItemIndex, "end");
requestAnimationFrame(() => {
this._virtualizerElement
?.querySelector(`#list-item-${this._selectedItemIndex}`)
?.classList.add("selected");
});
};
private _pickSelectedItem = (ev: KeyboardEvent) => {
ev.stopPropagation();
if (this._selectedItemIndex === -1) {
return;
}
// if filter button is focused
ev.preventDefault();
const item: any = this._virtualizerElement?.items[this._selectedItemIndex];
if (item && item.id !== NO_MATCHING_ITEMS_FOUND_ID) {
fireEvent(this, "value-changed", { value: item.id });
}
};
private _resetSelectedItem() {
this._virtualizerElement
?.querySelector(".selected")
?.classList.remove("selected");
this._selectedItemIndex = -1;
}
static styles = [
haStyleScrollbar,
css`
:host {
display: flex;
flex-direction: column;
padding-top: var(--ha-space-3);
flex: 1;
}
ha-textfield {
padding: 0 var(--ha-space-3);
margin-bottom: var(--ha-space-3);
}
:host([mode="dialog"]) ha-textfield {
padding: 0 var(--ha-space-4);
}
ha-combo-box-item {
width: 100%;
}
ha-combo-box-item.selected {
background-color: var(--ha-color-fill-neutral-quiet-hover);
}
@media (prefers-color-scheme: dark) {
ha-combo-box-item.selected {
background-color: var(--ha-color-fill-neutral-normal-hover);
}
}
lit-virtualizer {
flex: 1;
}
lit-virtualizer:focus-visible {
outline: none;
}
lit-virtualizer.scrolled {
border-top: 1px solid var(--ha-color-border-neutral-quiet);
}
.bottom-padding {
height: max(var(--safe-area-inset-bottom, 0px), var(--ha-space-8));
width: 100%;
}
.empty {
text-align: center;
}
.combo-box-row {
display: flex;
width: 100%;
align-items: center;
box-sizing: border-box;
min-height: 36px;
}
.combo-box-row.current-value {
background-color: var(--ha-color-fill-primary-quiet-resting);
}
.combo-box-row.selected {
background-color: var(--ha-color-fill-neutral-quiet-hover);
}
@media (prefers-color-scheme: dark) {
.combo-box-row.selected {
background-color: var(--ha-color-fill-neutral-normal-hover);
}
}
`,
];
}
declare global {

View File

@@ -137,7 +137,7 @@ export class HaSelect extends SelectBase {
height: var(--ha-select-height, 56px);
}
.mdc-select--filled .mdc-floating-label {
inset-inline-start: 12px;
inset-inline-start: var(--ha-space-4);
inset-inline-end: initial;
direction: var(--direction);
}
@@ -147,7 +147,7 @@ export class HaSelect extends SelectBase {
direction: var(--direction);
}
.mdc-select .mdc-select__anchor {
padding-inline-start: 12px;
padding-inline-start: var(--ha-space-4);
padding-inline-end: 0px;
direction: var(--direction);
}
@@ -158,7 +158,10 @@ export class HaSelect extends SelectBase {
padding-inline-end: var(--select-selected-text-padding-end, 0px);
}
:host([clearable]) .mdc-select__selected-text-container {
padding-inline-end: var(--select-selected-text-padding-end, 12px);
padding-inline-end: var(
--select-selected-text-padding-end,
var(--ha-space-4)
);
}
ha-icon-button {
position: absolute;

View File

@@ -52,9 +52,10 @@ export class HaObjectSelector extends LitElement {
const translationKey = this.selector.object?.translation_key;
if (this.localizeValue && translationKey) {
const label = this.localizeValue(
`${translationKey}.fields.${schema.name}`
);
const label =
this.localizeValue(`${translationKey}.fields.${schema.name}.name`) ||
// Fallback for backward compatibility
this.localizeValue(`${translationKey}.fields.${schema.name}`);
if (label) {
return label;
}
@@ -62,6 +63,20 @@ export class HaObjectSelector extends LitElement {
return this.selector.object?.fields?.[schema.name]?.label || schema.name;
};
private _computeHelper = (schema: HaFormSchema): string => {
const translationKey = this.selector.object?.translation_key;
if (this.localizeValue && translationKey) {
const helper = this.localizeValue(
`${translationKey}.fields.${schema.name}.description`
);
if (helper) {
return helper;
}
}
return this.selector.object?.fields?.[schema.name]?.description || "";
};
private _renderItem(item: any, index: number) {
const labelField =
this.selector.object!.label_field ||
@@ -214,6 +229,7 @@ export class HaObjectSelector extends LitElement {
schema: this._schema(this.selector),
data: {},
computeLabel: this._computeLabel,
computeHelper: this._computeHelper,
submitText: this.hass.localize("ui.common.add"),
});

View File

@@ -36,7 +36,7 @@ export class HaSelectorUiStateContent extends SubscribeMixin(LitElement) {
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
.allowName=${this.selector.ui_state_content?.allow_name}
.allowName=${this.selector.ui_state_content?.allow_name || false}
></ha-entity-state-content-picker>
`;
}

View File

@@ -53,7 +53,7 @@ class HaServicePicker extends LitElement {
item,
{ index }
) => html`
<ha-combo-box-item type="button" border-top .borderTop=${index !== 0}>
<ha-combo-box-item type="button" .borderTop=${index !== 0}>
<ha-service-icon
slot="start"
.hass=${this.hass}
@@ -76,34 +76,42 @@ class HaServicePicker extends LitElement {
</ha-combo-box-item>
`;
private _valueRenderer: PickerValueRenderer = (value) => {
const serviceId = value;
const [domain, service] = serviceId.split(".");
private _valueRenderer = memoizeOne(
(
localize: LocalizeFunc,
services: HomeAssistant["services"]
): PickerValueRenderer =>
(value) => {
const serviceId = value;
const [domain, service] = serviceId.split(".");
if (!this.hass.services[domain]?.[service]) {
return html`
<ha-svg-icon slot="start" .path=${mdiRoomService}></ha-svg-icon>
<span slot="headline">${value}</span>
`;
}
if (!services[domain]?.[service]) {
return html`
<ha-svg-icon slot="start" .path=${mdiRoomService}></ha-svg-icon>
<span slot="headline">${value}</span>
`;
}
const serviceName =
this.hass.localize(`component.${domain}.services.${service}.name`) ||
this.hass.services[domain][service].name ||
service;
const serviceName =
localize(`component.${domain}.services.${service}.name`) ||
services[domain][service].name ||
service;
return html`
<ha-service-icon
slot="start"
.hass=${this.hass}
.service=${serviceId}
></ha-service-icon>
<span slot="headline">${serviceName}</span>
${this.showServiceId
? html`<span slot="supporting-text" class="code">${serviceId}</span>`
: nothing}
`;
};
return html`
<ha-service-icon
slot="start"
.hass=${this.hass}
.service=${serviceId}
></ha-service-icon>
<span slot="headline">${serviceName}</span>
${this.showServiceId
? html`<span slot="supporting-text" class="code"
>${serviceId}</span
>`
: nothing}
`;
}
);
protected render(): TemplateResult {
const placeholder =
@@ -123,7 +131,10 @@ class HaServicePicker extends LitElement {
.value=${this.value}
.getItems=${this._getItems}
.rowRenderer=${this._rowRenderer}
.valueRenderer=${this._valueRenderer}
.valueRenderer=${this._valueRenderer(
this.hass.localize,
this.hass.services
)}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
@@ -162,7 +173,9 @@ class HaServicePicker extends LitElement {
const description =
this.hass.localize(
`component.${domain}.services.${service}.description`
) || services[domain][service].description;
) ||
services[domain][service].description ||
"";
items.push({
id: serviceId,

View File

@@ -29,6 +29,7 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { toggleAttribute } from "../common/dom/toggle_attribute";
import { stringCompare } from "../common/string/compare";
import { computeRTL } from "../common/util/compute_rtl";
import { throttle } from "../common/util/throttle";
import { subscribeFrontendUserData } from "../data/frontend";
import type { ActionHandlerDetail } from "../data/lovelace/action_handler";
@@ -536,11 +537,17 @@ class HaSidebar extends SubscribeMixin(LitElement) {
}
private _renderUserItem(selectedPanel: string) {
const isRTL = computeRTL(this.hass);
return html`
<ha-md-list-item
href="/profile"
type="link"
class="user ${selectedPanel === "profile" ? " selected" : ""}"
class=${classMap({
user: true,
selected: selectedPanel === "profile",
rtl: isRTL,
})}
@mouseenter=${this._itemMouseEnter}
@mouseleave=${this._itemMouseLeave}
>
@@ -666,7 +673,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
tooltip.style.display = "block";
tooltip.style.position = "fixed";
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() {
@@ -705,13 +712,17 @@ class HaSidebar extends SubscribeMixin(LitElement) {
background-color: var(--sidebar-background-color);
width: 100%;
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 {
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;
display: flex;
padding: 0 4px;
padding: 0 var(--ha-space-1);
border-bottom: 1px solid transparent;
white-space: nowrap;
font-weight: var(--ha-font-weight-normal);
@@ -726,13 +737,17 @@ class HaSidebar extends SubscribeMixin(LitElement) {
);
font-size: var(--ha-font-size-xl);
align-items: center;
padding-left: calc(4px + var(--safe-area-inset-left, 0px));
padding-inline-start: calc(4px + var(--safe-area-inset-left, 0px));
padding-left: calc(
var(--ha-space-1) + var(--safe-area-inset-left, var(--ha-space-0))
);
padding-inline-start: calc(
var(--ha-space-1) + var(--safe-area-inset-left, var(--ha-space-0))
);
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 {
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 {
width: 100%;
@@ -748,8 +763,8 @@ class HaSidebar extends SubscribeMixin(LitElement) {
display: none;
}
:host([narrow]) .title {
margin: 0;
padding: 0 16px;
margin: var(--ha-space-0);
padding: var(--ha-space-0) var(--ha-space-4);
}
:host([expanded]) .title {
display: initial;
@@ -761,13 +776,16 @@ class HaSidebar extends SubscribeMixin(LitElement) {
ha-fade-in,
ha-md-list {
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
);
}
ha-fade-in {
padding: 4px 0;
padding: var(--ha-space-1) var(--ha-space-0);
box-sizing: border-box;
display: flex;
justify-content: center;
@@ -777,29 +795,29 @@ class HaSidebar extends SubscribeMixin(LitElement) {
ha-md-list {
overflow-x: hidden;
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 {
flex-shrink: 0;
box-sizing: border-box;
margin: 4px;
margin: var(--ha-space-1);
border-radius: var(--ha-border-radius-sm);
--md-list-item-one-line-container-height: 40px;
--md-list-item-one-line-container-height: var(--ha-space-10);
--md-list-item-top-space: 0;
--md-list-item-bottom-space: 0;
width: 48px;
width: var(--ha-space-12);
position: relative;
--md-list-item-label-text-color: var(--sidebar-text-color);
--md-list-item-leading-space: 12px;
--md-list-item-trailing-space: 12px;
--md-list-item-leading-icon-size: 24px;
--md-list-item-leading-space: var(--ha-space-3);
--md-list-item-trailing-space: var(--ha-space-3);
--md-list-item-leading-icon-size: var(--ha-space-6);
}
:host([expanded]) ha-md-list-item {
width: 248px;
}
: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 {
@@ -823,7 +841,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
ha-icon[slot="start"],
ha-svg-icon[slot="start"] {
width: 24px;
width: var(--ha-space-6);
flex-shrink: 0;
color: var(--sidebar-icon-color);
}
@@ -856,7 +874,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
display: flex;
justify-content: center;
align-items: center;
min-width: 8px;
min-width: var(--ha-space-2);
border-radius: var(--ha-border-radius-xl);
font-weight: var(--ha-font-weight-normal);
line-height: normal;
@@ -867,22 +885,26 @@ class HaSidebar extends SubscribeMixin(LitElement) {
ha-svg-icon + .badge {
position: absolute;
top: 4px;
top: var(--ha-space-1);
left: 26px;
border-radius: var(--ha-border-radius-md);
font-size: 0.65em;
line-height: var(--ha-line-height-expanded);
padding: 0 4px;
padding: var(--ha-space-0) var(--ha-space-1);
}
ha-md-list-item.user {
--md-list-item-leading-icon-size: 40px;
--md-list-item-leading-space: 4px;
--md-list-item-leading-icon-size: var(--ha-space-10);
--md-list-item-leading-space: var(--ha-space-1);
}
ha-md-list-item.user.rtl {
--md-list-item-leading-space: var(--ha-space-3);
}
ha-user-badge {
flex-shrink: 0;
margin-right: -8px;
margin-right: calc(var(--ha-space-2) * -1);
}
.spacer {
@@ -894,7 +916,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
color: var(--sidebar-text-color);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
padding: 16px;
padding: var(--ha-space-4);
white-space: nowrap;
}
@@ -906,7 +928,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
white-space: nowrap;
color: var(--sidebar-background-color);
background-color: var(--sidebar-text-color);
padding: 4px;
padding: var(--ha-space-1);
font-weight: var(--ha-font-weight-medium);
}

View File

@@ -2,26 +2,13 @@ import { TopAppBarFixedBase } from "@material/mwc-top-app-bar-fixed/mwc-top-app-
import { styles } from "@material/mwc-top-app-bar/mwc-top-app-bar.css";
import { css } from "lit";
import { customElement, property } from "lit/decorators";
import { ViewTransitionMixin } from "../mixins/view-transition-mixin";
import { haStyleViewTransitions } from "../resources/styles";
@customElement("ha-top-app-bar-fixed")
export class HaTopAppBarFixed extends ViewTransitionMixin(TopAppBarFixedBase) {
export class HaTopAppBarFixed extends TopAppBarFixedBase {
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ type: Boolean, reflect: true, attribute: "content-loading" })
public contentLoading = true;
protected override onLoadTransition(): void {
// Use reflected property since we can't add class to base component's rendered elements
this.startViewTransition(() => {
this.contentLoading = false;
});
}
static override styles = [
styles,
haStyleViewTransitions,
css`
header {
padding-top: var(--safe-area-inset-top);
@@ -36,10 +23,6 @@ export class HaTopAppBarFixed extends ViewTransitionMixin(TopAppBarFixedBase) {
);
padding-bottom: var(--safe-area-inset-bottom);
padding-right: var(--safe-area-inset-right);
view-transition-name: layout-fade-in;
}
:host([content-loading]) .mdc-top-app-bar--fixed-adjust {
opacity: 0;
}
:host([narrow]) .mdc-top-app-bar--fixed-adjust {
padding-left: var(--safe-area-inset-left);

View File

@@ -10,15 +10,14 @@ import { html, css, nothing } from "lit";
import { property, query, customElement } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styles } from "@material/mwc-top-app-bar/mwc-top-app-bar.css";
import { ViewTransitionMixin } from "../mixins/view-transition-mixin";
import { haStyleScrollbar, haStyleViewTransitions } from "../resources/styles";
import { haStyleScrollbar } from "../resources/styles";
export const passiveEventOptionsIfSupported = supportsPassiveEventListener
? { passive: true }
: undefined;
@customElement("ha-two-pane-top-app-bar-fixed")
export class TopAppBarBaseBase extends ViewTransitionMixin(BaseElement) {
export class TopAppBarBaseBase extends BaseElement {
protected override mdcFoundation!: MDCFixedTopAppBarFoundation;
protected override mdcFoundationClass = MDCFixedTopAppBarFoundation;
@@ -145,12 +144,7 @@ export class TopAppBarBaseBase extends ViewTransitionMixin(BaseElement) {
: nothing}
<div class="main">
${this.pane ? html`<div class="shadow-container"></div>` : nothing}
<div
class=${classMap({
content: true,
loading: !this._loaded,
})}
>
<div class="content">
<slot></slot>
</div>
</div>
@@ -251,7 +245,6 @@ export class TopAppBarBaseBase extends ViewTransitionMixin(BaseElement) {
static override styles = [
styles,
haStyleScrollbar,
haStyleViewTransitions,
css`
header {
padding-top: var(--safe-area-inset-top);
@@ -348,10 +341,6 @@ export class TopAppBarBaseBase extends ViewTransitionMixin(BaseElement) {
.mdc-top-app-bar--pane .content {
height: 100%;
overflow: auto;
view-transition-name: layout-fade-in;
}
.content.loading {
opacity: 0;
}
.mdc-top-app-bar__title {
font-size: var(--ha-font-size-xl);

View File

@@ -1,12 +1,19 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "@home-assistant/webawesome/dist/components/dialog/dialog";
import { mdiClose } from "@mdi/js";
import "./ha-dialog-header";
import "./ha-icon-button";
import type { HomeAssistant } from "../types";
import { css, html, LitElement } from "lit";
import {
customElement,
eventOptions,
property,
query,
state,
} from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-dialog-header";
import "./ha-icon-button";
export type DialogWidth = "small" | "medium" | "large" | "full";
@@ -25,6 +32,8 @@ export type DialogWidth = "small" | "medium" | "large" | "full";
*
* @slot header - Replace the entire header area.
* @slot headerNavigationIcon - Leading header action (e.g. close/back button).
* @slot headerTitle - Custom title content (used when header-title is not set).
* @slot headerSubtitle - Custom subtitle content (used when header-subtitle is not set).
* @slot headerActionItems - Trailing header actions (e.g. buttons, menus).
* @slot - Dialog content body.
* @slot footer - Dialog footer content.
@@ -46,8 +55,8 @@ export type DialogWidth = "small" | "medium" | "large" | "full";
* @attr {boolean} open - Controls the dialog open state.
* @attr {("small"|"medium"|"large"|"full")} width - Preferred dialog width preset. Defaults to "medium".
* @attr {boolean} prevent-scrim-close - Prevents closing the dialog by clicking the scrim/overlay. Defaults to false.
* @attr {string} header-title - Header title text when no custom title slot is provided.
* @attr {string} header-subtitle - Header subtitle text when no custom subtitle slot is provided.
* @attr {string} header-title - Header title text. If not set, the headerTitle slot is used.
* @attr {string} header-subtitle - Header subtitle text. If not set, the headerSubtitle slot is used.
* @attr {("above"|"below")} header-subtitle-position - Position of the subtitle relative to the title. Defaults to "below".
* @attr {boolean} flexcontent - Makes the dialog body a flex container for flexible layouts.
*
@@ -66,6 +75,12 @@ export type DialogWidth = "small" | "medium" | "large" | "full";
export class HaWaDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "aria-labelledby" })
public ariaLabelledBy?: string;
@property({ attribute: "aria-describedby" })
public ariaDescribedBy?: string;
@property({ type: Boolean, reflect: true })
public open = false;
@@ -75,11 +90,11 @@ export class HaWaDialog extends LitElement {
@property({ type: Boolean, reflect: true, attribute: "prevent-scrim-close" })
public preventScrimClose = false;
@property({ type: String, attribute: "header-title" })
public headerTitle = "";
@property({ attribute: "header-title" })
public headerTitle?: string;
@property({ type: String, attribute: "header-subtitle" })
public headerSubtitle = "";
@property({ attribute: "header-subtitle" })
public headerSubtitle?: string;
@property({ type: String, attribute: "header-subtitle-position" })
public headerSubtitlePosition: "above" | "below" = "below";
@@ -90,6 +105,13 @@ export class HaWaDialog extends LitElement {
@state()
private _open = false;
@query(".body") public bodyContainer!: HTMLDivElement;
@query("wa-dialog") private _waDialog?: any;
@state()
private _bodyScrolled = false;
protected updated(
changedProperties: Map<string | number | symbol, unknown>
): void {
@@ -100,17 +122,50 @@ export class HaWaDialog extends LitElement {
}
}
private _setAriaAttributes() {
const internalDialog = this._waDialog?.shadowRoot?.querySelector(
".dialog"
) as HTMLDialogElement | null;
if (internalDialog) {
const labelledBy =
this.ariaLabelledBy ||
(this.headerTitle !== undefined ? "ha-wa-dialog-title" : undefined);
if (labelledBy) {
internalDialog.setAttribute("aria-labelledby", labelledBy);
} else {
internalDialog.removeAttribute("aria-labelledby");
}
if (this.ariaDescribedBy) {
internalDialog.setAttribute("aria-describedby", this.ariaDescribedBy);
} else {
internalDialog.removeAttribute("aria-describedby");
}
}
}
protected render() {
return html`
<wa-dialog
.open=${this._open}
.lightDismiss=${!this.preventScrimClose}
without-header
aria-labelledby=${ifDefined(
this.ariaLabelledBy ||
(this.headerTitle !== undefined ? "ha-wa-dialog-title" : undefined)
)}
aria-describedby=${ifDefined(this.ariaDescribedBy)}
@wa-show=${this._handleShow}
@wa-after-show=${this._handleAfterShow}
@wa-after-hide=${this._handleAfterHide}
>
<slot name="header">
<ha-dialog-header .subtitlePosition=${this.headerSubtitlePosition}>
<ha-dialog-header
.subtitlePosition=${this.headerSubtitlePosition}
.showBorder=${this._bodyScrolled}
>
<slot name="headerNavigationIcon" slot="navigationIcon">
<ha-icon-button
data-dialog="close"
@@ -118,18 +173,18 @@ export class HaWaDialog extends LitElement {
.path=${mdiClose}
></ha-icon-button>
</slot>
${this.headerTitle
? html`<span slot="title" class="title">
${this.headerTitle !== undefined
? html`<span slot="title" class="title" id="ha-wa-dialog-title">
${this.headerTitle}
</span>`
: nothing}
${this.headerSubtitle
: html`<slot name="headerTitle" slot="title"></slot>`}
${this.headerSubtitle !== undefined
? html`<span slot="subtitle">${this.headerSubtitle}</span>`
: nothing}
: html`<slot name="headerSubtitle" slot="subtitle"></slot>`}
<slot name="headerActionItems" slot="actionItems"></slot>
</ha-dialog-header>
</slot>
<div class="body ha-scrollbar">
<div class="body ha-scrollbar" @scroll=${this._handleBodyScroll}>
<slot></slot>
</div>
<slot name="footer" slot="footer"></slot>
@@ -143,9 +198,15 @@ export class HaWaDialog extends LitElement {
await this.updateComplete;
this._setAriaAttributes();
(this.querySelector("[autofocus]") as HTMLElement | null)?.focus();
};
private _handleAfterShow = () => {
fireEvent(this, "after-show");
};
private _handleAfterHide = () => {
this._open = false;
fireEvent(this, "closed");
@@ -156,6 +217,11 @@ export class HaWaDialog extends LitElement {
this._open = false;
}
@eventOptions({ passive: true })
private _handleBodyScroll(ev: Event) {
this._bodyScrolled = (ev.target as HTMLDivElement).scrollTop > 0;
}
static styles = [
haStyleScrollbar,
css`
@@ -172,7 +238,7 @@ export class HaWaDialog extends LitElement {
)
)
);
--width: var(--ha-dialog-width-md, min(580px, var(--full-width)));
--width: min(var(--ha-dialog-width-md, 580px), var(--full-width));
--spacing: var(--dialog-content-padding, var(--ha-space-6));
--show-duration: var(--ha-dialog-show-duration, 200ms);
--hide-duration: var(--ha-dialog-hide-duration, 200ms);
@@ -193,11 +259,11 @@ export class HaWaDialog extends LitElement {
}
:host([width="small"]) wa-dialog {
--width: var(--ha-dialog-width-sm, min(320px, var(--full-width)));
--width: min(var(--ha-dialog-width-sm, 320px), var(--full-width));
}
:host([width="large"]) wa-dialog {
--width: var(--ha-dialog-width-lg, min(720px, var(--full-width)));
--width: min(var(--ha-dialog-width-lg, 720px), var(--full-width));
}
:host([width="full"]) wa-dialog {
@@ -211,6 +277,7 @@ export class HaWaDialog extends LitElement {
--ha-dialog-max-height,
calc(100% - var(--ha-space-20))
);
min-height: var(--ha-dialog-min-height);
position: var(--dialog-surface-position, relative);
margin-top: var(--dialog-surface-margin-top, auto);
display: flex;
@@ -284,6 +351,7 @@ export class HaWaDialog extends LitElement {
}
:host([flexcontent]) .body {
max-width: 100%;
flex: 1;
display: flex;
flex-direction: column;
}
@@ -312,6 +380,7 @@ declare global {
interface HASSDomEvents {
opened: undefined;
"after-show": undefined;
closed: undefined;
}
}

View File

@@ -15,6 +15,7 @@ import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { until } from "lit/directives/until";
import { fireEvent } from "../../common/dom/fire_event";
import { slugify } from "../../common/string/slugify";
import { debounce } from "../../common/util/debounce";
import { isUnavailableState } from "../../data/entity";
import type {
@@ -693,10 +694,12 @@ export class HaMediaPlayerBrowse extends LitElement {
`
: ""}
</div>
<ha-tooltip .for="grid-${child.title}" distance="-4">
<ha-tooltip .for="grid-${slugify(child.title)}" distance="-4">
${child.title}
</ha-tooltip>
<div .id="grid-${child.title}" class="title">${child.title}</div>
<div .id="grid-${slugify(child.title)}" class="title">
${child.title}
</div>
</ha-card>
</div>
`;

View File

@@ -1,17 +1,15 @@
import { mdiClose } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import type { HomeAssistant } from "../../../types";
import "../../ha-dialog-header";
import "../../ha-icon-button";
import "../../ha-icon-next";
import "../../ha-md-dialog";
import type { HaMdDialog } from "../../ha-md-dialog";
import "../../ha-md-list";
import "../../ha-md-list-item";
import "../../ha-svg-icon";
import "../../ha-wa-dialog";
import "../ha-target-picker-item-row";
import type { TargetDetailsDialogParams } from "./show-dialog-target-details";
@@ -21,14 +19,15 @@ class DialogTargetDetails extends LitElement implements HassDialog {
@state() private _params?: TargetDetailsDialogParams;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
@state() private _opened = false;
public showDialog(params: TargetDetailsDialogParams): void {
this._params = params;
this._opened = true;
}
public closeDialog() {
this._dialog?.close();
this._opened = false;
return true;
}
@@ -43,58 +42,31 @@ class DialogTargetDetails extends LitElement implements HassDialog {
}
return html`
<ha-md-dialog open @closed=${this._dialogClosed}>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
@click=${this.closeDialog}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<span slot="title"
>${this.hass.localize(
"ui.components.target-picker.target_details"
)}</span
>
<span slot="subtitle"
>${this.hass.localize(
`ui.components.target-picker.type.${this._params.type}`
)}:
${this._params.title}</span
>
</ha-dialog-header>
<div slot="content">
<ha-target-picker-item-row
.hass=${this.hass}
.type=${this._params.type}
.itemId=${this._params.itemId}
.deviceFilter=${this._params.deviceFilter}
.entityFilter=${this._params.entityFilter}
.includeDomains=${this._params.includeDomains}
.includeDeviceClasses=${this._params.includeDeviceClasses}
expand
></ha-target-picker-item-row>
</div>
</ha-md-dialog>
<ha-wa-dialog
.hass=${this.hass}
.open=${this._opened}
header-title=${this.hass.localize(
"ui.components.target-picker.target_details"
)}
header-subtitle=${`${this.hass.localize(
`ui.components.target-picker.type.${this._params.type}`
)}:
${this._params.title}`}
@closed=${this._dialogClosed}
>
<ha-target-picker-item-row
.hass=${this.hass}
.type=${this._params.type}
.itemId=${this._params.itemId}
.deviceFilter=${this._params.deviceFilter}
.entityFilter=${this._params.entityFilter}
.includeDomains=${this._params.includeDomains}
.includeDeviceClasses=${this._params.includeDeviceClasses}
expand
></ha-target-picker-item-row>
</ha-wa-dialog>
`;
}
static styles = css`
ha-md-dialog {
min-width: 400px;
max-height: 90%;
--dialog-content-padding: var(--ha-space-2) var(--ha-space-6)
max(var(--safe-area-inset-bottom, var(--ha-space-0)), var(--ha-space-8));
}
@media all and (max-width: 600px), all and (max-height: 500px) {
ha-md-dialog {
--md-dialog-container-shape: var(--ha-space-0);
min-width: 100%;
min-height: 100%;
}
}
`;
}
declare global {

View File

@@ -162,11 +162,12 @@ export class HaTargetPickerItemRow extends LitElement {
<div slot="headline">${name}</div>
${context && !this.hideContext
? html`<span slot="supporting-text">${context}</span>`
: this._domainName && this.subEntry
? html`<span slot="supporting-text" class="domain"
>${this._domainName}</span
>`
: nothing}
: nothing}
${this._domainName && this.subEntry
? html`<span slot="supporting-text" class="domain"
>${this._domainName}</span
>`
: nothing}
${!this.subEntry && entries && showEntities
? html`
<div slot="end" class="summary">
@@ -231,9 +232,11 @@ export class HaTargetPickerItemRow extends LitElement {
const rows1 =
(nextType === "area"
? entries?.referenced_areas
: nextType === "device"
: nextType === "device" && this.type !== "label"
? entries?.referenced_devices
: entries?.referenced_entities) || [];
: this.type !== "label"
? entries?.referenced_entities
: []) || [];
const devicesInAreas = [] as string[];
@@ -284,9 +287,13 @@ export class HaTargetPickerItemRow extends LitElement {
const entityRows =
this.type === "label" && entries
? entries.referenced_entities.filter((entity_id) =>
this.hass.entities[entity_id].labels.includes(this.itemId)
)
? entries.referenced_entities.filter((entity_id) => {
const entity = this.hass.entities[entity_id];
return (
entity.labels.includes(this.itemId) &&
!entries.referenced_devices.includes(entity.device_id || "")
);
})
: nextType === "device" && entries
? entries.referenced_entities.filter(
(entity_id) =>
@@ -412,7 +419,6 @@ export class HaTargetPickerItemRow extends LitElement {
const device = this.hass.devices[device_id];
if (
!hiddenAreaIds.includes(device.area_id || "") &&
(this.type !== "label" || device.labels.includes(this.itemId)) &&
deviceMeetsFilter(
device,
this.hass.entities,
@@ -669,6 +675,14 @@ export class HaTargetPickerItemRow extends LitElement {
button.link:focus {
text-decoration: underline;
}
.domain {
width: fit-content;
border-radius: var(--ha-border-radius-md);
background-color: var(--ha-color-fill-neutral-quiet-resting);
padding: var(--ha-space-1);
font-family: var(--ha-font-family-code);
}
`,
];
}

View File

@@ -16,6 +16,7 @@ import memoizeOne from "memoize-one";
import { computeCssColor } from "../../common/color/compute-color";
import { hex2rgb } from "../../common/color/convert-color";
import { fireEvent } from "../../common/dom/fire_event";
import { slugify } from "../../common/string/slugify";
import {
computeDeviceName,
computeDeviceNameDisplay,
@@ -102,7 +103,7 @@ export class HaTargetPickerValueChip extends LitElement {
${this.type === "entity"
? nothing
: html`<span role="gridcell">
<ha-tooltip .for="expand-${this.itemId}"
<ha-tooltip .for="expand-${slugify(this.itemId)}"
>${this.hass.localize(
`ui.components.target-picker.expand_${this.type}_id`
)}
@@ -114,13 +115,13 @@ export class HaTargetPickerValueChip extends LitElement {
)}
.path=${mdiUnfoldMoreVertical}
hide-title
.id="expand-${this.itemId}"
.id="expand-${slugify(this.itemId)}"
.type=${this.type}
@click=${this._handleExpand}
></ha-icon-button>
</span>`}
<span role="gridcell">
<ha-tooltip .for="remove-${this.itemId}">
<ha-tooltip .for="remove-${slugify(this.itemId)}">
${this.hass.localize(
`ui.components.target-picker.remove_${this.type}_id`
)}
@@ -130,7 +131,7 @@ export class HaTargetPickerValueChip extends LitElement {
.label=${this.hass.localize("ui.components.target-picker.remove")}
.path=${mdiClose}
hide-title
.id="remove-${this.itemId}"
.id="remove-${slugify(this.itemId)}"
.type=${this.type}
@click=${this._removeItem}
></ha-icon-button>

View File

@@ -6,8 +6,6 @@ import {
mdiCallSplit,
mdiCodeBraces,
mdiDevices,
mdiDotsHorizontal,
mdiExcavator,
mdiFormatListNumbered,
mdiGestureDoubleTap,
mdiHandBackRight,
@@ -16,10 +14,10 @@ import {
mdiRoomService,
mdiShuffleDisabled,
mdiTimerOutline,
mdiTools,
mdiTrafficLight,
} from "@mdi/js";
import type { AutomationElementGroup } from "./automation";
import type { AutomationElementGroupCollection } from "./automation";
import type { Action } from "./script";
export const ACTION_ICONS = {
condition: mdiAbTesting,
@@ -48,37 +46,73 @@ export const YAML_ONLY_ACTION_TYPES = new Set<keyof typeof ACTION_ICONS>([
"variables",
]);
export const ACTION_GROUPS: AutomationElementGroup = {
device_id: {},
helpers: {
icon: mdiTools,
members: {},
},
building_blocks: {
icon: mdiExcavator,
members: {
condition: {},
delay: {},
wait_template: {},
wait_for_trigger: {},
repeat_count: {},
repeat_while: {},
repeat_until: {},
repeat_for_each: {},
choose: {},
if: {},
stop: {},
sequence: {},
parallel: {},
variables: {},
export const ACTION_COLLECTIONS: AutomationElementGroupCollection[] = [
{
groups: {
device_id: {},
serviceGroups: {},
},
},
other: {
icon: mdiDotsHorizontal,
members: {
{
titleKey: "ui.panel.config.automation.editor.actions.groups.helpers.label",
groups: {
helpers: {},
},
},
{
titleKey: "ui.panel.config.automation.editor.actions.groups.other.label",
groups: {
event: {},
service: {},
set_conversation_response: {},
other: {},
},
},
] as const;
export const ACTION_BUILDING_BLOCKS_GROUP = {
condition: {},
delay: {},
wait_template: {},
wait_for_trigger: {},
repeat_count: {},
repeat_while: {},
repeat_until: {},
repeat_for_each: {},
choose: {},
if: {},
stop: {},
sequence: {},
parallel: {},
variables: {},
};
// These will be replaced with the correct action
export const VIRTUAL_ACTIONS: Partial<
Record<keyof typeof ACTION_BUILDING_BLOCKS_GROUP, Action>
> = {
repeat_count: {
repeat: {
count: 2,
sequence: [],
},
},
repeat_while: {
repeat: {
while: [],
sequence: [],
},
},
repeat_until: {
repeat: {
until: [],
sequence: [],
},
},
repeat_for_each: {
repeat: {
for_each: {},
sequence: [],
},
},
} as const;

View File

@@ -4,6 +4,7 @@ import type {
} from "home-assistant-js-websocket";
import { ensureArray } from "../common/array/ensure-array";
import { navigate } from "../common/navigate";
import type { LocalizeKeys } from "../common/translations/localize";
import { createSearchParam } from "../common/url/search-params";
import type { Context, HomeAssistant } from "../types";
import type { BlueprintInput } from "./blueprint";
@@ -293,6 +294,11 @@ export interface ShorthandNotCondition extends ShorthandBaseCondition {
not: Condition[];
}
export interface AutomationElementGroupCollection {
titleKey?: LocalizeKeys;
groups: AutomationElementGroup;
}
export type AutomationElementGroup = Record<
string,
{ icon?: string; members?: AutomationElementGroup }

View File

@@ -3,8 +3,6 @@ import {
mdiClockOutline,
mdiCodeBraces,
mdiDevices,
mdiDotsHorizontal,
mdiExcavator,
mdiGateOr,
mdiIdentifier,
mdiMapClock,
@@ -15,7 +13,7 @@ import {
mdiStateMachine,
mdiWeatherSunny,
} from "@mdi/js";
import type { AutomationElementGroup } from "./automation";
import type { AutomationElementGroupCollection } from "./automation";
export const CONDITION_ICONS = {
device: mdiDevices,
@@ -31,25 +29,31 @@ export const CONDITION_ICONS = {
zone: mdiMapMarkerRadius,
};
export const CONDITION_GROUPS: AutomationElementGroup = {
device: {},
entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
time_location: {
icon: mdiMapClock,
members: { sun: {}, time: {}, zone: {} },
export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [
{
groups: {
device: {},
entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
time_location: {
icon: mdiMapClock,
members: { sun: {}, time: {}, zone: {} },
},
},
},
building_blocks: {
icon: mdiExcavator,
members: { and: {}, or: {}, not: {} },
},
other: {
icon: mdiDotsHorizontal,
members: {
{
titleKey: "ui.panel.config.automation.editor.conditions.groups.other.label",
groups: {
template: {},
trigger: {},
},
},
} as const;
] as const;
export const CONDITION_BUILDING_BLOCKS_GROUP = {
and: {},
or: {},
not: {},
};
export const CONDITION_BUILDING_BLOCKS = ["and", "or", "not"];

View File

@@ -76,7 +76,7 @@ export const floorCompare =
const floorA = entries?.[a];
const floorB = entries?.[b];
if (floorA && floorB && floorA.level !== floorB.level) {
return (floorA.level ?? 9999) - (floorB.level ?? 9999);
return (floorB.level ?? -9999) - (floorA.level ?? -9999);
}
const nameA = floorA?.name ?? a;
const nameB = floorB?.name ?? b;

View File

@@ -264,6 +264,7 @@ export const getLabels = (
const items = outputLabels.map<PickerComboBoxItem>((label) => ({
id: label.label_id,
primary: label.name,
secondary: label.description ?? "",
icon: label.icon || undefined,
icon_path: label.icon ? undefined : mdiLabel,
sorting_label: label.name,

View File

@@ -352,6 +352,7 @@ export interface NumberSelector {
interface ObjectSelectorField {
selector: Selector;
label?: string;
description?: string;
required?: boolean;
}

View File

@@ -4,7 +4,6 @@ import {
mdiClockOutline,
mdiCodeBraces,
mdiDevices,
mdiDotsHorizontal,
mdiFormatListBulleted,
mdiGestureDoubleTap,
mdiMapClock,
@@ -23,7 +22,7 @@ import {
import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
import type {
AutomationElementGroup,
AutomationElementGroupCollection,
Trigger,
TriggerList,
} from "./automation";
@@ -49,16 +48,26 @@ export const TRIGGER_ICONS = {
list: mdiFormatListBulleted,
};
export const TRIGGER_GROUPS: AutomationElementGroup = {
device: {},
entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
time_location: {
icon: mdiMapClock,
members: { calendar: {}, sun: {}, time: {}, time_pattern: {}, zone: {} },
export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
{
groups: {
device: {},
entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
time_location: {
icon: mdiMapClock,
members: {
calendar: {},
sun: {},
time: {},
time_pattern: {},
zone: {},
},
},
},
},
other: {
icon: mdiDotsHorizontal,
members: {
{
titleKey: "ui.panel.config.automation.editor.triggers.groups.other.label",
groups: {
event: {},
geo_location: {},
homeassistant: {},
@@ -70,7 +79,7 @@ export const TRIGGER_GROUPS: AutomationElementGroup = {
persistent_notification: {},
},
},
} as const;
] as const;
export const isTriggerList = (trigger: Trigger): trigger is TriggerList =>
"triggers" in trigger;

View File

@@ -37,7 +37,6 @@
flex-direction: column;
justify-content: center;
align-items: center;
view-transition-name: layout-fade-out;
}
#ha-launch-screen svg {
width: 112px;

View File

@@ -61,7 +61,6 @@ class HassLoadingScreen extends LitElement {
display: block;
height: 100%;
background-color: var(--primary-background-color);
view-transition-name: layout-fade-out;
}
.toolbar {
display: flex;

View File

@@ -3,7 +3,6 @@ import { ReactiveElement } from "lit";
import { property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { navigate } from "../common/navigate";
import { ViewTransitionMixin } from "../mixins/view-transition-mixin";
import type { Route } from "../types";
const extractPage = (path: string, defaultPage: string) => {
@@ -44,7 +43,7 @@ export interface RouterOptions {
// Time to wait for code to load before we show loading screen.
const LOADING_SCREEN_THRESHOLD = 400; // ms
export class HassRouterPage extends ViewTransitionMixin(ReactiveElement) {
export class HassRouterPage extends ReactiveElement {
@property({ attribute: false }) public route?: Route;
protected routerOptions!: RouterOptions;
@@ -311,18 +310,16 @@ export class HassRouterPage extends ViewTransitionMixin(ReactiveElement) {
page: string,
routeOptions: RouteOptions
) {
this.startViewTransition(() => {
if (this.lastChild) {
this.removeChild(this.lastChild);
}
if (this.lastChild) {
this.removeChild(this.lastChild);
}
const panelEl = this._cache[page] || this.createElement(routeOptions.tag);
this.updatePageEl(panelEl);
this.appendChild(panelEl);
const panelEl = this._cache[page] || this.createElement(routeOptions.tag);
this.updatePageEl(panelEl);
this.appendChild(panelEl);
if (routerOptions.cacheAll || routeOptions.cache) {
this._cache[page] = panelEl;
}
});
if (routerOptions.cacheAll || routeOptions.cache) {
this._cache[page] = panelEl;
}
}
}

View File

@@ -1,17 +1,15 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, eventOptions, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { restoreScroll } from "../common/decorators/restore-scroll";
import { goBack } from "../common/navigate";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-menu-button";
import { ViewTransitionMixin } from "../mixins/view-transition-mixin";
import { haStyleScrollbar, haStyleViewTransitions } from "../resources/styles";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
@customElement("hass-subpage")
class HassSubpage extends ViewTransitionMixin(LitElement) {
class HassSubpage extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public header?: string;
@@ -62,14 +60,7 @@ class HassSubpage extends ViewTransitionMixin(LitElement) {
<slot name="toolbar-icon"></slot>
</div>
</div>
<div
class=${classMap({
content: true,
"ha-scrollbar": true,
loading: !this._loaded,
})}
@scroll=${this._saveScrollPos}
>
<div class="content ha-scrollbar" @scroll=${this._saveScrollPos}>
<slot></slot>
</div>
<div id="fab">
@@ -94,7 +85,6 @@ class HassSubpage extends ViewTransitionMixin(LitElement) {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
haStyleViewTransitions,
css`
:host {
display: block;
@@ -177,10 +167,6 @@ class HassSubpage extends ViewTransitionMixin(LitElement) {
overflow-y: auto;
overflow: auto;
-webkit-overflow-scrolling: touch;
view-transition-name: layout-fade-in;
}
.content.loading {
opacity: 0;
}
:host([narrow]) .content {
width: calc(

View File

@@ -11,8 +11,7 @@ import "../components/ha-icon-button-arrow-prev";
import "../components/ha-menu-button";
import "../components/ha-svg-icon";
import "../components/ha-tab";
import { ViewTransitionMixin } from "../mixins/view-transition-mixin";
import { haStyleScrollbar, haStyleViewTransitions } from "../resources/styles";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant, Route } from "../types";
export interface PageNavigation {
@@ -30,7 +29,7 @@ export interface PageNavigation {
}
@customElement("hass-tabs-subpage")
class HassTabsSubpage extends ViewTransitionMixin(LitElement) {
class HassTabsSubpage extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public supervisor = false;
@@ -186,12 +185,7 @@ class HassTabsSubpage extends ViewTransitionMixin(LitElement) {
</div>`
: nothing}
<div
class=${classMap({
content: true,
"ha-scrollbar": true,
tabs: showTabs,
loading: !this._loaded,
})}
class="content ha-scrollbar ${classMap({ tabs: showTabs })}"
@scroll=${this._saveScrollPos}
>
<slot></slot>
@@ -220,7 +214,6 @@ class HassTabsSubpage extends ViewTransitionMixin(LitElement) {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
haStyleViewTransitions,
css`
:host {
display: block;
@@ -339,10 +332,6 @@ class HassTabsSubpage extends ViewTransitionMixin(LitElement) {
margin-bottom: var(--safe-area-inset-bottom);
overflow: auto;
-webkit-overflow-scrolling: touch;
view-transition-name: layout-fade-in;
}
.content.loading {
opacity: 0;
}
:host([narrow]) .content {
margin-left: var(--safe-area-inset-left);

View File

@@ -1,201 +0,0 @@
import type { PropertyValues, ReactiveElement } from "lit";
import { state } from "lit/decorators";
/**
* Abstract constructor type for a class that extends a reactive element
* @param T - The type of the reactive element
* @returns The abstract constructor
*/
type AbstractConstructor<T extends ReactiveElement> = abstract new (
...args: any[]
) => T;
/**
* ViewTransitionMixin - Adds view transition support to reactive elements
*
* This mixin provides automatic fade-in transitions when content loads using the
* View Transition API. User preferences are respected for reduced motion.
* Falls back to synchronous updates for browsers that don't support the API.
*
* @example
* Basic usage:
* ```typescript
* @customElement("my-component")
* class MyComponent extends ViewTransitionMixin(LitElement) {
* render() {
* return html`
* <div class=${classMap({ content: true, loading: !this._loaded })}>
* <slot></slot>
* </div>
* `;
* }
*
* static styles = css`
* .content {
* view-transition-name: layout-fade-in;
* }
* .content.loading {
* opacity: 0; // Hidden during initial load for transition
* }
* `;
* }
* ```
*
* @example
* Triggering transitions manually:
* ```typescript
* private _switchView() {
* this.startViewTransition(() => {
* // DOM updates here will be animated
* this.currentView = newView;
* });
* }
* ```
*
* @example
* Custom load behavior:
* ```typescript
* protected override onLoadTransition(): void {
* // Custom logic before triggering transition
* this.startViewTransition(() => {
* this._loaded = true;
* this._additionalSetup();
* });
* }
* ```
*
* Features:
* - Automatic fade-in transition when slotted content loads
* - Provides `_loaded` state property for conditional rendering
* - `startViewTransition()` method for manual transitions
* - Respects prefers-reduced-motion user preference
* - Falls back gracefully when View Transition API unavailable
* - Automatic cleanup of event listeners
*
* The mixin monitors the default slot and triggers `onLoadTransition()` when
* content is available. Override `onLoadTransition()` to customize this behavior.
*/
export const ViewTransitionMixin = <
T extends AbstractConstructor<ReactiveElement>,
>(
superClass: T
) => {
abstract class ViewTransitionClass extends superClass {
/**
* Reference to the default (unnamed) slot element for monitoring content changes.
* Used to detect when slotted content is available to trigger load transitions.
*/
private _slot?: HTMLSlotElement;
/**
* Prevents multiple slotchange events from triggering the transition more than once.
* Once content loads and transition starts, this flag ensures it won't retrigger.
*/
private _transitionTriggered = false;
/**
* State property indicating whether content has finished loading.
* Use this in templates with the loading class pattern to hide content until ready.
*/
@state() protected _loaded = false;
/**
* Trigger a view transition if supported by the browser
* @param updateCallback - Callback function that updates the DOM
* @returns Promise that resolves when the transition is complete
*/
protected async startViewTransition(
updateCallback: () => void | Promise<void>
): Promise<void> {
if (
!document.startViewTransition ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
// Fallback: update without view transition
await updateCallback();
return;
}
const transition = document.startViewTransition(async () => {
await updateCallback();
});
try {
await transition.finished;
} catch {
// View transition failed - this is non-critical, continue silently
}
}
/**
* Callback executed when content is ready to transition in.
*
* Called automatically when:
* - The default slot receives content (slotchange event)
* - No slot exists in the component (triggers immediately after firstUpdated)
*
* Default implementation sets `_loaded = true` within a view transition.
* Override this method to add custom logic before or during the transition,
* but ensure you call `startViewTransition()` to maintain transition behavior.
*/
protected onLoadTransition(): void {
this.startViewTransition(() => {
this._loaded = true;
});
}
/**
* Check if slot has content and trigger transition if it does
*/
private _checkSlotContent = (): void => {
// Guard against multiple slotchange events triggering the transition multiple times
if (this._transitionTriggered) {
return;
}
if (this._slot) {
const elements = this._slot.assignedElements();
if (elements.length > 0) {
this._transitionTriggered = true;
this.onLoadTransition();
}
}
};
/**
* Automatically apply view transition on first render
* @param changedProperties - Properties that changed
*/
protected firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties);
// Wait for slotted content to be ready, then trigger transition
// Only monitor the default (unnamed) slot - named slots are for specific purposes
this._slot = this.shadowRoot?.querySelector("slot:not([name])") as
| HTMLSlotElement
| undefined;
if (this._slot) {
this._checkSlotContent();
this._slot.addEventListener("slotchange", this._checkSlotContent);
} else {
// Start transition immediately if no slot is found
this.onLoadTransition();
}
}
/**
* Cleanup event listeners when component is removed from the DOM.
* Removes the slotchange listener.
*/
override disconnectedCallback(): void {
super.disconnectedCallback();
if (this._slot) {
this._slot.removeEventListener("slotchange", this._checkSlotContent);
this._slot = undefined;
this._transitionTriggered = false;
this._loaded = false;
}
}
}
return ViewTransitionClass;
};

View File

@@ -1,40 +1,39 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators";
import type { HassEntity } from "home-assistant-js-websocket";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/entity/ha-entity-picker";
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
import "../../../components/ha-alert";
import "../../../components/ha-aliases-editor";
import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-floor-picker";
import "../../../components/ha-icon-picker";
import "../../../components/ha-labels-picker";
import "../../../components/ha-picture-upload";
import type { HaPictureUpload } from "../../../components/ha-picture-upload";
import "../../../components/ha-settings-row";
import "../../../components/ha-icon-picker";
import "../../../components/ha-floor-picker";
import "../../../components/entity/ha-entity-picker";
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
import "../../../components/ha-textfield";
import "../../../components/ha-labels-picker";
import type {
AreaRegistryEntry,
AreaRegistryEntryMutableParams,
} from "../../../data/area_registry";
import { deleteAreaRegistryEntry } from "../../../data/area_registry";
import type { CropOptions } from "../../../dialogs/image-cropper-dialog/show-image-cropper-dialog";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import type { AreaRegistryDetailDialogParams } from "./show-dialog-area-registry-detail";
import {
SENSOR_DEVICE_CLASS_HUMIDITY,
SENSOR_DEVICE_CLASS_TEMPERATURE,
} from "../../../data/sensor";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { createCloseHeading } from "../../../components/ha-dialog";
import type { CropOptions } from "../../../dialogs/image-cropper-dialog/show-image-cropper-dialog";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import type { AreaRegistryDetailDialogParams } from "./show-dialog-area-registry-detail";
const cropOptions: CropOptions = {
round: false,
type: "image/jpeg",
quality: 0.75,
aspectRatio: 1.78,
};
const SENSOR_DOMAINS = ["sensor"];
@@ -139,6 +138,7 @@ class DialogAreaDetail extends LitElement {
></ha-floor-picker>
<ha-labels-picker
.label=${this.hass.localize("ui.components.label-picker.labels")}
.hass=${this.hass}
.value=${this._labels}
@value-changed=${this._labelsChanged}
@@ -265,19 +265,15 @@ class DialogAreaDetail extends LitElement {
${this.hass.localize("ui.common.delete")}
</ha-button>`
: nothing}
<div slot="primaryAction">
<ha-button appearance="plain" @click=${this.closeDialog}>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
@click=${this._updateEntry}
.disabled=${nameInvalid || !!this._submitting}
>
${entry
? this.hass.localize("ui.common.save")
: this.hass.localize("ui.common.create")}
</ha-button>
</div>
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${nameInvalid || !!this._submitting}
>
${entry
? this.hass.localize("ui.common.save")
: this.hass.localize("ui.common.create")}
</ha-button>
</ha-dialog>
`;
}

View File

@@ -1,9 +1,10 @@
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import deepClone from "deep-clone-simple";
import type { PropertyValues } from "lit";
import { LitElement, html, nothing } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, queryAll, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { ensureArray } from "../../../../common/array/ensure-array";
import { storage } from "../../../../common/decorators/storage";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
@@ -15,19 +16,18 @@ import {
ACTION_BUILDING_BLOCKS,
getService,
isService,
VIRTUAL_ACTIONS,
} from "../../../../data/action";
import type { AutomationClipboard } from "../../../../data/automation";
import type { Action } from "../../../../data/script";
import type { HomeAssistant } from "../../../../types";
import {
PASTE_VALUE,
VIRTUAL_ACTIONS,
showAddAutomationElementDialog,
} from "../show-add-automation-element-dialog";
import { automationRowsStyles } from "../styles";
import type HaAutomationActionRow from "./ha-automation-action-row";
import { getAutomationActionType } from "./ha-automation-action-row";
import { ensureArray } from "../../../../common/array/ensure-array";
@customElement("ha-automation-action")
export default class HaAutomationAction extends LitElement {
@@ -136,17 +136,6 @@ export default class HaAutomationAction extends LitElement {
"ui.panel.config.automation.editor.actions.add"
)}
</ha-button>
<ha-button
.disabled=${this.disabled}
@click=${this._addActionBuildingBlockDialog}
appearance="plain"
.size=${this.root ? "medium" : "small"}
>
<ha-svg-icon .path=${mdiPlus} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.add_building_block"
)}
</ha-button>
</div>
</div>
</ha-sortable>
@@ -222,15 +211,6 @@ export default class HaAutomationAction extends LitElement {
});
}
private _addActionBuildingBlockDialog() {
showAddAutomationElementDialog(this, {
type: "action",
add: this._addAction,
clipboardItem: getAutomationActionType(this._clipboard?.action),
group: "building_blocks",
});
}
private _addAction = (action: string) => {
let actions: Action[];
if (action === PASTE_VALUE) {

File diff suppressed because it is too large Load Diff

View File

@@ -214,17 +214,6 @@ export default class HaAutomationCondition extends LitElement {
"ui.panel.config.automation.editor.conditions.add"
)}
</ha-button>
<ha-button
.disabled=${this.disabled}
appearance="plain"
.size=${this.root ? "medium" : "small"}
@click=${this._addConditionBuildingBlockDialog}
>
<ha-svg-icon .path=${mdiPlus} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.add_building_block"
)}
</ha-button>
</div>
</div>
</ha-sortable>
@@ -242,15 +231,6 @@ export default class HaAutomationCondition extends LitElement {
});
}
private _addConditionBuildingBlockDialog() {
showAddAutomationElementDialog(this, {
type: "condition",
add: this._addCondition,
clipboardItem: this._clipboard?.condition?.condition,
group: "building_blocks",
});
}
private _addCondition = (value) => {
let conditions: Condition[];
if (value === PASTE_VALUE) {

View File

@@ -36,7 +36,8 @@ export default class HaAutomationSidebar extends LitElement {
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "sidebar-key" }) public sidebarKey?: string;
@property({ type: Number, attribute: "sidebar-key" })
public sidebarKey?: number;
@state() private _yamlMode = false;

View File

@@ -101,7 +101,7 @@ export class HaManualAutomationEditor extends LitElement {
@state() private _sidebarConfig?: SidebarConfig;
@state() private _sidebarKey?: string;
@state() private _sidebarKey = 0;
@storage({
key: "automation-sidebar-width",
@@ -350,7 +350,9 @@ export class HaManualAutomationEditor extends LitElement {
// deselect previous selected row
this._sidebarConfig?.close?.();
this._sidebarConfig = ev.detail;
this._sidebarKey = JSON.stringify(this._sidebarConfig);
// be sure the sidebar editor is recreated
this._sidebarKey++;
await this._sidebarElement?.updateComplete;
this._sidebarElement?.focus();
@@ -375,6 +377,7 @@ export class HaManualAutomationEditor extends LitElement {
return;
}
this._sidebarConfig?.close();
this._sidebarKey = 0;
}
}

View File

@@ -1,45 +1,11 @@
import { fireEvent } from "../../../common/dom/fire_event";
import type { ACTION_GROUPS } from "../../../data/action";
import type { ActionType } from "../../../data/script";
export const PASTE_VALUE = "__paste__";
// These will be replaced with the correct action
export const VIRTUAL_ACTIONS: Record<
keyof (typeof ACTION_GROUPS)["building_blocks"]["members"],
ActionType
> = {
repeat_count: {
repeat: {
count: 2,
sequence: [],
},
},
repeat_while: {
repeat: {
while: [],
sequence: [],
},
},
repeat_until: {
repeat: {
until: [],
sequence: [],
},
},
repeat_for_each: {
repeat: {
for_each: {},
sequence: [],
},
},
} as const;
export interface AddAutomationElementDialogParams {
type: "trigger" | "condition" | "action";
add: (key: string) => void;
clipboardItem: string | undefined;
group?: string;
}
const loadDialog = () => import("./add-automation-element-dialog");

View File

@@ -44,7 +44,8 @@ export default class HaAutomationSidebarAction extends LitElement {
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "sidebar-key" }) public sidebarKey?: string;
@property({ type: Number, attribute: "sidebar-key" })
public sidebarKey?: number;
@state() private _warnings?: string[];

View File

@@ -44,7 +44,8 @@ export default class HaAutomationSidebarCondition extends LitElement {
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "sidebar-key" }) public sidebarKey?: string;
@property({ type: Number, attribute: "sidebar-key" })
public sidebarKey?: number;
@state() private _warnings?: string[];

View File

@@ -26,7 +26,8 @@ export default class HaAutomationSidebarScriptFieldSelector extends LitElement {
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "sidebar-key" }) public sidebarKey?: string;
@property({ type: Number, attribute: "sidebar-key" })
public sidebarKey?: number;
@state() private _warnings?: string[];

View File

@@ -25,7 +25,8 @@ export default class HaAutomationSidebarScriptField extends LitElement {
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "sidebar-key" }) public sidebarKey?: string;
@property({ type: Number, attribute: "sidebar-key" })
public sidebarKey?: number;
@state() private _warnings?: string[];

View File

@@ -37,7 +37,8 @@ export default class HaAutomationSidebarTrigger extends LitElement {
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "sidebar-key" }) public sidebarKey?: string;
@property({ type: Number, attribute: "sidebar-key" })
public sidebarKey?: number;
@state() private _warnings?: string[];

View File

@@ -98,7 +98,7 @@ class DialogCategoryDetail extends LitElement {
</div>
<ha-button
appearance="plain"
slot="primaryAction"
slot="secondaryAction"
@click=${this.closeDialog}
>
${this.hass.localize("ui.common.cancel")}

View File

@@ -173,7 +173,7 @@ export class DialogEnergySolarSettings
<ha-checkbox
.entry=${entry}
@change=${this._forecastCheckChanged}
.checked=${this._source?.config_entry_solar_forecast?.includes(
.checked=${!!this._source?.config_entry_solar_forecast?.includes(
entry.entry_id
)}
>

View File

@@ -492,7 +492,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
)}</span
>
<ha-switch
.checked=${this.entry.options?.switch_as_x?.invert}
.checked=${!!this.entry.options?.switch_as_x?.invert}
@change=${this._switchAsInvertChanged}
></ha-switch>
</ha-settings-row>

View File

@@ -126,7 +126,7 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
.hass=${this.hass}
.entry=${this.entry}
.helperConfigEntry=${this._helperConfigEntry}
.disabled=${this._submitting}
.disabled=${!!this._submitting}
@change=${this._entityRegistryChanged}
></entity-registry-settings-editor>
</div>

View File

@@ -153,6 +153,8 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
},
yAxis: {
type: "value",
min: 0,
max: 100,
splitLine: {
show: true,
},

View File

@@ -191,7 +191,7 @@ class ZWaveJSCapabilityDoorLock extends LitElement {
<ha-switch
@change=${this._booleanChanged}
key="twistAssist"
.checked=${this._configuration?.twistAssist}
.checked=${!!this._configuration?.twistAssist}
>
</ha-switch>
</ha-formfield>
@@ -209,7 +209,7 @@ class ZWaveJSCapabilityDoorLock extends LitElement {
<ha-switch
@change=${this._booleanChanged}
key="blockToBlock"
.checked=${this._configuration?.blockToBlock}
.checked=${!!this._configuration?.blockToBlock}
>
</ha-switch>
</ha-formfield>

View File

@@ -3,6 +3,7 @@ import {
mdiDevices,
mdiDotsVertical,
mdiHelpCircle,
mdiLabelOutline,
mdiPlus,
mdiRobot,
mdiShape,
@@ -23,8 +24,10 @@ import type {
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/ha-fab";
import "../../../components/ha-icon";
import "../../../components/ha-icon-button";
import "../../../components/ha-relative-time";
import type { HaMdMenu } from "../../../components/ha-md-menu";
import "../../../components/ha-svg-icon";
import type {
LabelRegistryEntry,
LabelRegistryEntryMutableParams,
@@ -43,7 +46,6 @@ import "../../../layouts/hass-tabs-subpage-data-table";
import type { HomeAssistant, Route } from "../../../types";
import { configSections } from "../ha-panel-config";
import { showLabelDetailDialog } from "./show-dialog-label-detail";
import type { HaMdMenu } from "../../../components/ha-md-menu";
@customElement("ha-config-labels")
export class HaConfigLabels extends LitElement {
@@ -100,7 +102,9 @@ export class HaConfigLabels extends LitElement {
label: localize("ui.panel.config.labels.headers.icon"),
type: "icon",
template: (label) =>
label.icon ? html`<ha-icon .icon=${label.icon}></ha-icon>` : nothing,
label.icon
? html`<ha-icon .icon=${label.icon}></ha-icon>`
: html`<ha-svg-icon .path=${mdiLabelOutline}></ha-svg-icon>`,
},
color: {
title: "",
@@ -108,18 +112,18 @@ export class HaConfigLabels extends LitElement {
label: localize("ui.panel.config.labels.headers.color"),
type: "icon",
template: (label) =>
label.color
? html`<div
style=${styleMap({
backgroundColor: computeCssColor(label.color),
borderRadius: "var(--ha-border-radius-md)",
border: "1px solid var(--outline-color)",
boxSizing: "border-box",
width: "20px",
height: "20px",
})}
></div>`
: nothing,
html`<div
style=${styleMap({
backgroundColor: label.color
? computeCssColor(label.color)
: undefined,
borderRadius: "var(--ha-border-radius-md)",
border: "1px solid var(--outline-color)",
boxSizing: "border-box",
width: "var(--ha-space-5)",
height: "var(--ha-space-5)",
})}
></div>`,
},
name: {
title: localize("ui.panel.config.labels.headers.name"),

View File

@@ -1,15 +1,14 @@
import { mdiClose, mdiOpenInNew } from "@mdi/js";
import { mdiOpenInNew } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state, query } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { isNavigationClick } from "../../../common/dom/is-navigation-click";
import "../../../components/ha-alert";
import "../../../components/ha-md-dialog";
import type { HaMdDialog } from "../../../components/ha-md-dialog";
import "../../../components/ha-wa-dialog";
import "../../../components/ha-button";
import "../../../components/ha-svg-icon";
import "../../../components/ha-dialog-header";
import "../../../components/ha-dialog-footer";
import "./dialog-repairs-issue-subtitle";
import "../../../components/ha-markdown";
import type { RepairsIssue } from "../../../data/repairs";
@@ -26,11 +25,12 @@ class DialogRepairsIssue extends LitElement {
@state() private _params?: RepairsIssueDialogParams;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
@state() private _open = false;
public showDialog(params: RepairsIssueDialogParams): void {
this._params = params;
this._issue = this._params.issue;
this._open = true;
}
private _dialogClosed() {
@@ -44,7 +44,7 @@ class DialogRepairsIssue extends LitElement {
}
public closeDialog() {
this._dialog?.close();
this._open = false;
}
protected render() {
@@ -62,32 +62,19 @@ class DialogRepairsIssue extends LitElement {
) || this.hass!.localize("ui.panel.config.repairs.dialog.title");
return html`
<ha-md-dialog
open
@closed=${this._dialogClosed}
aria-labelledby="dialog-repairs-issue-title"
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${dialogTitle}
aria-describedby="dialog-repairs-issue-description"
@closed=${this._dialogClosed}
>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.common.close") ?? "Close"}
.path=${mdiClose}
@click=${this.closeDialog}
></ha-icon-button>
<span
slot="title"
id="dialog-repairs-issue-title"
.title=${dialogTitle}
>${dialogTitle}</span
>
<dialog-repairs-issue-subtitle
slot="subtitle"
.hass=${this.hass}
.issue=${this._issue}
></dialog-repairs-issue-subtitle>
</ha-dialog-header>
<div slot="content" class="dialog-content">
<dialog-repairs-issue-subtitle
slot="headerSubtitle"
.hass=${this.hass}
.issue=${this._issue}
></dialog-repairs-issue-subtitle>
<div class="dialog-content">
${this._issue.breaks_in_ha_version
? html`
<ha-alert alert-type="warning">
@@ -122,8 +109,12 @@ class DialogRepairsIssue extends LitElement {
`
: ""}
</div>
<div slot="actions">
<ha-button appearance="plain" @click=${this._ignoreIssue}>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this._ignoreIssue}
>
${this._issue!.ignored
? this.hass!.localize("ui.panel.config.repairs.dialog.unignore")
: this.hass!.localize("ui.panel.config.repairs.dialog.ignore")}
@@ -131,6 +122,7 @@ class DialogRepairsIssue extends LitElement {
${this._issue.learn_more_url
? html`
<ha-button
slot="primaryAction"
appearance="filled"
rel="noopener noreferrer"
href=${learnMoreUrlIsHomeAssistant
@@ -149,8 +141,8 @@ class DialogRepairsIssue extends LitElement {
</ha-button>
`
: ""}
</div>
</ha-md-dialog>
</ha-dialog-footer>
</ha-wa-dialog>
`;
}

View File

@@ -89,7 +89,7 @@ export class HaManualScriptEditor extends LitElement {
@state() private _sidebarConfig?: SidebarConfig;
@state() private _sidebarKey?: string;
@state() private _sidebarKey = 0;
@storage({
key: "automation-sidebar-width",
@@ -512,7 +512,9 @@ export class HaManualScriptEditor extends LitElement {
// deselect previous selected row
this._sidebarConfig?.close?.();
this._sidebarConfig = ev.detail;
this._sidebarKey = JSON.stringify(this._sidebarConfig);
// be sure the sidebar editor is recreated
this._sidebarKey++;
await this._sidebarElement?.updateComplete;
this._sidebarElement?.focus();
@@ -537,6 +539,7 @@ export class HaManualScriptEditor extends LitElement {
return;
}
this._sidebarConfig?.close();
this._sidebarKey = 0;
}
}

View File

@@ -34,6 +34,7 @@ import { fireEvent } from "../../../../common/dom/fire_event";
import { measureTextWidth } from "../../../../util/text";
import "../../../../components/ha-icon-button";
import { storage } from "../../../../common/decorators/storage";
import { listenMediaQuery } from "../../../../common/dom/media_query";
@customElement("hui-energy-devices-graph-card")
export class HuiEnergyDevicesGraphCard
@@ -56,6 +57,8 @@ export class HuiEnergyDevicesGraphCard
})
private _chartType: "bar" | "pie" = "bar";
@state() private _isMobile = false;
private _compoundStats: string[] = [];
protected hassSubscribeRequiredHostProps = ["_config"];
@@ -68,6 +71,12 @@ export class HuiEnergyDevicesGraphCard
this._data = data;
this._getStatistics(data);
}),
listenMediaQuery(
"all and (max-width: 450px), all and (max-height: 500px)",
(matches) => {
this._isMobile = matches;
}
),
];
}
@@ -154,9 +163,6 @@ export class HuiEnergyDevicesGraphCard
yAxis: { show: false },
};
if (chartType === "bar") {
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
options.xAxis = {
show: true,
type: "value",
@@ -175,7 +181,7 @@ export class HuiEnergyDevicesGraphCard
fontSize: 12,
margin: 5,
width: Math.min(
isMobile ? 100 : 200,
this._isMobile ? 100 : 200,
Math.max(
...(data[0]?.data?.map(
(d: any) =>
@@ -235,8 +241,14 @@ export class HuiEnergyDevicesGraphCard
this._chartType === "pie"
? {
formatter: ({ name }) => this._getDeviceName(name),
overflow: "break",
alignTo: this._isMobile ? "edge" : "none",
edgeDistance: 1,
}
: undefined,
labelLine: {
length2: 10,
},
} as BarSeriesOption | PieSeriesOption,
];

View File

@@ -660,7 +660,7 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
display: flex;
align-items: center;
justify-content: center;
--mdc-icon-size: 48px;
--mdc-icon-size: var(--ha-space-12);
color: var(--tile-color);
}
.picture .icon-container::before {
@@ -729,13 +729,15 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
}
hui-card-features {
--feature-color: var(--tile-color);
padding: 0 12px 12px 12px;
padding: 0 var(--ha-space-3) var(--ha-space-3) var(--ha-space-3);
}
.container.horizontal hui-card-features {
width: calc(50% - var(--column-gap, 0px) / 2 - 12px);
width: calc(
50% - var(--column-gap, var(--ha-space-0)) / 2 - var(--ha-space-3)
);
flex: none;
--feature-height: 36px;
padding: 0 12px;
--feature-height: var(--ha-space-9);
padding: 0 var(--ha-space-3);
padding-inline-start: 0;
}
.alert-badge {
@@ -748,18 +750,18 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
display: flex;
flex-direction: row;
gap: var(--ha-space-2);
padding: 8px;
padding: var(--ha-space-2);
pointer-events: none;
z-index: 1;
}
.alert {
background-color: var(--orange-color);
border-radius: var(--ha-border-radius-lg);
width: 24px;
height: 24px;
width: var(--ha-space-6);
height: var(--ha-space-6);
padding: 2px;
box-sizing: border-box;
--mdc-icon-size: 16px;
--mdc-icon-size: var(--ha-space-4);
display: flex;
align-items: center;
justify-content: center;

View File

@@ -447,13 +447,13 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
}
hui-card-features {
--feature-color: var(--tile-color);
padding: 0 12px 12px 12px;
padding: 0 var(--ha-space-3) var(--ha-space-3) var(--ha-space-3);
}
.container.horizontal hui-card-features {
width: calc(50% - var(--column-gap, 0px) / 2 - 12px);
width: calc(50% - var(--column-gap, 0px) / 2 - var(--ha-space-3));
flex: none;
--feature-height: 36px;
padding: 0 12px;
--feature-height: var(--ha-space-9);
padding: 0 var(--ha-space-3);
padding-inline-start: 0;
}

View File

@@ -563,7 +563,7 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
.name,
.attribute {
font-size: var(--ha-font-size-m);
line-height: 1;
line-height: var(--ha-line-height-condensed);
}
.name-state {
@@ -729,7 +729,7 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
.short .state,
.short .temp-attribute .temp {
font-size: 24px;
line-height: 1.25;
line-height: var(--ha-line-height-condensed);
}
.short .content + .forecast {

View File

@@ -174,10 +174,10 @@ export class HuiEntityEditor extends LitElement {
</div>
</ha-sortable>`}
<ha-entity-picker
class="add-entity"
.hass=${this.hass}
.entityFilter=${this.entityFilter}
@value-changed=${this._addEntity}
add-button
></ha-entity-picker>
`;
}
@@ -226,13 +226,6 @@ export class HuiEntityEditor extends LitElement {
ha-entity-picker {
margin-top: 8px;
}
.add-entity {
display: block;
margin-left: 31px;
margin-inline-start: 31px;
margin-inline-end: initial;
direction: var(--direction);
}
.entity {
display: flex;
align-items: center;

View File

@@ -495,7 +495,7 @@ export class HuiCardFeaturesEditor extends LitElement {
flex-direction: column;
}
ha-button-menu {
margin-top: 8px;
margin-top: var(--ha-space-2);
}
.feature {
display: flex;
@@ -504,8 +504,8 @@ export class HuiCardFeaturesEditor extends LitElement {
.feature .handle {
cursor: move; /* fallback if grab cursor is unsupported */
cursor: grab;
padding-right: 8px;
padding-inline-end: 8px;
padding-right: var(--ha-space-2);
padding-inline-end: var(--ha-space-2);
padding-inline-start: initial;
direction: var(--direction);
}
@@ -514,7 +514,7 @@ export class HuiCardFeaturesEditor extends LitElement {
}
.feature-content {
height: 60px;
height: var(--ha-space-15);
font-size: var(--ha-font-size-l);
display: flex;
align-items: center;
@@ -529,7 +529,7 @@ export class HuiCardFeaturesEditor extends LitElement {
.remove-icon,
.edit-icon {
--mdc-icon-button-size: 36px;
--mdc-icon-button-size: var(--ha-space-9);
color: var(--secondary-text-color);
}

View File

@@ -1,20 +1,12 @@
import "@material/mwc-menu/mwc-menu-surface";
import {
mdiDelete,
mdiDragHorizontalVariant,
mdiPencil,
mdiPlus,
} from "@mdi/js";
import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light";
import { mdiDelete, mdiDragHorizontalVariant, mdiPencil } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../../../../common/dom/fire_event";
import { preventDefault } from "../../../../common/dom/prevent_default";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { computeStateName } from "../../../../common/entity/compute_state_name";
import "../../../../components/entity/ha-entity-picker";
import type { HaEntityPicker } from "../../../../components/entity/ha-entity-picker";
import "../../../../components/ha-button";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-sortable";
@@ -36,14 +28,6 @@ export class HuiHeadingBadgesEditor extends LitElement {
@property({ attribute: false })
public badges?: LovelaceHeadingBadgeConfig[];
@query(".add-container", true) private _addContainer?: HTMLDivElement;
@query("ha-entity-picker") private _entityPicker?: HaEntityPicker;
@state() private _addMode = false;
private _opened = false;
private _badgesKeys = new WeakMap<LovelaceHeadingBadgeConfig, string>();
private _getKey(badge: LovelaceHeadingBadgeConfig) {
@@ -125,32 +109,6 @@ export class HuiHeadingBadgesEditor extends LitElement {
`
: nothing}
<div class="add-container">
<ha-button
data-add-entity
appearance="filled"
@click=${this._addEntity}
>
<ha-svg-icon .path=${mdiPlus} slot="start"></ha-svg-icon>
${this.hass!.localize(`ui.panel.lovelace.editor.entities.add`)}
</ha-button>
${this._renderPicker()}
</div>
`;
}
private _renderPicker() {
if (!this._addMode) {
return nothing;
}
return html`
<mwc-menu-surface
open
.anchor=${this._addContainer}
@closed=${this._onClosed}
@opened=${this._onOpened}
@opened-changed=${this._openedChanged}
@input=${stopPropagation}
>
<ha-entity-picker
.hass=${this.hass}
id="input"
@@ -161,39 +119,15 @@ export class HuiHeadingBadgesEditor extends LitElement {
"ui.components.entity.entity-picker.choose_entity"
)}
@value-changed=${this._entityPicked}
.value=${undefined}
@click=${preventDefault}
allow-custom-entity
add-button
></ha-entity-picker>
</mwc-menu-surface>
</div>
`;
}
private _onClosed(ev) {
ev.stopPropagation();
ev.target.open = true;
}
private async _onOpened() {
if (!this._addMode) {
return;
}
await this._entityPicker?.focus();
await this._entityPicker?.open();
this._opened = true;
}
private _openedChanged(ev: ComboBoxLightOpenedChangedEvent) {
if (this._opened && !ev.detail.value) {
this._opened = false;
this._addMode = false;
}
}
private async _addEntity(ev): Promise<void> {
ev.stopPropagation();
this._addMode = true;
}
private _entityPicked(ev) {
ev.stopPropagation();
if (!ev.detail.value) {

View File

@@ -115,6 +115,7 @@ export class HuiEntitiesCardRowEditor extends LitElement {
class="add-entity"
.hass=${this.hass}
@value-changed=${this._addEntity}
add-button
></ha-entity-picker>
`;
}

View File

@@ -72,8 +72,7 @@ import {
} from "../../dialogs/quick-bar/show-dialog-quick-bar";
import { showShortcutsDialog } from "../../dialogs/shortcuts/show-shortcuts-dialog";
import { showVoiceCommandDialog } from "../../dialogs/voice-command-dialog/show-ha-voice-command-dialog";
import { ViewTransitionMixin } from "../../mixins/view-transition-mixin";
import { haStyle, haStyleViewTransitions } from "../../resources/styles";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant, PanelInfo } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import { showToast } from "../../util/toast";
@@ -115,7 +114,7 @@ interface SubActionItem {
}
@customElement("hui-root")
class HUIRoot extends ViewTransitionMixin(LitElement) {
class HUIRoot extends LitElement {
@property({ attribute: false }) public panel?: PanelInfo<LovelacePanelConfig>;
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -497,7 +496,6 @@ class HUIRoot extends ViewTransitionMixin(LitElement) {
class=${classMap({
"edit-mode": this._editMode,
narrow: this.narrow,
loading: !this._loaded,
})}
>
<div class="header">
@@ -1167,45 +1165,43 @@ class HUIRoot extends ViewTransitionMixin(LitElement) {
// Recreate a new element to clear the applied themes.
const root = this._viewRoot;
this.startViewTransition(() => {
if (root.lastChild) {
root.removeChild(root.lastChild);
}
if (root.lastChild) {
root.removeChild(root.lastChild);
}
if (viewIndex === "hass-unused-entities") {
const unusedEntities = document.createElement("hui-unused-entities");
// Wait for promise to resolve so that the element has been upgraded.
import("./editor/unused-entities/hui-unused-entities").then(() => {
unusedEntities.hass = this.hass!;
unusedEntities.lovelace = this.lovelace!;
unusedEntities.narrow = this.narrow;
});
root.appendChild(unusedEntities);
return;
}
if (viewIndex === "hass-unused-entities") {
const unusedEntities = document.createElement("hui-unused-entities");
// Wait for promise to resolve so that the element has been upgraded.
import("./editor/unused-entities/hui-unused-entities").then(() => {
unusedEntities.hass = this.hass!;
unusedEntities.lovelace = this.lovelace!;
unusedEntities.narrow = this.narrow;
});
root.appendChild(unusedEntities);
return;
}
let view;
const viewConfig = this.config.views[viewIndex];
let view;
const viewConfig = this.config.views[viewIndex];
if (!viewConfig) {
this.lovelace!.setEditMode(true);
return;
}
if (!viewConfig) {
this.lovelace!.setEditMode(true);
return;
}
if (!force && this._viewCache![viewIndex]) {
view = this._viewCache![viewIndex];
} else {
view = document.createElement("hui-view");
view.index = viewIndex;
this._viewCache![viewIndex] = view;
}
if (!force && this._viewCache![viewIndex]) {
view = this._viewCache![viewIndex];
} else {
view = document.createElement("hui-view");
view.index = viewIndex;
this._viewCache![viewIndex] = view;
}
view.lovelace = this.lovelace;
view.hass = this.hass;
view.narrow = this.narrow;
view.lovelace = this.lovelace;
view.hass = this.hass;
view.narrow = this.narrow;
root.appendChild(view);
});
root.appendChild(view);
}
private _openShortcutDialog(ev: Event) {
@@ -1216,21 +1212,12 @@ class HUIRoot extends ViewTransitionMixin(LitElement) {
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleViewTransitions,
css`
:host {
-ms-user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
}
@media (prefers-reduced-motion: no-preference) {
::view-transition-new(hui-root-container) {
animation: fade-in var(--ha-animation-layout-duration) ease-out;
animation-delay: var(--ha-animation-layout-delay-base);
}
}
.header {
background-color: var(--app-header-background-color);
color: var(--app-header-text-color, white);
@@ -1419,10 +1406,6 @@ class HUIRoot extends ViewTransitionMixin(LitElement) {
padding-right: var(--safe-area-inset-right);
padding-inline-end: var(--safe-area-inset-right);
padding-bottom: var(--safe-area-inset-bottom);
view-transition-name: hui-root-container;
}
.loading hui-view-container {
opacity: 0;
}
.narrow hui-view-container {
padding-left: var(--safe-area-inset-left);
@@ -1431,7 +1414,6 @@ class HUIRoot extends ViewTransitionMixin(LitElement) {
hui-view-container > * {
flex: 1 1 100%;
max-width: 100%;
view-transition-name: layout-fade-in;
}
/**
* In edit mode we have the tab bar on a new line *

View File

@@ -38,7 +38,7 @@ class AdvancedModeRow extends LitElement {
</a>
</span>
<ha-switch
.checked=${this.coreUserData && this.coreUserData.showAdvanced}
.checked=${!!this.coreUserData && !!this.coreUserData.showAdvanced}
.disabled=${this.coreUserData === undefined}
@change=${this._advancedToggled}
></ha-switch>

View File

@@ -32,7 +32,8 @@ class EntityIdPickerRow extends LitElement {
${this.hass.localize("ui.panel.profile.entity_id_picker.description")}
</span>
<ha-switch
.checked=${this.coreUserData && this.coreUserData.showEntityIdPicker}
.checked=${!!this.coreUserData &&
!!this.coreUserData.showEntityIdPicker}
.disabled=${this.coreUserData === undefined}
@change=${this._toggled}
></ha-switch>

View File

@@ -199,56 +199,3 @@ export const baseEntrypointStyles = css`
width: 100vw;
}
`;
export const haStyleViewTransitions = css`
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: no-preference) {
/* Prevent root cross-fade during view transitions (pseudo-element) */
::view-transition-old(root) {
animation: none;
}
::view-transition-new(root) {
animation: none;
}
/* Elements leaving the view (loading screen) */
::view-transition-group(layout-fade-out) {
animation-duration: var(--ha-animation-layout-duration);
animation-timing-function: ease-out;
}
::view-transition-old(layout-fade-out) {
animation: fade-out var(--ha-animation-layout-duration) ease-out;
}
::view-transition-new(layout-fade-out) {
animation: none;
}
/* New content entering (panels, subpages)
Uses base delay to be less abrupt and allow for elements to render */
::view-transition-group(layout-fade-in) {
animation-duration: var(--ha-animation-layout-duration);
animation-timing-function: ease-out;
}
::view-transition-new(layout-fade-in) {
animation: fade-in var(--ha-animation-layout-duration) ease-out;
animation-delay: var(--ha-animation-layout-delay-base);
}
}
`;

View File

@@ -42,17 +42,6 @@ export const coreStyles = css`
--ha-space-18: 72px;
--ha-space-19: 76px;
--ha-space-20: 80px;
/* Animation timing */
--ha-animation-layout-duration: 350ms;
--ha-animation-layout-delay-base: 100ms;
}
@media (prefers-reduced-motion: reduce) {
html {
--ha-animation-layout-duration: 0ms;
--ha-animation-layout-delay-base: 0ms;
}
}
`;

View File

@@ -648,6 +648,7 @@
"entity-picker": {
"choose_entity": "Choose entity",
"entity": "Entity",
"add": "Add entity",
"edit": "Edit",
"clear": "Clear",
"no_entities": "You don't have any entities",
@@ -676,6 +677,9 @@
},
"entity-state-picker": {
"state": "State"
},
"entity-state-content-picker": {
"add": "Add"
}
},
"target-picker": {
@@ -806,6 +810,7 @@
"labels": "Labels",
"add_new_sugestion": "Add new label ''{name}''",
"add_new": "Add new label…",
"add": "Add label",
"no_labels": "You don't have any labels",
"no_match": "No matching labels found",
"failed_create_label": "Failed to create label."
@@ -3915,7 +3920,6 @@
"edit_yaml": "Edit in YAML",
"edit_ui": "Edit in visual editor",
"copy_to_clipboard": "Copy to clipboard",
"search_in": "Search · {group}",
"unknown_entity": "unknown entity",
"edit_unknown_device": "Editor not available for unknown device",
"switch_ui_yaml_error": "There are currently YAML errors in the automation, and it cannot be parsed. Switching to UI mode may cause pending changes to be lost. Press cancel to correct any errors before proceeding to prevent loss of pending changes, or continue if you are sure.",
@@ -3930,6 +3934,7 @@
"item_pasted": "{item} pasted",
"ctrl": "Ctrl",
"del": "Del",
"blocks": "Blocks",
"triggers": {
"name": "Triggers",
"header": "When",
@@ -3937,7 +3942,7 @@
"learn_more": "Learn more about triggers",
"triggered": "Triggered",
"add": "Add trigger",
"search": "Search trigger",
"empty_search": "No triggers found for {term}",
"id": "Trigger ID",
"id_helper": "Helps identify each run based on which trigger fired.",
"optional": "Optional",
@@ -3958,14 +3963,16 @@
"trigger": "Trigger",
"copied_to_clipboard": "Trigger copied to clipboard",
"cut_to_clipboard": "Trigger cut to clipboard",
"select": "Select a trigger",
"groups": {
"device": {
"label": "Device"
},
"entity": {
"label": "Entity",
"description": "When something happens to an entity."
"label": "Entity"
},
"time_location": {
"label": "Time and location",
"description": "When someone enters or leaves a zone, or at a specific time."
"label": "Time and location"
},
"other": {
"label": "Other triggers"
@@ -4198,7 +4205,7 @@
"description": "All conditions added here need to be satisfied for the automation to run. A condition can be satisfied or not at any given time, for example: ''If {user} is home''. You can use building blocks to create more complex conditions.",
"learn_more": "Learn more about conditions",
"add": "Add condition",
"search": "Search condition",
"empty_search": "No conditions and blocks found for {term}",
"add_building_block": "Add building block",
"test": "Test",
"testing_error": "Condition did not pass",
@@ -4220,21 +4227,22 @@
"condition": "Condition",
"copied_to_clipboard": "Condition copied to clipboard",
"cut_to_clipboard": "Condition cut to clipboard",
"select": "Select a condition",
"groups": {
"device": {
"label": "Device"
},
"entity": {
"label": "Entity",
"description": "If an entity is in a specific state."
"label": "Entity"
},
"time_location": {
"label": "Time and location",
"description": "If someone is in a zone or if the current time is before or after a specified time."
"label": "Time and location"
},
"other": {
"label": "Other conditions"
},
"building_blocks": {
"label": "Building blocks",
"description": "Build more complex conditions."
"label": "Building blocks"
}
},
"type": {
@@ -4365,7 +4373,7 @@
"description": "All actions added here will be performed in sequence when the automation runs. An action usually controls one of your areas, devices, or entities, for example: 'Turn on the lights'. You can use building blocks to create more complex sequences of actions.",
"learn_more": "Learn more about actions",
"add": "Add action",
"search": "Search action",
"empty_search": "No actions and blocks found for {term}",
"add_building_block": "Add building block",
"invalid_action": "Invalid action",
"run": "Run action",
@@ -4389,7 +4397,11 @@
"action": "Action",
"copied_to_clipboard": "Action copied to clipboard",
"cut_to_clipboard": "Action cut to clipboard",
"select": "Select an action",
"groups": {
"device_id": {
"label": "Device"
},
"helpers": {
"label": "Helpers"
},
@@ -4397,8 +4409,7 @@
"label": "Other actions"
},
"building_blocks": {
"label": "Building blocks",
"description": "Build more complex sequences of actions."
"label": "Building blocks"
}
},
"type": {

View File

@@ -3,21 +3,8 @@ import { render } from "lit";
export const removeLaunchScreen = () => {
const launchScreenElement = document.getElementById("ha-launch-screen");
if (!launchScreenElement?.parentElement) {
return;
}
// Use View Transition API if available and user doesn't prefer reduced motion
if (
document.startViewTransition &&
!window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
document.startViewTransition(() => {
launchScreenElement.parentElement?.removeChild(launchScreenElement);
});
} else {
// Fallback: Direct removal without transition
launchScreenElement.parentElement.removeChild(launchScreenElement);
if (launchScreenElement) {
launchScreenElement.parentElement!.removeChild(launchScreenElement);
}
};

View File

@@ -26,7 +26,7 @@ describe("floorCompare", () => {
});
describe("floorCompare(entries)", () => {
it("sorts by level, then by name", () => {
it("sorts by level descending (highest to lowest), then by name", () => {
const entries = {
floor1: { name: "Ground Floor", level: 0 } as FloorRegistryEntry,
floor2: { name: "First Floor", level: 1 } as FloorRegistryEntry,
@@ -35,13 +35,13 @@ describe("floorCompare", () => {
const floors = ["floor1", "floor2", "floor3"];
expect(floors.sort(floorCompare(entries))).toEqual([
"floor3",
"floor1",
"floor2",
"floor1",
"floor3",
]);
});
it("treats null level as 9999, placing it at the end", () => {
it("treats null level as -9999, placing it at the end", () => {
const entries = {
floor1: { name: "Ground Floor", level: 0 } as FloorRegistryEntry,
floor2: { name: "First Floor", level: 1 } as FloorRegistryEntry,
@@ -50,8 +50,8 @@ describe("floorCompare", () => {
const floors = ["floor2", "floor3", "floor1"];
expect(floors.sort(floorCompare(entries))).toEqual([
"floor1",
"floor2",
"floor1",
"floor3",
]);
});

1673
yarn.lock

File diff suppressed because it is too large Load Diff