Compare commits

..

56 Commits

Author SHA1 Message Date
Aidan Timson
83512e62f5 Remove 2025-10-23 11:16:09 +01:00
Aidan Timson
aa010bc6f0 Use unknown 2025-10-23 11:11:32 +01:00
Aidan Timson
19d6743f8c Show warning 2025-10-23 11:11:32 +01:00
Aidan Timson
e7f816b982 Cleanup 2025-10-23 11:11:32 +01:00
Aidan Timson
944ab1b3ce Less any 2025-10-23 11:11:31 +01:00
Aidan Timson
918e0f8383 Docs 2025-10-23 11:11:31 +01:00
Aidan Timson
146c2654b3 Docs 2025-10-23 11:11:31 +01:00
Aidan Timson
8af8d6cd3f Typescript error 2025-10-23 11:11:31 +01:00
Aidan Timson
cf93fb7091 Remove 2025-10-23 11:11:31 +01:00
Aidan Timson
3ce7b42dc3 Check for parent 2025-10-23 11:11:31 +01:00
Aidan Timson
91f5a8beca Remove duplicate implementation 2025-10-23 11:11:31 +01:00
Aidan Timson
50fc5645ae Cleanup 2025-10-23 11:11:31 +01:00
Aidan Timson
bacc478e4a Override method 2025-10-23 11:11:31 +01:00
Aidan Timson
e7bb2cc10c Remove duplicate implementations 2025-10-23 11:11:31 +01:00
Aidan Timson
7b37e9e030 Format 2025-10-23 11:11:31 +01:00
Aidan Timson
5da2abd720 Comments 2025-10-23 11:11:31 +01:00
Aidan Timson
61b34507ed Add guard 2025-10-23 11:11:31 +01:00
Aidan Timson
49f916428d Fix leak 2025-10-23 11:11:31 +01:00
Aidan Timson
71b568076c Simplify 2025-10-23 11:11:31 +01:00
Aidan Timson
4af4d86c53 Remove duplicate transitions (non view transitions) 2025-10-23 11:11:31 +01:00
Aidan Timson
13f6d2af1f Remove unused code 2025-10-23 11:11:31 +01:00
Aidan Timson
9f1fd06def Cleanup 2025-10-23 11:11:31 +01:00
Aidan Timson
d2f354ed71 Move duplicated logic into mixin 2025-10-23 11:11:31 +01:00
Aidan Timson
d612e29b31 Flip logic 2025-10-23 11:11:31 +01:00
Aidan Timson
6656fe7122 Setup other layouts 2025-10-23 11:11:31 +01:00
Aidan Timson
ab4f7cef2b Fix 2025-10-23 11:11:31 +01:00
Aidan Timson
ae929d57b6 Fix 2025-10-23 11:11:31 +01:00
Aidan Timson
6f8516aa4a Cleanup 2025-10-23 11:11:31 +01:00
Aidan Timson
74aa390229 Fix 2025-10-23 11:11:31 +01:00
Aidan Timson
944ed9f000 Rename 2025-10-23 11:11:31 +01:00
Aidan Timson
d4a02dddf0 Cleanup 2025-10-23 11:11:31 +01:00
Aidan Timson
59b56822b8 Cleanup 2025-10-23 11:11:31 +01:00
Aidan Timson
0d0eb737c6 Rename, zero for reduced motion 2025-10-23 11:11:31 +01:00
Aidan Timson
5338192c97 Show on loaded 2025-10-23 11:11:31 +01:00
Aidan Timson
04e9d1bec3 Rename 2025-10-23 11:11:31 +01:00
Aidan Timson
1bfbd1ec09 Fade out launch screen 2025-10-23 11:11:31 +01:00
Aidan Timson
afebe1d588 Allow transition name to be provided by caller 2025-10-23 11:11:31 +01:00
Aidan Timson
d0c527943d Use generic transition names 2025-10-23 11:11:31 +01:00
Aidan Timson
8f50e2c025 Switch to mixin 2025-10-23 11:11:31 +01:00
Aidan Timson
a9219a8779 Cleanup 2025-10-23 11:11:31 +01:00
Aidan Timson
2a135c50ce Order 2025-10-23 11:11:30 +01:00
Aidan Timson
36b11dbbcd Revert 2025-10-23 11:11:30 +01:00
Aidan Timson
37ea0a11fa Remove sidebar code 2025-10-23 11:11:30 +01:00
Aidan Timson
e9ab1c27d2 POC: view transitions 2025-10-23 11:11:30 +01:00
Aidan Timson
1ec0ff46c9 Respect reduced motion 2025-10-23 11:11:30 +01:00
Aidan Timson
2b0fd53349 Add to hui views 2025-10-23 11:11:30 +01:00
Aidan Timson
8c7643c524 Add animations 2025-10-23 11:11:30 +01:00
Aidan Timson
ff32bae8ea Cleanup 2025-10-23 11:11:30 +01:00
Aidan Timson
72cc53d960 Use index based delay 2025-10-23 11:11:30 +01:00
Aidan Timson
2b6ce8c34e Faster 2025-10-23 11:11:30 +01:00
Aidan Timson
f61ebe36b9 Fade in menu button 2025-10-23 11:11:30 +01:00
Aidan Timson
2c8e3762c6 Move 2025-10-23 11:11:30 +01:00
Aidan Timson
89b86d0d69 Cap stagger at 8 items 2025-10-23 11:11:30 +01:00
Aidan Timson
c60d038828 Animate sidebar 2025-10-23 11:11:30 +01:00
Aidan Timson
f9e2d4ef95 Set base themable animation durations 2025-10-23 11:11:30 +01:00
Aidan Timson
2609133f54 Create fade in slide down shared animation 2025-10-23 11:11:30 +01:00
88 changed files with 2443 additions and 3142 deletions

View File

@@ -89,7 +89,7 @@ jobs:
env:
IS_TEST: "true"
- name: Upload bundle stats
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
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@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
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@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
uses: github/codeql-action/autobuild@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
# 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@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9

View File

@@ -57,14 +57,14 @@ jobs:
run: tar -czvf translations.tar.gz translations
- name: Upload build artifacts
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: wheels
path: dist/home_assistant_frontend*.whl
if-no-files-found: error
- name: Upload translations
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
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.1",
"@codemirror/commands": "6.10.0",
"@codemirror/autocomplete": "6.19.0",
"@codemirror/commands": "6.9.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.6",
"@home-assistant/webawesome": "3.0.0-beta.6.ha.5",
"@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.5",
"@babel/core": "7.28.4",
"@babel/helper-define-polyfill-provider": "0.6.5",
"@babel/plugin-transform-runtime": "7.28.5",
"@babel/preset-env": "7.28.5",
"@babel/plugin-transform-runtime": "7.28.3",
"@babel/preset-env": "7.28.3",
"@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.4",
"@rsdoctor/rspack-plugin": "1.3.3",
"@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.6",
"@types/sortablejs": "1.15.9",
"@types/qrcode": "1.5.5",
"@types/sortablejs": "1.15.8",
"@types/tar": "6.1.13",
"@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.0.2",
"@vitest/coverage-v8": "3.2.4",
"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.6",
"lint-staged": "16.2.4",
"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.2",
"typescript-eslint": "8.46.1",
"vite-tsconfig-paths": "5.1.4",
"vitest": "4.0.2",
"vitest": "3.2.4",
"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,19 +88,9 @@ export class HaChartBase extends LitElement {
private _lastTapTime?: number;
private _shouldResizeChart = false;
// @ts-ignore
private _resizeController = new ResizeController(this, {
callback: () => {
if (this.chart) {
if (!this.chart.getZr().animation.isFinished()) {
this._shouldResizeChart = true;
} else {
this.chart.resize();
}
}
},
callback: () => this.chart?.resize(),
});
private _loading = false;
@@ -376,7 +366,6 @@ 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) {
@@ -956,13 +945,6 @@ 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,7 +147,6 @@ 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,9 +113,6 @@ 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 {
@@ -284,7 +281,7 @@ export class HaEntityPicker extends LitElement {
.searchLabel=${this.searchLabel}
.notFoundLabel=${notFoundLabel}
.placeholder=${placeholder}
.value=${this.addButton ? undefined : this.value}
.value=${this.value}
.rowRenderer=${this._rowRenderer}
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
@@ -292,9 +289,6 @@ 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,39 +1,23 @@
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 { mdiDragHorizontalVariant } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import type { PropertyValues } from "lit";
import { LitElement, css, html, 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 "../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>
`;
import "../chips/ha-input-chip";
import "../chips/ha-chip-set";
import type { HaComboBox } from "../ha-combo-box";
const HIDDEN_ATTRIBUTES = [
"access_token",
@@ -90,7 +74,7 @@ const HIDDEN_ATTRIBUTES = [
];
@customElement("ha-entity-state-content-picker")
export class HaStateContentPicker extends LitElement {
class HaEntityStatePicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId?: string;
@@ -111,28 +95,26 @@ export class HaStateContentPicker extends LitElement {
@property() public helper?: string;
@query(".container", true) private _container?: HTMLDivElement;
@state() private _opened = false;
@query("ha-combo-box", true) private _comboBox!: HaComboBox;
@state() private _opened = false;
protected shouldUpdate(changedProps: PropertyValues) {
return !(!changedProps.has("_opened") && this._opened);
}
private _editIndex?: number;
private _options = memoizeOne(
private options = memoizeOne(
(entityId?: string, stateObj?: HassEntity, allowName?: boolean) => {
const domain = entityId ? computeDomain(entityId) : undefined;
return [
{
primary: this.hass.localize(
"ui.components.state-content-picker.state"
),
label: this.hass.localize("ui.components.state-content-picker.state"),
value: "state",
},
...(allowName
? [
{
primary: this.hass.localize(
label: this.hass.localize(
"ui.components.state-content-picker.name"
),
value: "name",
@@ -140,13 +122,13 @@ export class HaStateContentPicker extends LitElement {
]
: []),
{
primary: this.hass.localize(
label: this.hass.localize(
"ui.components.state-content-picker.last_changed"
),
value: "last_changed",
},
{
primary: this.hass.localize(
label: this.hass.localize(
"ui.components.state-content-picker.last_updated"
),
value: "last_updated",
@@ -155,7 +137,7 @@ export class HaStateContentPicker extends LitElement {
? STATE_DISPLAY_SPECIAL_CONTENT.filter((content) =>
STATE_DISPLAY_SPECIAL_CONTENT_DOMAINS[domain]?.includes(content)
).map((content) => ({
primary: this.hass.localize(
label: this.hass.localize(
`ui.components.state-content-picker.${content}`
),
value: content,
@@ -164,201 +146,108 @@ export class HaStateContentPicker 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 options = this.options(this.entityId, stateObj, this.allowName);
const optionItems = options.filter(
(option) => !this._value.includes(option.value)
);
return html`
${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>
${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}
<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>
<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>
`;
}
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>) {
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;
}
this._opened = ev.detail.value;
this._comboBox.filteredItems = this._comboBox.items;
}
private _filterSelectedOptions = (
options: StateContentOption[],
current?: string
) => {
const value = this._value;
private _filterChanged(ev?: CustomEvent): void {
this._filter = ev?.detail.value || "";
return options.filter(
(option) => !value.includes(option.value) || option.value === current
);
};
const filteredItems = this._comboBox.items?.filter((item) => {
const label = item.label || item.value;
return label.toLowerCase().includes(this._filter?.toLowerCase());
});
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;
if (this._filter) {
filteredItems?.unshift({ label: this._filter, value: this._filter });
}
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;
}
@@ -371,40 +260,43 @@ export class HaStateContentPicker extends LitElement {
newValue.splice(newIndex, 0, element);
this._setValue(newValue);
await this.updateComplete;
this._filterChanged({ detail: { value: "" } } as ValueChangedEvent<string>);
this._filterChanged();
}
private async _removeItem(ev) {
ev.stopPropagation();
const value = [...this._value];
const idx = parseInt(ev.target.dataset.idx, 10);
value.splice(idx, 1);
const value: string[] = [...this._value];
value.splice(ev.target.idx, 1);
this._setValue(value);
await this.updateComplete;
this._filterChanged({ detail: { value: "" } } as ValueChangedEvent<string>);
this._filterChanged();
}
private _comboBoxValueChanged(ev: ValueChangedEvent<string>): void {
private _comboBoxValueChanged(ev: CustomEvent): void {
ev.stopPropagation();
const value = ev.detail.value;
const newValue = ev.detail.value;
if (this.disabled || value === "") {
if (this.disabled || newValue === "") {
return;
}
const newValue = [...this._value];
const currentValue = this._value;
if (this._editIndex != null) {
newValue[this._editIndex] = value;
} else {
newValue.push(value);
if (currentValue.includes(newValue)) {
return;
}
this._setValue(newValue);
setTimeout(() => {
this._filterChanged();
this._comboBox.setInputValue("");
}, 0);
this._setValue([...currentValue, newValue]);
}
private _setValue(value: string[]) {
const newValue = this._toValue(value);
const newValue =
value.length === 0 ? undefined : value.length === 1 ? value[0] : value;
this.value = newValue;
fireEvent(this, "value-changed", {
value: newValue,
@@ -414,64 +306,10 @@ export class HaStateContentPicker 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: var(--ha-space-2) var(--ha-space-2);
}
.invalid {
text-decoration: line-through;
padding: 8px 0;
}
.sortable-fallback {
@@ -491,6 +329,6 @@ export class HaStateContentPicker extends LitElement {
declare global {
interface HTMLElementTagNameMap {
"ha-entity-state-content-picker": HaStateContentPicker;
"ha-entity-state-content-picker": HaEntityStatePicker;
}
}

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

@@ -118,7 +118,7 @@ export class HaAutomationRow extends LitElement {
}
.row {
display: flex;
padding: var(--ha-space-0) var(--ha-space-2);
padding: 0 8px;
min-height: 48px;
align-items: center;
cursor: pointer;
@@ -134,12 +134,12 @@ export class HaAutomationRow extends LitElement {
.expand-button {
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--ha-color-on-neutral-quiet);
margin-left: calc(var(--ha-space-2) * -1);
margin-left: -8px;
}
:host([building-block]) .leading-icon-wrapper {
background-color: var(--ha-color-fill-neutral-loud-resting);
border-radius: var(--ha-border-radius-md);
padding: var(--ha-space-1);
padding: 4px;
display: flex;
justify-content: center;
align-items: center;
@@ -149,7 +149,7 @@ export class HaAutomationRow extends LitElement {
color: var(--ha-color-on-neutral-quiet);
}
:host([building-block]) ::slotted([slot="leading-icon"]) {
--mdc-icon-size: var(--ha-space-5);
--mdc-icon-size: 20px;
color: var(--white-color);
transform: rotate(-45deg);
}
@@ -170,7 +170,7 @@ export class HaAutomationRow extends LitElement {
::slotted([slot="header"]) {
flex: 1;
overflow-wrap: anywhere;
margin: var(--ha-space-0) var(--ha-space-3);
margin: 0 12px;
}
:host([sort-selected]) .row {
outline: solid;

View File

@@ -1,7 +1,6 @@
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;
@@ -38,61 +37,49 @@ export class HaBottomSheet extends LitElement {
@wa-after-hide=${this._handleAfterHide}
without-header
>
<slot name="header"></slot>
<div class="body ha-scrollbar">
<slot></slot>
</div>
<slot></slot>
</wa-drawer>
`;
}
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;
}
`,
];
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;
}
`;
}
declare global {

View File

@@ -31,9 +31,6 @@ 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"
@@ -41,13 +38,6 @@ 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">
@@ -56,9 +46,7 @@ export class HaButtonToggleGroup extends LitElement {
html`<ha-button
iconTag="ha-svg-icon"
class="icon"
.variant=${this.active !== button.value || !this.activeVariant
? this.variant
: this.activeVariant}
.variant=${this.variant}
.size=${this.size}
.value=${button.value}
@click=${this._handleClick}
@@ -90,19 +78,6 @@ 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

@@ -44,26 +44,26 @@ export class HaCard extends LitElement {
font-size: var(--ha-card-header-font-size, var(--ha-font-size-2xl));
letter-spacing: -0.012em;
line-height: var(--ha-line-height-expanded);
padding: var(--ha-space-3) var(--ha-space-4) var(--ha-space-4);
padding: 12px 16px 16px;
display: block;
margin-block-start: var(--ha-space-0);
margin-block-end: var(--ha-space-0);
margin-block-start: 0px;
margin-block-end: 0px;
font-weight: var(--ha-font-weight-normal);
}
:host ::slotted(.card-content:not(:first-child)),
slot:not(:first-child)::slotted(.card-content) {
padding-top: var(--ha-space-0);
margin-top: calc(var(--ha-space-2) * -1);
padding-top: 0px;
margin-top: -8px;
}
:host ::slotted(.card-content) {
padding: var(--ha-space-4);
padding: 16px;
}
:host ::slotted(.card-actions) {
border-top: 1px solid var(--divider-color, #e8e8e8);
padding: var(--ha-space-2);
padding: 8px;
}
`;

View File

@@ -6,9 +6,6 @@ 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: var(--ha-space-1) var(--ha-space-2) 0;
padding: 0 8px;
}
`,
];

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,10 @@
import "@home-assistant/webawesome/dist/components/popover/popover";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiPlaylistPlus } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light";
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";
@@ -19,7 +15,7 @@ import type {
PickerComboBoxSearchFn,
} from "./ha-picker-combo-box";
import "./ha-picker-field";
import type { PickerValueRenderer } from "./ha-picker-field";
import type { HaPickerField, PickerValueRenderer } from "./ha-picker-field";
import "./ha-svg-icon";
@customElement("ha-generic-picker")
@@ -57,7 +53,7 @@ export class HaGenericPicker extends LitElement {
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
@property({ attribute: false })
public rowRenderer?: RenderItemFunction<PickerComboBoxItem>;
public rowRenderer?: ComboBoxLitRenderer<PickerComboBoxItem>;
@property({ attribute: false })
public valueRenderer?: PickerValueRenderer;
@@ -68,130 +64,59 @@ export class HaGenericPicker extends LitElement {
@property({ attribute: "not-found-label", type: String })
public notFoundLabel?: string;
/** 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-field") private _field?: HaPickerField;
@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">
<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)
${!this._opened
? html`
<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
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}
>
${this._renderComboBox()}
</wa-popover>
</ha-picker-field>
`
: 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}
: 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>
`}
</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}
@@ -200,33 +125,13 @@ 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;
}
this._pickerWrapperOpen = false;
this._newValue = value;
fireEvent(this, "value-changed", { value });
}
private _clear(e) {
@@ -239,44 +144,24 @@ export class HaGenericPicker extends LitElement {
fireEvent(this, "value-changed", { value });
}
public async open(ev?: Event) {
ev?.stopPropagation();
public async open() {
if (this.disabled) {
return;
}
this._openedNarrow = this._narrow;
this._popoverWidth = this._containerElement?.offsetWidth || 250;
this._pickerWrapperOpen = true;
this._unsubscribeTinyKeys = tinykeys(this, {
Escape: this._handleEscClose,
});
this._opened = true;
await this.updateComplete;
this._comboBox?.focus();
this._comboBox?.open();
}
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 async _openedChanged(ev: ComboBoxLightOpenedChangedEvent) {
const opened = ev.detail.value;
if (this._opened && !opened) {
this._opened = false;
await this.updateComplete;
this._field?.focus();
}
};
private _handleEscClose = (ev: KeyboardEvent) => {
ev.stopPropagation();
};
}
static get styles(): CSSResultGroup {
return [
@@ -296,44 +181,6 @@ 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,13 +2,7 @@ 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,
queryAssignedElements,
state,
} from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import type { LabelRegistryEntry } from "../data/label_registry";
@@ -90,9 +84,6 @@ 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() {
@@ -220,14 +211,12 @@ 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}
@@ -235,7 +224,6 @@ 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,4 +1,3 @@
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";
@@ -124,6 +123,36 @@ 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}
@@ -133,47 +162,6 @@ 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>
`;
}
@@ -215,25 +203,9 @@ 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

@@ -50,7 +50,7 @@ export class HaMarkdown extends LitElement {
}
ha-alert {
display: block;
margin: var(--ha-space-1) 0;
margin: 4px 0;
}
a {
color: var(--primary-color);
@@ -75,7 +75,7 @@ export class HaMarkdown extends LitElement {
padding: 0;
}
pre {
padding: var(--ha-space-4);
padding: 16px;
overflow: auto;
line-height: var(--ha-line-height-condensed);
font-family: var(--ha-font-family-code);
@@ -95,7 +95,7 @@ export class HaMarkdown extends LitElement {
hr {
border-color: var(--divider-color);
border-bottom: none;
margin: var(--ha-space-4) 0;
margin: 16px 0;
}
` as CSSResultGroup;
}

View File

@@ -1,28 +1,19 @@
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 { css, html, LitElement, nothing } from "lit";
import {
customElement,
eventOptions,
property,
query,
state,
} from "lit/decorators";
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, 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 { haStyleScrollbar } from "../resources/styles";
import { loadVirtualizer } from "../resources/virtualizer";
import type { HomeAssistant } from "../types";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box";
import "./ha-combo-box-item";
import "./ha-icon";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
export interface PickerComboBoxItem {
id: string;
@@ -42,13 +33,10 @@ export interface PickerComboBoxItemWithLabel extends PickerComboBoxItem {
const NO_MATCHING_ITEMS_FOUND_ID = "___no_matching_items_found___";
const DEFAULT_ROW_RENDERER: RenderItemFunction<PickerComboBoxItem> = (
const DEFAULT_ROW_RENDERER: ComboBoxLitRenderer<PickerComboBoxItem> = (
item
) => html`
<ha-combo-box-item
.type=${item.id === NO_MATCHING_ITEMS_FOUND_ID ? "text" : "button"}
compact
>
<ha-combo-box-item type="button" compact>
${item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: item.icon_path
@@ -85,7 +73,7 @@ export class HaPickerComboBox extends LitElement {
@property() public value?: string;
@state() private _listScrolled = false;
@property() public helper?: string;
@property({ attribute: false, type: Array })
public getItems?: () => PickerComboBoxItem[];
@@ -94,7 +82,10 @@ export class HaPickerComboBox extends LitElement {
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
@property({ attribute: false })
public rowRenderer?: RenderItemFunction<PickerComboBoxItem>;
public rowRenderer?: ComboBoxLitRenderer<PickerComboBoxItem>;
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@property({ attribute: "not-found-label", type: String })
public notFoundLabel?: string;
@@ -102,59 +93,23 @@ export class HaPickerComboBox extends LitElement {
@property({ attribute: false })
public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
@property({ reflect: true }) public mode: "popover" | "dialog" = "popover";
@state() private _opened = false;
@query("lit-virtualizer") private _virtualizerElement?: LitVirtualizer;
@query("ha-combo-box", true) public comboBox!: HaComboBox;
@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 open() {
await this.updateComplete;
await this.comboBox?.open();
}
public willUpdate() {
if (!this.hasUpdated) {
loadVirtualizer();
this._allItems = this._getItems();
this._items = this._allItems;
}
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
}
disconnectedCallback() {
super.disconnectedCallback();
this._removeKeyboardShortcuts?.();
}
private _initialItems = false;
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 _items: PickerComboBoxItemWithLabel[] = [];
private _defaultNotFoundItem = memoizeOne(
(
@@ -204,56 +159,94 @@ export class HaPickerComboBox extends LitElement {
return sortedItems;
};
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>`;
};
protected shouldUpdate(changedProps: PropertyValues) {
if (
changedProps.has("value") ||
changedProps.has("label") ||
changedProps.has("disabled")
) {
return true;
}
return !(!changedProps.has("_opened") && this._opened);
}
@eventOptions({ passive: true })
private _onScrollList(ev) {
const top = ev.target.scrollTop ?? 0;
this._listScrolled = top > 0;
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>
`;
}
private get _value() {
return this.value || "";
}
private _valueSelected = (ev: Event) => {
private _openedChanged(ev: ValueChangedEvent<boolean>) {
ev.stopPropagation();
const value = (ev.currentTarget as any).value as string;
const newValue = value?.trim();
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();
if (newValue === NO_MATCHING_ITEMS_FOUND_ID) {
return;
}
fireEvent(this, "value-changed", { value: newValue });
};
if (newValue !== this._value) {
this._setValue(newValue);
}
}
private _fuseIndex = memoizeOne((states: PickerComboBoxItem[]) =>
Fuse.createIndex(["search_labels"], states)
);
private _filterChanged = (ev: Event) => {
const textfield = ev.target as HaTextField;
const searchString = textfield.value.trim();
private _filterChanged(ev: CustomEvent): void {
if (!this._opened) return;
const index = this._fuseIndex(this._allItems);
const fuse = new HaFuse(this._allItems, { shouldSort: false }, index);
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 results = fuse.multiTermsSearch(searchString);
let filteredItems = this._allItems as PickerComboBoxItem[];
let filteredItems = this._items as PickerComboBoxItem[];
if (results) {
const items = results.map((result) => result.item);
if (items.length === 0) {
@@ -267,266 +260,17 @@ export class HaPickerComboBox extends LitElement {
}
if (this.searchFn) {
filteredItems = this.searchFn(
searchString,
filteredItems,
this._allItems
);
filteredItems = this.searchFn(searchString, filteredItems, this._items);
}
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,
});
target.filteredItems = filteredItems;
}
private _focusList() {
if (this._selectedItemIndex === -1) {
this._selectNextItem();
}
private _setValue(value: string | undefined) {
setTimeout(() => {
fireEvent(this, "value-changed", { value });
}, 0);
}
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: var(--ha-space-4);
inset-inline-start: 12px;
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: var(--ha-space-4);
padding-inline-start: 12px;
padding-inline-end: 0px;
direction: var(--direction);
}
@@ -158,10 +158,7 @@ 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,
var(--ha-space-4)
);
padding-inline-end: var(--select-selected-text-padding-end, 12px);
}
ha-icon-button {
position: absolute;

View File

@@ -52,10 +52,9 @@ export class HaObjectSelector extends LitElement {
const translationKey = this.selector.object?.translation_key;
if (this.localizeValue && translationKey) {
const label =
this.localizeValue(`${translationKey}.fields.${schema.name}.name`) ||
// Fallback for backward compatibility
this.localizeValue(`${translationKey}.fields.${schema.name}`);
const label = this.localizeValue(
`${translationKey}.fields.${schema.name}`
);
if (label) {
return label;
}
@@ -63,20 +62,6 @@ 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 ||
@@ -229,7 +214,6 @@ 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 || false}
.allowName=${this.selector.ui_state_content?.allow_name}
></ha-entity-state-content-picker>
`;
}

View File

@@ -53,7 +53,7 @@ class HaServicePicker extends LitElement {
item,
{ index }
) => html`
<ha-combo-box-item type="button" .borderTop=${index !== 0}>
<ha-combo-box-item type="button" border-top .borderTop=${index !== 0}>
<ha-service-icon
slot="start"
.hass=${this.hass}
@@ -76,42 +76,34 @@ class HaServicePicker extends LitElement {
</ha-combo-box-item>
`;
private _valueRenderer = memoizeOne(
(
localize: LocalizeFunc,
services: HomeAssistant["services"]
): PickerValueRenderer =>
(value) => {
const serviceId = value;
const [domain, service] = serviceId.split(".");
private _valueRenderer: PickerValueRenderer = (value) => {
const serviceId = value;
const [domain, service] = serviceId.split(".");
if (!services[domain]?.[service]) {
return html`
<ha-svg-icon slot="start" .path=${mdiRoomService}></ha-svg-icon>
<span slot="headline">${value}</span>
`;
}
if (!this.hass.services[domain]?.[service]) {
return html`
<ha-svg-icon slot="start" .path=${mdiRoomService}></ha-svg-icon>
<span slot="headline">${value}</span>
`;
}
const serviceName =
localize(`component.${domain}.services.${service}.name`) ||
services[domain][service].name ||
service;
const serviceName =
this.hass.localize(`component.${domain}.services.${service}.name`) ||
this.hass.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 =
@@ -131,10 +123,7 @@ class HaServicePicker extends LitElement {
.value=${this.value}
.getItems=${this._getItems}
.rowRenderer=${this._rowRenderer}
.valueRenderer=${this._valueRenderer(
this.hass.localize,
this.hass.services
)}
.valueRenderer=${this._valueRenderer}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
@@ -173,9 +162,7 @@ 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,7 +29,6 @@ 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";
@@ -537,17 +536,11 @@ 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=${classMap({
user: true,
selected: selectedPanel === "profile",
rtl: isRTL,
})}
class="user ${selectedPanel === "profile" ? " selected" : ""}"
@mouseenter=${this._itemMouseEnter}
@mouseleave=${this._itemMouseLeave}
>
@@ -673,7 +666,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, var(--ha-space-0)))`;
tooltip.style.left = `calc(${item.offsetLeft + item.clientWidth + 8}px + var(--safe-area-inset-left, 0px))`;
}
private _hideTooltip() {
@@ -712,17 +705,13 @@ 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, var(--ha-space-0))
);
padding-bottom: calc(14px + var(--safe-area-inset-bottom, 0px));
}
.menu {
height: calc(
var(--header-height) + var(--safe-area-inset-top, var(--ha-space-0))
);
height: calc(var(--header-height) + var(--safe-area-inset-top, 0px));
box-sizing: border-box;
display: flex;
padding: 0 var(--ha-space-1);
padding: 0 4px;
border-bottom: 1px solid transparent;
white-space: nowrap;
font-weight: var(--ha-font-weight-normal);
@@ -737,17 +726,13 @@ class HaSidebar extends SubscribeMixin(LitElement) {
);
font-size: var(--ha-font-size-xl);
align-items: center;
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-left: calc(4px + var(--safe-area-inset-left, 0px));
padding-inline-start: calc(4px + var(--safe-area-inset-left, 0px));
padding-inline-end: initial;
padding-top: var(--safe-area-inset-top, var(--ha-space-0));
padding-top: var(--safe-area-inset-top, 0px);
}
:host([expanded]) .menu {
width: calc(256px + var(--safe-area-inset-left, var(--ha-space-0)));
width: calc(256px + var(--safe-area-inset-left, 0px));
}
:host([narrow][expanded]) .menu {
width: 100%;
@@ -763,8 +748,8 @@ class HaSidebar extends SubscribeMixin(LitElement) {
display: none;
}
:host([narrow]) .title {
margin: var(--ha-space-0);
padding: var(--ha-space-0) var(--ha-space-4);
margin: 0;
padding: 0 16px;
}
:host([expanded]) .title {
display: initial;
@@ -776,16 +761,13 @@ class HaSidebar extends SubscribeMixin(LitElement) {
ha-fade-in,
ha-md-list {
height: calc(
100% - var(--header-height) - var(
--safe-area-inset-top,
var(--ha-space-0)
) -
100% - var(--header-height) - var(--safe-area-inset-top, 0px) -
132px
);
}
ha-fade-in {
padding: var(--ha-space-1) var(--ha-space-0);
padding: 4px 0;
box-sizing: border-box;
display: flex;
justify-content: center;
@@ -795,29 +777,29 @@ class HaSidebar extends SubscribeMixin(LitElement) {
ha-md-list {
overflow-x: hidden;
background: none;
margin-left: var(--safe-area-inset-left, var(--ha-space-0));
margin-left: var(--safe-area-inset-left, 0px);
}
ha-md-list-item {
flex-shrink: 0;
box-sizing: border-box;
margin: var(--ha-space-1);
margin: 4px;
border-radius: var(--ha-border-radius-sm);
--md-list-item-one-line-container-height: var(--ha-space-10);
--md-list-item-one-line-container-height: 40px;
--md-list-item-top-space: 0;
--md-list-item-bottom-space: 0;
width: var(--ha-space-12);
width: 48px;
position: relative;
--md-list-item-label-text-color: var(--sidebar-text-color);
--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);
--md-list-item-leading-space: 12px;
--md-list-item-trailing-space: 12px;
--md-list-item-leading-icon-size: 24px;
}
:host([expanded]) ha-md-list-item {
width: 248px;
}
:host([narrow][expanded]) ha-md-list-item {
width: calc(240px - var(--safe-area-inset-left, var(--ha-space-0)));
width: calc(240px - var(--safe-area-inset-left, 0px));
}
ha-md-list-item.selected {
@@ -841,7 +823,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
ha-icon[slot="start"],
ha-svg-icon[slot="start"] {
width: var(--ha-space-6);
width: 24px;
flex-shrink: 0;
color: var(--sidebar-icon-color);
}
@@ -874,7 +856,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
display: flex;
justify-content: center;
align-items: center;
min-width: var(--ha-space-2);
min-width: 8px;
border-radius: var(--ha-border-radius-xl);
font-weight: var(--ha-font-weight-normal);
line-height: normal;
@@ -885,26 +867,22 @@ class HaSidebar extends SubscribeMixin(LitElement) {
ha-svg-icon + .badge {
position: absolute;
top: var(--ha-space-1);
top: 4px;
left: 26px;
border-radius: var(--ha-border-radius-md);
font-size: 0.65em;
line-height: var(--ha-line-height-expanded);
padding: var(--ha-space-0) var(--ha-space-1);
padding: 0 4px;
}
ha-md-list-item.user {
--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);
--md-list-item-leading-icon-size: 40px;
--md-list-item-leading-space: 4px;
}
ha-user-badge {
flex-shrink: 0;
margin-right: calc(var(--ha-space-2) * -1);
margin-right: -8px;
}
.spacer {
@@ -916,7 +894,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: var(--ha-space-4);
padding: 16px;
white-space: nowrap;
}
@@ -928,7 +906,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
white-space: nowrap;
color: var(--sidebar-background-color);
background-color: var(--sidebar-text-color);
padding: var(--ha-space-1);
padding: 4px;
font-weight: var(--ha-font-weight-medium);
}

View File

@@ -2,13 +2,26 @@ 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 TopAppBarFixedBase {
export class HaTopAppBarFixed extends ViewTransitionMixin(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);
@@ -23,6 +36,10 @@ export class HaTopAppBarFixed extends 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,14 +10,15 @@ 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 { haStyleScrollbar } from "../resources/styles";
import { ViewTransitionMixin } from "../mixins/view-transition-mixin";
import { haStyleScrollbar, haStyleViewTransitions } from "../resources/styles";
export const passiveEventOptionsIfSupported = supportsPassiveEventListener
? { passive: true }
: undefined;
@customElement("ha-two-pane-top-app-bar-fixed")
export class TopAppBarBaseBase extends BaseElement {
export class TopAppBarBaseBase extends ViewTransitionMixin(BaseElement) {
protected override mdcFoundation!: MDCFixedTopAppBarFoundation;
protected override mdcFoundationClass = MDCFixedTopAppBarFoundation;
@@ -144,7 +145,12 @@ export class TopAppBarBaseBase extends BaseElement {
: nothing}
<div class="main">
${this.pane ? html`<div class="shadow-container"></div>` : nothing}
<div class="content">
<div
class=${classMap({
content: true,
loading: !this._loaded,
})}
>
<slot></slot>
</div>
</div>
@@ -245,6 +251,7 @@ export class TopAppBarBaseBase extends BaseElement {
static override styles = [
styles,
haStyleScrollbar,
haStyleViewTransitions,
css`
header {
padding-top: var(--safe-area-inset-top);
@@ -341,6 +348,10 @@ export class TopAppBarBaseBase extends 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,18 +1,12 @@
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 { css, html, LitElement, nothing } from "lit";
import {
customElement,
eventOptions,
property,
query,
state,
} from "lit/decorators";
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";
import type { HomeAssistant } from "../types";
import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles";
export type DialogWidth = "small" | "medium" | "large" | "full";
@@ -96,11 +90,6 @@ export class HaWaDialog extends LitElement {
@state()
private _open = false;
@query(".body") public bodyContainer!: HTMLDivElement;
@state()
private _bodyScrolled = false;
protected updated(
changedProperties: Map<string | number | symbol, unknown>
): void {
@@ -118,14 +107,10 @@ export class HaWaDialog extends LitElement {
.lightDismiss=${!this.preventScrimClose}
without-header
@wa-show=${this._handleShow}
@wa-after-show=${this._handleAfterShow}
@wa-after-hide=${this._handleAfterHide}
>
<slot name="header">
<ha-dialog-header
.subtitlePosition=${this.headerSubtitlePosition}
.showBorder=${this._bodyScrolled}
>
<ha-dialog-header .subtitlePosition=${this.headerSubtitlePosition}>
<slot name="headerNavigationIcon" slot="navigationIcon">
<ha-icon-button
data-dialog="close"
@@ -144,7 +129,7 @@ export class HaWaDialog extends LitElement {
<slot name="headerActionItems" slot="actionItems"></slot>
</ha-dialog-header>
</slot>
<div class="body ha-scrollbar" @scroll=${this._handleBodyScroll}>
<div class="body ha-scrollbar">
<slot></slot>
</div>
<slot name="footer" slot="footer"></slot>
@@ -161,10 +146,6 @@ export class HaWaDialog extends LitElement {
(this.querySelector("[autofocus]") as HTMLElement | null)?.focus();
};
private _handleAfterShow = () => {
fireEvent(this, "after-show");
};
private _handleAfterHide = () => {
this._open = false;
fireEvent(this, "closed");
@@ -175,11 +156,6 @@ 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`
@@ -196,7 +172,7 @@ export class HaWaDialog extends LitElement {
)
)
);
--width: min(var(--ha-dialog-width-md, 580px), var(--full-width));
--width: var(--ha-dialog-width-md, min(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);
@@ -214,16 +190,14 @@ export class HaWaDialog extends LitElement {
);
max-width: var(--ha-dialog-max-width, 100vw);
max-width: var(--ha-dialog-max-width, 100svw);
/* TODO: animate view transition between width changes.
Needs https://github.com/home-assistant/frontend/pull/27281 for mixin */
}
:host([width="small"]) wa-dialog {
--width: min(var(--ha-dialog-width-sm, 320px), var(--full-width));
--width: var(--ha-dialog-width-sm, min(320px, var(--full-width)));
}
:host([width="large"]) wa-dialog {
--width: min(var(--ha-dialog-width-lg, 720px), var(--full-width));
--width: var(--ha-dialog-width-lg, min(720px, var(--full-width)));
}
:host([width="full"]) wa-dialog {
@@ -237,7 +211,6 @@ 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;
@@ -311,7 +284,6 @@ export class HaWaDialog extends LitElement {
}
:host([flexcontent]) .body {
max-width: 100%;
flex: 1;
display: flex;
flex-direction: column;
}
@@ -340,7 +312,6 @@ declare global {
interface HASSDomEvents {
opened: undefined;
"after-show": undefined;
closed: undefined;
}
}

View File

@@ -15,7 +15,6 @@ 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 {
@@ -694,12 +693,10 @@ export class HaMediaPlayerBrowse extends LitElement {
`
: ""}
</div>
<ha-tooltip .for="grid-${slugify(child.title)}" distance="-4">
<ha-tooltip .for="grid-${child.title}" distance="-4">
${child.title}
</ha-tooltip>
<div .id="grid-${slugify(child.title)}" class="title">
${child.title}
</div>
<div .id="grid-${child.title}" class="title">${child.title}</div>
</ha-card>
</div>
`;

View File

@@ -1,15 +1,17 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { mdiClose } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, 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";
@@ -19,15 +21,14 @@ class DialogTargetDetails extends LitElement implements HassDialog {
@state() private _params?: TargetDetailsDialogParams;
@state() private _opened = false;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
public showDialog(params: TargetDetailsDialogParams): void {
this._params = params;
this._opened = true;
}
public closeDialog() {
this._opened = false;
this._dialog?.close();
return true;
}
@@ -42,31 +43,58 @@ class DialogTargetDetails extends LitElement implements HassDialog {
}
return html`
<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>
<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>
`;
}
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,12 +162,11 @@ export class HaTargetPickerItemRow extends LitElement {
<div slot="headline">${name}</div>
${context && !this.hideContext
? html`<span slot="supporting-text">${context}</span>`
: nothing}
${this._domainName && this.subEntry
? html`<span slot="supporting-text" class="domain"
>${this._domainName}</span
>`
: 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">
@@ -232,11 +231,9 @@ export class HaTargetPickerItemRow extends LitElement {
const rows1 =
(nextType === "area"
? entries?.referenced_areas
: nextType === "device" && this.type !== "label"
: nextType === "device"
? entries?.referenced_devices
: this.type !== "label"
? entries?.referenced_entities
: []) || [];
: entries?.referenced_entities) || [];
const devicesInAreas = [] as string[];
@@ -287,13 +284,9 @@ export class HaTargetPickerItemRow extends LitElement {
const entityRows =
this.type === "label" && entries
? 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 || "")
);
})
? entries.referenced_entities.filter((entity_id) =>
this.hass.entities[entity_id].labels.includes(this.itemId)
)
: nextType === "device" && entries
? entries.referenced_entities.filter(
(entity_id) =>
@@ -419,6 +412,7 @@ 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,
@@ -675,14 +669,6 @@ 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,7 +16,6 @@ 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,
@@ -103,7 +102,7 @@ export class HaTargetPickerValueChip extends LitElement {
${this.type === "entity"
? nothing
: html`<span role="gridcell">
<ha-tooltip .for="expand-${slugify(this.itemId)}"
<ha-tooltip .for="expand-${this.itemId}"
>${this.hass.localize(
`ui.components.target-picker.expand_${this.type}_id`
)}
@@ -115,13 +114,13 @@ export class HaTargetPickerValueChip extends LitElement {
)}
.path=${mdiUnfoldMoreVertical}
hide-title
.id="expand-${slugify(this.itemId)}"
.id="expand-${this.itemId}"
.type=${this.type}
@click=${this._handleExpand}
></ha-icon-button>
</span>`}
<span role="gridcell">
<ha-tooltip .for="remove-${slugify(this.itemId)}">
<ha-tooltip .for="remove-${this.itemId}">
${this.hass.localize(
`ui.components.target-picker.remove_${this.type}_id`
)}
@@ -131,7 +130,7 @@ export class HaTargetPickerValueChip extends LitElement {
.label=${this.hass.localize("ui.components.target-picker.remove")}
.path=${mdiClose}
hide-title
.id="remove-${slugify(this.itemId)}"
.id="remove-${this.itemId}"
.type=${this.type}
@click=${this._removeItem}
></ha-icon-button>

View File

@@ -6,6 +6,8 @@ import {
mdiCallSplit,
mdiCodeBraces,
mdiDevices,
mdiDotsHorizontal,
mdiExcavator,
mdiFormatListNumbered,
mdiGestureDoubleTap,
mdiHandBackRight,
@@ -14,10 +16,10 @@ import {
mdiRoomService,
mdiShuffleDisabled,
mdiTimerOutline,
mdiTools,
mdiTrafficLight,
} from "@mdi/js";
import type { AutomationElementGroupCollection } from "./automation";
import type { Action } from "./script";
import type { AutomationElementGroup } from "./automation";
export const ACTION_ICONS = {
condition: mdiAbTesting,
@@ -46,73 +48,37 @@ export const YAML_ONLY_ACTION_TYPES = new Set<keyof typeof ACTION_ICONS>([
"variables",
]);
export const ACTION_COLLECTIONS: AutomationElementGroupCollection[] = [
{
groups: {
device_id: {},
serviceGroups: {},
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: {},
},
},
{
titleKey: "ui.panel.config.automation.editor.actions.groups.helpers.label",
groups: {
helpers: {},
},
},
{
titleKey: "ui.panel.config.automation.editor.actions.groups.other.label",
groups: {
other: {
icon: mdiDotsHorizontal,
members: {
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,7 +4,6 @@ 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";
@@ -294,11 +293,6 @@ 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,6 +3,8 @@ import {
mdiClockOutline,
mdiCodeBraces,
mdiDevices,
mdiDotsHorizontal,
mdiExcavator,
mdiGateOr,
mdiIdentifier,
mdiMapClock,
@@ -13,7 +15,7 @@ import {
mdiStateMachine,
mdiWeatherSunny,
} from "@mdi/js";
import type { AutomationElementGroupCollection } from "./automation";
import type { AutomationElementGroup } from "./automation";
export const CONDITION_ICONS = {
device: mdiDevices,
@@ -29,31 +31,25 @@ export const CONDITION_ICONS = {
zone: mdiMapMarkerRadius,
};
export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [
{
groups: {
device: {},
entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
time_location: {
icon: mdiMapClock,
members: { sun: {}, time: {}, zone: {} },
},
},
export const CONDITION_GROUPS: AutomationElementGroup = {
device: {},
entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
time_location: {
icon: mdiMapClock,
members: { sun: {}, time: {}, zone: {} },
},
{
titleKey: "ui.panel.config.automation.editor.conditions.groups.other.label",
groups: {
building_blocks: {
icon: mdiExcavator,
members: { and: {}, or: {}, not: {} },
},
other: {
icon: mdiDotsHorizontal,
members: {
template: {},
trigger: {},
},
},
] as const;
export const CONDITION_BUILDING_BLOCKS_GROUP = {
and: {},
or: {},
not: {},
};
} as const;
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 (floorB.level ?? -9999) - (floorA.level ?? -9999);
return (floorA.level ?? 9999) - (floorB.level ?? 9999);
}
const nameA = floorA?.name ?? a;
const nameB = floorB?.name ?? b;

View File

@@ -264,7 +264,6 @@ 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,7 +352,6 @@ export interface NumberSelector {
interface ObjectSelectorField {
selector: Selector;
label?: string;
description?: string;
required?: boolean;
}

View File

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

View File

@@ -34,7 +34,7 @@ import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-reques
import { navigate } from "../../common/navigate";
import { computeRTL } from "../../common/util/compute_rtl";
import "../../components/ha-button-menu";
import "../../components/ha-wa-dialog";
import "../../components/ha-dialog";
import "../../components/ha-dialog-header";
import "../../components/ha-icon-button";
import "../../components/ha-icon-button-prev";
@@ -99,8 +99,6 @@ export class MoreInfoDialog extends LitElement {
@property({ type: Boolean, reflect: true }) public large = false;
@state() private _open = false;
@state() private _parentEntityIds: string[] = [];
@state() private _entityId?: string | null;
@@ -133,7 +131,6 @@ export class MoreInfoDialog extends LitElement {
this._initialView = params.view || DEFAULT_VIEW;
this._childView = undefined;
this.large = false;
this._open = true;
this._loadEntityRegistryEntry();
}
@@ -152,10 +149,6 @@ export class MoreInfoDialog extends LitElement {
}
public closeDialog() {
this._open = false;
}
private _dialogClosed() {
this._entityId = undefined;
this._parentEntityIds = [];
this._entry = undefined;
@@ -371,21 +364,21 @@ export class MoreInfoDialog extends LitElement {
const isRTL = computeRTL(this.hass);
return html`
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
.width=${this.large ? "full" : "medium"}
@closed=${this._dialogClosed}
<ha-dialog
open
@closed=${this.closeDialog}
@opened=${this._handleOpened}
?prevent-scrim-close=${!this._isEscapeEnabled}
flexcontent
.escapeKeyAction=${this._isEscapeEnabled ? undefined : ""}
.heading=${title}
hideActions
flexContent
>
<ha-dialog-header slot="header">
<ha-dialog-header slot="heading">
${showCloseIcon
? html`
<ha-icon-button
slot="navigationIcon"
data-dialog="close"
dialogAction="cancel"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
@@ -567,7 +560,7 @@ export class MoreInfoDialog extends LitElement {
<div
class="content"
tabindex="-1"
autofocus
dialogInitialFocus
@show-child-view=${this._showChildView}
@entity-entry-updated=${this._entryUpdated}
@toggle-edit-mode=${this._handleToggleInfoEditModeEvent}
@@ -587,6 +580,7 @@ export class MoreInfoDialog extends LitElement {
: this._currView === "info"
? html`
<ha-more-info-info
dialogInitialFocus
.hass=${this.hass}
.entityId=${this._entityId}
.entry=${this._entry}
@@ -624,7 +618,7 @@ export class MoreInfoDialog extends LitElement {
</div>
`
)}
</ha-wa-dialog>
</ha-dialog>
`;
}
@@ -680,7 +674,13 @@ export class MoreInfoDialog extends LitElement {
return [
haStyleDialog,
css`
ha-wa-dialog {
ha-dialog {
/* Set the top top of the dialog to a fixed position, so it doesnt jump when the content changes size */
--vertical-align-dialog: flex-start;
--dialog-surface-margin-top: max(
40px,
var(--safe-area-inset-top, 0px)
);
--dialog-content-padding: 0;
}
@@ -698,17 +698,39 @@ export class MoreInfoDialog extends LitElement {
}
ha-more-info-history-and-logbook {
padding: var(--ha-space-2) var(--ha-space-6) var(--ha-space-6)
var(--ha-space-6);
padding: 8px 24px 24px 24px;
display: block;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
/* When in fullscreen dialog should be attached to top */
ha-dialog {
--dialog-surface-margin-top: 0px;
}
}
@media all and (min-width: 600px) and (min-height: 501px) {
ha-dialog {
--mdc-dialog-min-width: 580px;
--mdc-dialog-max-width: 580px;
--mdc-dialog-max-height: calc(100% - 72px);
}
.main-title {
cursor: default;
}
:host([large]) ha-dialog {
--mdc-dialog-min-width: 90vw;
--mdc-dialog-max-width: 90vw;
}
}
.title {
display: flex;
flex-direction: column;
align-items: flex-start;
margin: var(--ha-space-0) var(--ha-space-0)
calc(var(--ha-space-2) * -1) var(--ha-space-0);
margin: 0 0 -10px 0;
}
.title p {
@@ -730,9 +752,9 @@ export class MoreInfoDialog extends LitElement {
font-size: var(--ha-font-size-m);
line-height: 16px;
--mdc-icon-size: 16px;
padding: var(--ha-space-1);
margin: calc(var(--ha-space-1) * -1);
margin-top: calc(var(--ha-space-2) * -1);
padding: 4px;
margin: -4px;
margin-top: -10px;
background: none;
border: none;
outline: none;

View File

@@ -1011,8 +1011,8 @@ export class QuickBar extends LitElement {
--mdc-dialog-max-width: 800px;
--mdc-dialog-min-width: 500px;
--dialog-surface-position: fixed;
--dialog-surface-top: var(--ha-space-10);
--mdc-dialog-max-height: calc(100% - var(--ha-space-18));
--dialog-surface-top: 40px;
--mdc-dialog-max-height: calc(100% - 72px);
}
}
@@ -1055,8 +1055,8 @@ export class QuickBar extends LitElement {
}
span.command-text {
margin-left: var(--ha-space-2);
margin-inline-start: var(--ha-space-2);
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
direction: var(--direction);
}
@@ -1069,8 +1069,8 @@ export class QuickBar extends LitElement {
ha-md-list-item.two-line {
--md-list-item-one-line-container-height: 64px;
--md-list-item-two-line-container-height: 64px;
--md-list-item-top-space: var(--ha-space-2);
--md-list-item-bottom-space: var(--ha-space-2);
--md-list-item-top-space: 8px;
--md-list-item-bottom-space: 8px;
}
ha-md-list-item.three-line {
@@ -1078,8 +1078,8 @@ export class QuickBar extends LitElement {
--md-list-item-one-line-container-height: 72px;
--md-list-item-two-line-container-height: 72px;
--md-list-item-three-line-container-height: 72px;
--md-list-item-top-space: var(--ha-space-2);
--md-list-item-bottom-space: var(--ha-space-2);
--md-list-item-top-space: 8px;
--md-list-item-bottom-space: 8px;
}
ha-md-list-item .code {
@@ -1104,11 +1104,11 @@ export class QuickBar extends LitElement {
}
ha-tip {
padding: var(--ha-space-5);
padding: 20px;
}
.nothing-found {
padding: var(--ha-space-4) var(--ha-space-0);
padding: 16px 0px;
text-align: center;
}

View File

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

View File

@@ -61,6 +61,7 @@ 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,6 +3,7 @@ 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) => {
@@ -43,7 +44,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 ReactiveElement {
export class HassRouterPage extends ViewTransitionMixin(ReactiveElement) {
@property({ attribute: false }) public route?: Route;
protected routerOptions!: RouterOptions;
@@ -310,16 +311,18 @@ export class HassRouterPage extends ReactiveElement {
page: string,
routeOptions: RouteOptions
) {
if (this.lastChild) {
this.removeChild(this.lastChild);
}
this.startViewTransition(() => {
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,15 +1,17 @@
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 { haStyleScrollbar } from "../resources/styles";
import { ViewTransitionMixin } from "../mixins/view-transition-mixin";
import { haStyleScrollbar, haStyleViewTransitions } from "../resources/styles";
import type { HomeAssistant } from "../types";
@customElement("hass-subpage")
class HassSubpage extends LitElement {
class HassSubpage extends ViewTransitionMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public header?: string;
@@ -60,7 +62,14 @@ class HassSubpage extends LitElement {
<slot name="toolbar-icon"></slot>
</div>
</div>
<div class="content ha-scrollbar" @scroll=${this._saveScrollPos}>
<div
class=${classMap({
content: true,
"ha-scrollbar": true,
loading: !this._loaded,
})}
@scroll=${this._saveScrollPos}
>
<slot></slot>
</div>
<div id="fab">
@@ -85,6 +94,7 @@ class HassSubpage extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
haStyleViewTransitions,
css`
:host {
display: block;
@@ -167,6 +177,10 @@ class HassSubpage extends 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,7 +11,8 @@ import "../components/ha-icon-button-arrow-prev";
import "../components/ha-menu-button";
import "../components/ha-svg-icon";
import "../components/ha-tab";
import { haStyleScrollbar } from "../resources/styles";
import { ViewTransitionMixin } from "../mixins/view-transition-mixin";
import { haStyleScrollbar, haStyleViewTransitions } from "../resources/styles";
import type { HomeAssistant, Route } from "../types";
export interface PageNavigation {
@@ -29,7 +30,7 @@ export interface PageNavigation {
}
@customElement("hass-tabs-subpage")
class HassTabsSubpage extends LitElement {
class HassTabsSubpage extends ViewTransitionMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public supervisor = false;
@@ -185,7 +186,12 @@ class HassTabsSubpage extends LitElement {
</div>`
: nothing}
<div
class="content ha-scrollbar ${classMap({ tabs: showTabs })}"
class=${classMap({
content: true,
"ha-scrollbar": true,
tabs: showTabs,
loading: !this._loaded,
})}
@scroll=${this._saveScrollPos}
>
<slot></slot>
@@ -214,6 +220,7 @@ class HassTabsSubpage extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
haStyleViewTransitions,
css`
:host {
display: block;
@@ -332,6 +339,10 @@ class HassTabsSubpage extends 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

@@ -0,0 +1,201 @@
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,39 +1,40 @@
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 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 { createCloseHeading } from "../../../components/ha-dialog";
const cropOptions: CropOptions = {
round: false,
type: "image/jpeg",
quality: 0.75,
aspectRatio: 1.78,
};
const SENSOR_DOMAINS = ["sensor"];
@@ -138,7 +139,6 @@ 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,15 +265,19 @@ class DialogAreaDetail extends LitElement {
${this.hass.localize("ui.common.delete")}
</ha-button>`
: nothing}
<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>
<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-dialog>
`;
}

View File

@@ -1,10 +1,9 @@
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import deepClone from "deep-clone-simple";
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { LitElement, html, 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";
@@ -16,18 +15,19 @@ 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,6 +136,17 @@ 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>
@@ -211,6 +222,15 @@ 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,6 +214,17 @@ 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>
@@ -231,6 +242,15 @@ 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,8 +36,7 @@ export default class HaAutomationSidebar extends LitElement {
@property({ type: Boolean }) public narrow = false;
@property({ type: Number, attribute: "sidebar-key" })
public sidebarKey?: number;
@property({ attribute: "sidebar-key" }) public sidebarKey?: string;
@state() private _yamlMode = false;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -98,7 +98,7 @@ class DialogCategoryDetail extends LitElement {
</div>
<ha-button
appearance="plain"
slot="secondaryAction"
slot="primaryAction"
@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,8 +153,6 @@ 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,7 +3,6 @@ import {
mdiDevices,
mdiDotsVertical,
mdiHelpCircle,
mdiLabelOutline,
mdiPlus,
mdiRobot,
mdiShape,
@@ -24,10 +23,8 @@ import type {
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/ha-fab";
import "../../../components/ha-icon";
import "../../../components/ha-icon-button";
import type { HaMdMenu } from "../../../components/ha-md-menu";
import "../../../components/ha-svg-icon";
import "../../../components/ha-relative-time";
import type {
LabelRegistryEntry,
LabelRegistryEntryMutableParams,
@@ -46,6 +43,7 @@ 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 {
@@ -102,9 +100,7 @@ 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>`
: html`<ha-svg-icon .path=${mdiLabelOutline}></ha-svg-icon>`,
label.icon ? html`<ha-icon .icon=${label.icon}></ha-icon>` : nothing,
},
color: {
title: "",
@@ -112,18 +108,18 @@ export class HaConfigLabels extends LitElement {
label: localize("ui.panel.config.labels.headers.color"),
type: "icon",
template: (label) =>
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>`,
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,
},
name: {
title: localize("ui.panel.config.labels.headers.name"),

View File

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

View File

@@ -34,7 +34,6 @@ 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
@@ -57,8 +56,6 @@ export class HuiEnergyDevicesGraphCard
})
private _chartType: "bar" | "pie" = "bar";
@state() private _isMobile = false;
private _compoundStats: string[] = [];
protected hassSubscribeRequiredHostProps = ["_config"];
@@ -71,12 +68,6 @@ export class HuiEnergyDevicesGraphCard
this._data = data;
this._getStatistics(data);
}),
listenMediaQuery(
"all and (max-width: 450px), all and (max-height: 500px)",
(matches) => {
this._isMobile = matches;
}
),
];
}
@@ -163,6 +154,9 @@ 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",
@@ -181,7 +175,7 @@ export class HuiEnergyDevicesGraphCard
fontSize: 12,
margin: 5,
width: Math.min(
this._isMobile ? 100 : 200,
isMobile ? 100 : 200,
Math.max(
...(data[0]?.data?.map(
(d: any) =>
@@ -241,14 +235,8 @@ 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: var(--ha-space-12);
--mdc-icon-size: 48px;
color: var(--tile-color);
}
.picture .icon-container::before {
@@ -729,15 +729,13 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
}
hui-card-features {
--feature-color: var(--tile-color);
padding: 0 var(--ha-space-3) var(--ha-space-3) var(--ha-space-3);
padding: 0 12px 12px 12px;
}
.container.horizontal hui-card-features {
width: calc(
50% - var(--column-gap, var(--ha-space-0)) / 2 - var(--ha-space-3)
);
width: calc(50% - var(--column-gap, 0px) / 2 - 12px);
flex: none;
--feature-height: var(--ha-space-9);
padding: 0 var(--ha-space-3);
--feature-height: 36px;
padding: 0 12px;
padding-inline-start: 0;
}
.alert-badge {
@@ -750,18 +748,18 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
display: flex;
flex-direction: row;
gap: var(--ha-space-2);
padding: var(--ha-space-2);
padding: 8px;
pointer-events: none;
z-index: 1;
}
.alert {
background-color: var(--orange-color);
border-radius: var(--ha-border-radius-lg);
width: var(--ha-space-6);
height: var(--ha-space-6);
width: 24px;
height: 24px;
padding: 2px;
box-sizing: border-box;
--mdc-icon-size: var(--ha-space-4);
--mdc-icon-size: 16px;
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 var(--ha-space-3) var(--ha-space-3) var(--ha-space-3);
padding: 0 12px 12px 12px;
}
.container.horizontal hui-card-features {
width: calc(50% - var(--column-gap, 0px) / 2 - var(--ha-space-3));
width: calc(50% - var(--column-gap, 0px) / 2 - 12px);
flex: none;
--feature-height: var(--ha-space-9);
padding: 0 var(--ha-space-3);
--feature-height: 36px;
padding: 0 12px;
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: var(--ha-line-height-condensed);
line-height: 1;
}
.name-state {
@@ -729,7 +729,7 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
.short .state,
.short .temp-attribute .temp {
font-size: 24px;
line-height: var(--ha-line-height-condensed);
line-height: 1.25;
}
.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,6 +226,13 @@ 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: var(--ha-space-2);
margin-top: 8px;
}
.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: var(--ha-space-2);
padding-inline-end: var(--ha-space-2);
padding-right: 8px;
padding-inline-end: 8px;
padding-inline-start: initial;
direction: var(--direction);
}
@@ -514,7 +514,7 @@ export class HuiCardFeaturesEditor extends LitElement {
}
.feature-content {
height: var(--ha-space-15);
height: 60px;
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: var(--ha-space-9);
--mdc-icon-button-size: 36px;
color: var(--secondary-text-color);
}

View File

@@ -1,12 +1,20 @@
import "@material/mwc-menu/mwc-menu-surface";
import { mdiDelete, mdiDragHorizontalVariant, mdiPencil } from "@mdi/js";
import {
mdiDelete,
mdiDragHorizontalVariant,
mdiPencil,
mdiPlus,
} from "@mdi/js";
import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, query, state } 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";
@@ -28,6 +36,14 @@ 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) {
@@ -109,6 +125,32 @@ 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"
@@ -119,15 +161,39 @@ 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>
</div>
</mwc-menu-surface>
`;
}
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,7 +115,6 @@ export class HuiEntitiesCardRowEditor extends LitElement {
class="add-entity"
.hass=${this.hass}
@value-changed=${this._addEntity}
add-button
></ha-entity-picker>
`;
}

View File

@@ -72,7 +72,8 @@ 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 { haStyle } from "../../resources/styles";
import { ViewTransitionMixin } from "../../mixins/view-transition-mixin";
import { haStyle, haStyleViewTransitions } from "../../resources/styles";
import type { HomeAssistant, PanelInfo } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import { showToast } from "../../util/toast";
@@ -114,7 +115,7 @@ interface SubActionItem {
}
@customElement("hui-root")
class HUIRoot extends LitElement {
class HUIRoot extends ViewTransitionMixin(LitElement) {
@property({ attribute: false }) public panel?: PanelInfo<LovelacePanelConfig>;
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -496,6 +497,7 @@ class HUIRoot extends LitElement {
class=${classMap({
"edit-mode": this._editMode,
narrow: this.narrow,
loading: !this._loaded,
})}
>
<div class="header">
@@ -1165,43 +1167,45 @@ class HUIRoot extends LitElement {
// Recreate a new element to clear the applied themes.
const root = this._viewRoot;
if (root.lastChild) {
root.removeChild(root.lastChild);
}
this.startViewTransition(() => {
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) {
@@ -1212,12 +1216,21 @@ class HUIRoot extends 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);
@@ -1406,6 +1419,10 @@ class HUIRoot extends 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);
@@ -1414,6 +1431,7 @@ class HUIRoot extends 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,8 +32,7 @@ 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,3 +199,56 @@ 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,6 +42,17 @@ 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,7 +648,6 @@
"entity-picker": {
"choose_entity": "Choose entity",
"entity": "Entity",
"add": "Add entity",
"edit": "Edit",
"clear": "Clear",
"no_entities": "You don't have any entities",
@@ -677,9 +676,6 @@
},
"entity-state-picker": {
"state": "State"
},
"entity-state-content-picker": {
"add": "Add"
}
},
"target-picker": {
@@ -810,7 +806,6 @@
"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."
@@ -3920,6 +3915,7 @@
"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.",
@@ -3934,7 +3930,6 @@
"item_pasted": "{item} pasted",
"ctrl": "Ctrl",
"del": "Del",
"blocks": "Blocks",
"triggers": {
"name": "Triggers",
"header": "When",
@@ -3942,7 +3937,7 @@
"learn_more": "Learn more about triggers",
"triggered": "Triggered",
"add": "Add trigger",
"empty_search": "No triggers found for {term}",
"search": "Search trigger",
"id": "Trigger ID",
"id_helper": "Helps identify each run based on which trigger fired.",
"optional": "Optional",
@@ -3963,16 +3958,14 @@
"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"
"label": "Entity",
"description": "When something happens to an entity."
},
"time_location": {
"label": "Time and location"
"label": "Time and location",
"description": "When someone enters or leaves a zone, or at a specific time."
},
"other": {
"label": "Other triggers"
@@ -4205,7 +4198,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",
"empty_search": "No conditions and blocks found for {term}",
"search": "Search condition",
"add_building_block": "Add building block",
"test": "Test",
"testing_error": "Condition did not pass",
@@ -4227,22 +4220,21 @@
"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"
"label": "Entity",
"description": "If an entity is in a specific state."
},
"time_location": {
"label": "Time and location"
"label": "Time and location",
"description": "If someone is in a zone or if the current time is before or after a specified time."
},
"other": {
"label": "Other conditions"
},
"building_blocks": {
"label": "Building blocks"
"label": "Building blocks",
"description": "Build more complex conditions."
}
},
"type": {
@@ -4373,7 +4365,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",
"empty_search": "No actions and blocks found for {term}",
"search": "Search action",
"add_building_block": "Add building block",
"invalid_action": "Invalid action",
"run": "Run action",
@@ -4397,11 +4389,7 @@
"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"
},
@@ -4409,7 +4397,8 @@
"label": "Other actions"
},
"building_blocks": {
"label": "Building blocks"
"label": "Building blocks",
"description": "Build more complex sequences of actions."
}
},
"type": {

View File

@@ -3,8 +3,21 @@ import { render } from "lit";
export const removeLaunchScreen = () => {
const launchScreenElement = document.getElementById("ha-launch-screen");
if (launchScreenElement) {
launchScreenElement.parentElement!.removeChild(launchScreenElement);
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);
}
};

View File

@@ -26,7 +26,7 @@ describe("floorCompare", () => {
});
describe("floorCompare(entries)", () => {
it("sorts by level descending (highest to lowest), then by name", () => {
it("sorts by level, 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([
"floor2",
"floor1",
"floor3",
"floor1",
"floor2",
]);
});
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([
"floor2",
"floor1",
"floor2",
"floor3",
]);
});

1679
yarn.lock

File diff suppressed because it is too large Load Diff