mirror of
https://github.com/home-assistant/frontend.git
synced 2026-02-20 16:37:48 +00:00
Compare commits
160 Commits
numeric-th
...
rc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d98e373f64 | ||
|
|
649516c9fa | ||
|
|
bbc4fb96b2 | ||
|
|
0ae639aeb0 | ||
|
|
0e7e41065e | ||
|
|
685843f112 | ||
|
|
5e1a99d94a | ||
|
|
d843349865 | ||
|
|
ec23164aa9 | ||
|
|
e74ef11101 | ||
|
|
a222f6a736 | ||
|
|
ef3dd16d45 | ||
|
|
5d4e1d205e | ||
|
|
1ee5ebbe75 | ||
|
|
59d705aa3d | ||
|
|
332e108dae | ||
|
|
3c15b29d0a | ||
|
|
130c708e23 | ||
|
|
588a14a8a7 | ||
|
|
a1ef6ad266 | ||
|
|
a6c1f87730 | ||
|
|
49252a3808 | ||
|
|
c7877fe38f | ||
|
|
e355a61d8f | ||
|
|
f2e19e51ce | ||
|
|
fd9ab8f561 | ||
|
|
faa1b3c98f | ||
|
|
acc4a84fc9 | ||
|
|
4d723dac37 | ||
|
|
f1d4d0ef98 | ||
|
|
88180a2708 | ||
|
|
258d87e3d5 | ||
|
|
55f22ba61a | ||
|
|
812f3ca8b9 | ||
|
|
7f880d11a0 | ||
|
|
6b2452c538 | ||
|
|
c2cbf8bd21 | ||
|
|
224bcece9c | ||
|
|
dc84b7698f | ||
|
|
bc22e6a9bd | ||
|
|
d44874783a | ||
|
|
8d1bb5c867 | ||
|
|
da1b528eee | ||
|
|
756138408a | ||
|
|
3c8f112565 | ||
|
|
2521f3dde4 | ||
|
|
56390aa01a | ||
|
|
9aac5b19da | ||
|
|
24afc3dc88 | ||
|
|
873c7b2947 | ||
|
|
648db4276b | ||
|
|
f86c3e7856 | ||
|
|
1d0251cc28 | ||
|
|
518cf87847 | ||
|
|
81a9216c44 | ||
|
|
f0e10e0058 | ||
|
|
5df8ea4f07 | ||
|
|
73f081f5cc | ||
|
|
f0d1db1da6 | ||
|
|
c658eb414b | ||
|
|
bac493b72b | ||
|
|
922e8c7752 | ||
|
|
e63301cd9c | ||
|
|
29a3d67e48 | ||
|
|
4c98a7791b | ||
|
|
5a76c3f606 | ||
|
|
251a4ce5ce | ||
|
|
408735fa77 | ||
|
|
c0442b5b39 | ||
|
|
c6284987fd | ||
|
|
ed618124dc | ||
|
|
3e350b7642 | ||
|
|
c66b4e2027 | ||
|
|
4c25c639af | ||
|
|
0fbde5024e | ||
|
|
b991a8122b | ||
|
|
c2c4e06915 | ||
|
|
91c12605d3 | ||
|
|
cddf91cfd0 | ||
|
|
6e1999ceb7 | ||
|
|
3b571d42fa | ||
|
|
08ee742233 | ||
|
|
b3cc88e124 | ||
|
|
9fe9456f3c | ||
|
|
6d1d7690ef | ||
|
|
4a2b7324f7 | ||
|
|
15b85d6f19 | ||
|
|
c49115a91e | ||
|
|
efc67a30f3 | ||
|
|
bf41b3f7e3 | ||
|
|
30eb50a962 | ||
|
|
567e8c51d0 | ||
|
|
e214c79cd5 | ||
|
|
c0cae1cead | ||
|
|
22742eec84 | ||
|
|
37d8273e7c | ||
|
|
9ba34869be | ||
|
|
63284b328c | ||
|
|
9bb9ae6ad6 | ||
|
|
0377bf378d | ||
|
|
7e5ecf4007 | ||
|
|
e17055bef0 | ||
|
|
38f64b0e93 | ||
|
|
4ea207d74a | ||
|
|
04b0db35f6 | ||
|
|
0d22b88f27 | ||
|
|
ddf209bd8d | ||
|
|
ce5c1d2a9f | ||
|
|
956dbb5346 | ||
|
|
2b7cd8fe3a | ||
|
|
dbff31a281 | ||
|
|
6572200e8b | ||
|
|
1465515fb8 | ||
|
|
ddaba99f64 | ||
|
|
1c73bc6f6c | ||
|
|
44870cb3eb | ||
|
|
481a90352b | ||
|
|
9b536b2172 | ||
|
|
78d41dfd55 | ||
|
|
905435db3e | ||
|
|
ea73fd3f01 | ||
|
|
e519a0203e | ||
|
|
d98ee7e0b5 | ||
|
|
6fc8c17909 | ||
|
|
201169c3d8 | ||
|
|
303538ac21 | ||
|
|
ac88f3ed0b | ||
|
|
3c5a6193d0 | ||
|
|
5ee4bd63f8 | ||
|
|
b193929bd9 | ||
|
|
3bee5c8cd4 | ||
|
|
976c74b8da | ||
|
|
3a4a13db21 | ||
|
|
a2f033dd88 | ||
|
|
a44b94c8df | ||
|
|
8796830ff9 | ||
|
|
bdff13d5e1 | ||
|
|
4346484afc | ||
|
|
533694391e | ||
|
|
3adba7aa1f | ||
|
|
b60552c025 | ||
|
|
3011d56101 | ||
|
|
c903c0d734 | ||
|
|
14be390994 | ||
|
|
d48019a48e | ||
|
|
7b5cbb76ef | ||
|
|
c75fab025f | ||
|
|
c007206fa0 | ||
|
|
ab5b5a4276 | ||
|
|
9eb40f8470 | ||
|
|
bc827d9bf1 | ||
|
|
24f5d58691 | ||
|
|
13505a9104 | ||
|
|
c1d135aa16 | ||
|
|
16d13c3202 | ||
|
|
46b3c34ba1 | ||
|
|
57a81b9de4 | ||
|
|
69f4f1dbed | ||
|
|
355a1aff3f | ||
|
|
3a3036c635 |
2
.github/ISSUE_TEMPLATE.md
vendored
2
.github/ISSUE_TEMPLATE.md
vendored
@@ -67,7 +67,7 @@ DO NOT DELETE ANY TEXT from this template! Otherwise, your issue may be closed w
|
||||
<!--
|
||||
If your issue is about how an entity is shown in the UI, please add the state
|
||||
and attributes for all situations with a screenshot of the UI.
|
||||
You can find this information at `/developer-tools/state`
|
||||
You can find this information at `/config/developer-tools/state`
|
||||
-->
|
||||
|
||||
```yaml
|
||||
|
||||
4
.github/workflows/cast_deployment.yaml
vendored
4
.github/workflows/cast_deployment.yaml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: dev
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: master
|
||||
|
||||
|
||||
6
.github/workflows/ci.yaml
vendored
6
.github/workflows/ci.yaml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
|
||||
8
.github/workflows/codeql-analysis.yml
vendored
8
.github/workflows/codeql-analysis.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# We must fetch at least the immediate parents so that if this is
|
||||
# a pull request then we can checkout the head.
|
||||
@@ -36,14 +36,14 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
uses: github/codeql-action/init@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11
|
||||
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@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
uses: github/codeql-action/autobuild@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11
|
||||
|
||||
# ℹ️ 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@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
uses: github/codeql-action/analyze@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11
|
||||
|
||||
4
.github/workflows/demo_deployment.yaml
vendored
4
.github/workflows/demo_deployment.yaml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: dev
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: master
|
||||
|
||||
|
||||
2
.github/workflows/design_deployment.yaml
vendored
2
.github/workflows/design_deployment.yaml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
|
||||
2
.github/workflows/design_preview.yaml
vendored
2
.github/workflows/design_preview.yaml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
|
||||
4
.github/workflows/nightly.yaml
vendored
4
.github/workflows/nightly.yaml
vendored
@@ -20,10 +20,10 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
|
||||
2
.github/workflows/release-drafter.yaml
vendored
2
.github/workflows/release-drafter.yaml
vendored
@@ -18,6 +18,6 @@ jobs:
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter@b1476f6e6eb133afa41ed8589daba6dc69b4d3f5 # v6.1.0
|
||||
- uses: release-drafter/release-drafter@6db134d15f3909ccc9eefd369f02bd1e9cffdf97 # v6.2.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
6
.github/workflows/release.yaml
vendored
6
.github/workflows/release.yaml
vendored
@@ -26,10 +26,10 @@ jobs:
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
contents: write # Required to upload release assets
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
|
||||
2
.github/workflows/translations.yaml
vendored
2
.github/workflows/translations.yaml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Upload Translations
|
||||
run: |
|
||||
|
||||
@@ -9,11 +9,14 @@ import { selectedDemoConfig } from "./configs/demo-configs";
|
||||
import { mockAreaRegistry } from "./stubs/area_registry";
|
||||
import { mockAuth } from "./stubs/auth";
|
||||
import { mockConfigEntries } from "./stubs/config_entries";
|
||||
import { mockDeviceRegistry } from "./stubs/device_registry";
|
||||
import { mockEnergy } from "./stubs/energy";
|
||||
import { energyEntities } from "./stubs/entities";
|
||||
import { mockEntityRegistry } from "./stubs/entity_registry";
|
||||
import { mockEvents } from "./stubs/events";
|
||||
import { mockFloorRegistry } from "./stubs/floor_registry";
|
||||
import { mockFrontend } from "./stubs/frontend";
|
||||
import { mockLabelRegistry } from "./stubs/label_registry";
|
||||
import { mockIcons } from "./stubs/icons";
|
||||
import { mockHistory } from "./stubs/history";
|
||||
import { mockLovelace } from "./stubs/lovelace";
|
||||
@@ -60,6 +63,9 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
mockPersistentNotification(hass);
|
||||
mockConfigEntries(hass);
|
||||
mockAreaRegistry(hass);
|
||||
mockDeviceRegistry(hass);
|
||||
mockFloorRegistry(hass);
|
||||
mockLabelRegistry(hass);
|
||||
mockEntityRegistry(hass, [
|
||||
{
|
||||
config_entry_id: "co2signal",
|
||||
|
||||
@@ -27,4 +27,25 @@ export const mockFrontend = (hass: MockHomeAssistant) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return () => {};
|
||||
});
|
||||
hass.mockWS(
|
||||
"frontend/subscribe_system_data",
|
||||
(_msg, currentHass, onChange) => {
|
||||
onChange?.({
|
||||
value: currentHass.systemData,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return () => {};
|
||||
}
|
||||
);
|
||||
hass.mockWS("labs/subscribe", (_msg, _currentHass, onChange) => {
|
||||
onChange?.({
|
||||
preview_feature: _msg.preview_feature,
|
||||
domain: _msg.domain,
|
||||
enabled: false,
|
||||
is_built_in: true,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return () => {};
|
||||
});
|
||||
hass.mockWS("repairs/list_issues", () => ({ issues: [] }));
|
||||
};
|
||||
|
||||
@@ -7,8 +7,18 @@ export const mockTemplate = (hass: MockHomeAssistant) => {
|
||||
})
|
||||
);
|
||||
hass.mockWS("render_template", (msg, _hass, onChange) => {
|
||||
let result = msg.template;
|
||||
// Simple variable substitution for demo purposes
|
||||
if (msg.variables) {
|
||||
for (const [key, value] of Object.entries(msg.variables)) {
|
||||
result = result.replace(
|
||||
new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, "g"),
|
||||
String(value)
|
||||
);
|
||||
}
|
||||
}
|
||||
onChange!({
|
||||
result: msg.template,
|
||||
result,
|
||||
listeners: { all: false, domains: [], entities: [], time: false },
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
|
||||
@@ -169,7 +169,7 @@ const SCHEMAS: {
|
||||
{
|
||||
title: "Selectors",
|
||||
translations: {
|
||||
addon: "App",
|
||||
app: "App",
|
||||
entity: "Entity",
|
||||
device: "Device",
|
||||
area: "Area",
|
||||
@@ -188,7 +188,7 @@ const SCHEMAS: {
|
||||
entities: "Entities",
|
||||
},
|
||||
schema: [
|
||||
{ name: "addon", selector: { addon: {} } },
|
||||
{ name: "app", selector: { app: {} } },
|
||||
{ name: "entity", selector: { entity: {} } },
|
||||
{
|
||||
name: "Attribute",
|
||||
|
||||
@@ -239,7 +239,7 @@ const SCHEMAS: {
|
||||
selector: { config_entry: {} },
|
||||
},
|
||||
duration: { name: "Duration", selector: { duration: {} } },
|
||||
addon: { name: "App", selector: { addon: {} } },
|
||||
app: { name: "App", selector: { app: {} } },
|
||||
number_box: {
|
||||
name: "Number Box",
|
||||
selector: {
|
||||
|
||||
12
package.json
12
package.json
@@ -147,7 +147,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.6",
|
||||
"@babel/helper-define-polyfill-provider": "0.6.5",
|
||||
"@babel/helper-define-polyfill-provider": "0.6.6",
|
||||
"@babel/plugin-transform-runtime": "7.28.5",
|
||||
"@babel/preset-env": "7.28.6",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.21.8",
|
||||
@@ -157,7 +157,7 @@
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@rsdoctor/rspack-plugin": "1.5.0",
|
||||
"@rspack/core": "1.7.3",
|
||||
"@rspack/dev-server": "1.1.5",
|
||||
"@rspack/dev-server": "1.2.1",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.25",
|
||||
"@types/chromecast-caf-sender": "1.0.11",
|
||||
@@ -176,7 +176,7 @@
|
||||
"@types/tar": "6.1.13",
|
||||
"@types/ua-parser-js": "0.7.39",
|
||||
"@types/webspeechapi": "0.0.29",
|
||||
"@vitest/coverage-v8": "4.0.17",
|
||||
"@vitest/coverage-v8": "4.0.18",
|
||||
"babel-loader": "10.0.0",
|
||||
"babel-plugin-template-html-minifier": "4.1.0",
|
||||
"browserslist-useragent-regexp": "4.1.3",
|
||||
@@ -216,8 +216,8 @@
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.53.1",
|
||||
"vite-tsconfig-paths": "6.0.4",
|
||||
"vitest": "4.0.17",
|
||||
"vite-tsconfig-paths": "6.0.5",
|
||||
"vitest": "4.0.18",
|
||||
"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"
|
||||
@@ -229,7 +229,7 @@
|
||||
"clean-css": "5.3.3",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@fullcalendar/daygrid": "6.1.20",
|
||||
"globals": "17.0.0",
|
||||
"globals": "17.1.0",
|
||||
"tslib": "2.8.1",
|
||||
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
|
||||
"glob@^10.2.2": "^10.5.0"
|
||||
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 9.8 KiB |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20251229.0"
|
||||
version = "20260128.6"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -38,3 +38,34 @@ export function computeCssColor(color: string): string {
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a string is a valid color.
|
||||
* Accepts: hex colors (#xxx, #xxxxxx), theme colors, and valid CSS color names.
|
||||
*/
|
||||
export function isValidColorString(color: string | undefined): boolean {
|
||||
if (!color || typeof color !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it's a theme color
|
||||
if (THEME_COLORS.has(color)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it's a hex color
|
||||
if (/^#([0-9A-Fa-f]{3}){1,2}$/.test(color)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it's a valid CSS color name by trying to parse it
|
||||
// Use CSS.supports() for a more efficient test without DOM manipulation
|
||||
// This checks if the browser recognizes the color value
|
||||
try {
|
||||
const style = new Option().style;
|
||||
style.color = color;
|
||||
return style.color !== "";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
29
src/common/config/filter_navigation_pages.ts
Normal file
29
src/common/config/filter_navigation_pages.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { canShowPage } from "./can_show_page";
|
||||
|
||||
export interface NavigationFilterOptions {
|
||||
/** Whether there are Bluetooth config entries (pre-fetched by caller) */
|
||||
hasBluetoothConfigEntries?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters navigation pages based on visibility rules.
|
||||
* Handles special cases like Bluetooth (requires config entries)
|
||||
* and external app configuration.
|
||||
*/
|
||||
export const filterNavigationPages = (
|
||||
hass: HomeAssistant,
|
||||
pages: PageNavigation[],
|
||||
options: NavigationFilterOptions = {}
|
||||
): PageNavigation[] =>
|
||||
pages.filter((page) => {
|
||||
if (page.path === "#external-app-configuration") {
|
||||
return hass.auth.external?.config.hasSettingsScreen;
|
||||
}
|
||||
// Only show Bluetooth page if there are Bluetooth config entries
|
||||
if (page.component === "bluetooth") {
|
||||
return options.hasBluetoothConfigEntries ?? false;
|
||||
}
|
||||
return canShowPage(hass, page);
|
||||
});
|
||||
@@ -8,7 +8,9 @@ export const computeGroupEntitiesState = (states: HassEntity[]): string => {
|
||||
return UNAVAILABLE;
|
||||
}
|
||||
|
||||
const validState = states.filter((stateObj) => isUnavailableState(stateObj));
|
||||
const validState = states.some(
|
||||
(stateObj) => !isUnavailableState(stateObj.state)
|
||||
);
|
||||
|
||||
if (!validState) {
|
||||
return UNAVAILABLE;
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface ShortcutConfig {
|
||||
* Default is false to avoid interrupting copy/paste.
|
||||
*/
|
||||
allowWhenTextSelected?: boolean;
|
||||
allowInInput?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,7 +30,10 @@ function registerShortcuts(
|
||||
|
||||
Object.entries(shortcuts).forEach(([key, config]) => {
|
||||
wrappedShortcuts[key] = (event: KeyboardEvent) => {
|
||||
if (!canOverrideAlphanumericInput(event.composedPath())) {
|
||||
if (
|
||||
!config.allowInInput &&
|
||||
!canOverrideAlphanumericInput(event.composedPath())
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!config.allowWhenTextSelected && window.getSelection()?.toString()) {
|
||||
|
||||
@@ -34,10 +34,6 @@ export function setDirectionStyles(direction: string, element: LitElement) {
|
||||
"--float-end",
|
||||
direction === "ltr" ? "right" : "left"
|
||||
);
|
||||
element.style.setProperty(
|
||||
"--margin-title",
|
||||
direction === "ltr" ? "var(--margin-title-ltr)" : "var(--margin-title-rtl)"
|
||||
);
|
||||
element.style.setProperty(
|
||||
"--scale-direction",
|
||||
direction === "ltr" ? "1" : "-1"
|
||||
|
||||
@@ -51,6 +51,7 @@ export class HaCard extends LitElement {
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
}
|
||||
|
||||
/* clean-css ignore:start */
|
||||
:host
|
||||
::slotted(
|
||||
.card-content:not(:nth-child(1 of .card-content, .card-header))
|
||||
@@ -59,6 +60,7 @@ export class HaCard extends LitElement {
|
||||
padding-top: 0;
|
||||
margin-top: calc(var(--ha-space-2) * -1);
|
||||
}
|
||||
/* clean-css ignore:end */
|
||||
|
||||
:host ::slotted(.card-content) {
|
||||
padding: var(--ha-space-4);
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
mdiArrowCollapse,
|
||||
mdiArrowExpand,
|
||||
mdiContentCopy,
|
||||
mdiBug,
|
||||
mdiBugOutline,
|
||||
mdiFindReplace,
|
||||
mdiRedo,
|
||||
mdiUndo,
|
||||
} from "@mdi/js";
|
||||
@@ -36,6 +39,7 @@ import type { HaIconButtonToolbar } from "./ha-icon-button-toolbar";
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"editor-save": undefined;
|
||||
"test-toggle": undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +86,11 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
@property({ type: Boolean, attribute: "has-toolbar" })
|
||||
public hasToolbar = true;
|
||||
|
||||
@property({ type: Boolean, attribute: "has-test" })
|
||||
public hasTest = false;
|
||||
|
||||
@property({ attribute: false }) public testing = false;
|
||||
|
||||
@property({ type: String }) public placeholder?: string;
|
||||
|
||||
@state() private _value = "";
|
||||
@@ -213,7 +222,8 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
if (
|
||||
changedProps.has("_canCopy") ||
|
||||
changedProps.has("_canUndo") ||
|
||||
changedProps.has("_canRedo")
|
||||
changedProps.has("_canRedo") ||
|
||||
changedProps.has("testing")
|
||||
) {
|
||||
this._updateToolbarButtons();
|
||||
}
|
||||
@@ -361,6 +371,19 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
}
|
||||
|
||||
this._editorToolbar.items = [
|
||||
...(this.hasTest && !this._isFullscreen
|
||||
? [
|
||||
{
|
||||
id: "test",
|
||||
label:
|
||||
this.hass?.localize(
|
||||
`ui.components.yaml-editor.test_${this.testing ? "off" : "on"}`
|
||||
) || "Test",
|
||||
path: this.testing ? mdiBugOutline : mdiBug,
|
||||
action: (e: Event) => this._handleTestClick(e),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "undo",
|
||||
disabled: !this._canUndo,
|
||||
@@ -384,6 +407,14 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
path: mdiContentCopy,
|
||||
action: (e: Event) => this._handleClipboardClick(e),
|
||||
},
|
||||
{
|
||||
id: "find-replace",
|
||||
label:
|
||||
this.hass?.localize("ui.components.yaml-editor.find_and_replace") ||
|
||||
"Find and replace",
|
||||
path: mdiFindReplace,
|
||||
action: (e: Event) => this._handleFindReplaceClick(e),
|
||||
},
|
||||
{
|
||||
id: "fullscreen",
|
||||
disabled: this.disableFullscreen,
|
||||
@@ -418,6 +449,15 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
}
|
||||
};
|
||||
|
||||
private _handleTestClick = (e: Event) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!this.codemirror) {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "test-toggle");
|
||||
};
|
||||
|
||||
private _handleUndoClick = (e: Event) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -442,6 +482,21 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
this._updateFullscreenState(!this._isFullscreen);
|
||||
};
|
||||
|
||||
private _handleFindReplaceClick = (e: Event) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!this.codemirror || !this._loadedCodeMirror) {
|
||||
return;
|
||||
}
|
||||
// Toggle search panel: close if open, open if closed
|
||||
const searchPanel = this.codemirror.dom.querySelector(".cm-search");
|
||||
if (searchPanel) {
|
||||
this._loadedCodeMirror.closeSearchPanel(this.codemirror);
|
||||
} else {
|
||||
this._loadedCodeMirror.openSearchPanel(this.codemirror);
|
||||
}
|
||||
};
|
||||
|
||||
private _handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (
|
||||
(e.key === "Escape" &&
|
||||
|
||||
@@ -2,11 +2,17 @@ import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { stopPropagation } from "../common/dom/stop_propagation";
|
||||
import { caseInsensitiveStringCompare } from "../common/string/compare";
|
||||
import "./ha-list-item";
|
||||
import "./ha-select";
|
||||
import type { HaSelect } from "./ha-select";
|
||||
import type { FrontendLocaleData } from "../data/translation";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import "./ha-generic-picker";
|
||||
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
|
||||
|
||||
const SEARCH_KEYS = [
|
||||
{ name: "primary", weight: 10 },
|
||||
{ name: "secondary", weight: 8 },
|
||||
{ name: "search_labels.english", weight: 5 },
|
||||
];
|
||||
|
||||
export const COUNTRIES = [
|
||||
"AD",
|
||||
@@ -260,9 +266,45 @@ export const COUNTRIES = [
|
||||
"ZW",
|
||||
];
|
||||
|
||||
export const getCountryOptions = (
|
||||
countries: string[],
|
||||
noSort: boolean,
|
||||
locale?: FrontendLocaleData
|
||||
): PickerComboBoxItem[] => {
|
||||
const language = locale?.language ?? "en";
|
||||
const countryDisplayNames = new Intl.DisplayNames(language, {
|
||||
type: "region",
|
||||
fallback: "code",
|
||||
});
|
||||
const englishDisplayNames = new Intl.DisplayNames("en", {
|
||||
type: "region",
|
||||
fallback: "code",
|
||||
});
|
||||
|
||||
const options: PickerComboBoxItem[] = countries.map((country) => {
|
||||
const primary = countryDisplayNames.of(country) ?? country;
|
||||
const englishName = englishDisplayNames.of(country) ?? country;
|
||||
return {
|
||||
id: country,
|
||||
primary,
|
||||
secondary: country,
|
||||
search_labels: {
|
||||
english: englishName !== primary ? englishName : null,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (!noSort && locale) {
|
||||
options.sort((a, b) =>
|
||||
caseInsensitiveStringCompare(a.primary, b.primary, locale.language)
|
||||
);
|
||||
}
|
||||
return options;
|
||||
};
|
||||
|
||||
@customElement("ha-country-picker")
|
||||
export class HaCountryPicker extends LitElement {
|
||||
@property() public language = "en";
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property() public value?: string;
|
||||
|
||||
@@ -278,76 +320,72 @@ export class HaCountryPicker extends LitElement {
|
||||
|
||||
@property({ attribute: "no-sort", type: Boolean }) public noSort = false;
|
||||
|
||||
private _getOptions = memoizeOne(
|
||||
(language?: string, countries?: string[]) => {
|
||||
let options: { label: string; value: string }[] = [];
|
||||
const countryDisplayNames = new Intl.DisplayNames(language, {
|
||||
type: "region",
|
||||
fallback: "code",
|
||||
});
|
||||
if (countries) {
|
||||
options = countries.map((country) => ({
|
||||
value: country,
|
||||
label: countryDisplayNames
|
||||
? countryDisplayNames.of(country)!
|
||||
: country,
|
||||
}));
|
||||
} else {
|
||||
options = COUNTRIES.map((country) => ({
|
||||
value: country,
|
||||
label: countryDisplayNames
|
||||
? countryDisplayNames.of(country)!
|
||||
: country,
|
||||
}));
|
||||
}
|
||||
private _getCountryOptions = memoizeOne(getCountryOptions);
|
||||
|
||||
if (!this.noSort) {
|
||||
options.sort((a, b) =>
|
||||
caseInsensitiveStringCompare(a.label, b.label, language)
|
||||
);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
);
|
||||
private _getItems = () =>
|
||||
this._getCountryOptions(
|
||||
this.countries ?? COUNTRIES,
|
||||
this.noSort,
|
||||
this.hass?.locale
|
||||
);
|
||||
|
||||
private _getCountryName = (country?: string) =>
|
||||
this._getItems().find((c) => c.id === country)?.primary;
|
||||
|
||||
private _valueRenderer = (value: string) =>
|
||||
html`<span slot="headline">${this._getCountryName(value) ?? value}</span>`;
|
||||
|
||||
protected render() {
|
||||
const options = this._getOptions(this.language, this.countries);
|
||||
const label =
|
||||
this.label ??
|
||||
(this.hass?.localize("ui.components.country-picker.country") ||
|
||||
"Country");
|
||||
|
||||
const value =
|
||||
this.value ??
|
||||
(this.required && !this.disabled ? this._getItems()[0]?.id : this.value);
|
||||
|
||||
return html`
|
||||
<ha-select
|
||||
.label=${this.label}
|
||||
.value=${this.value}
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.notFoundLabel=${this._notFoundLabel}
|
||||
.emptyLabel=${this.hass?.localize(
|
||||
"ui.components.country-picker.no_countries"
|
||||
) || "No countries available"}
|
||||
.label=${label}
|
||||
.value=${value}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.helper=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
@selected=${this._changed}
|
||||
@closed=${stopPropagation}
|
||||
fixedMenuPosition
|
||||
naturalMenuWidth
|
||||
>
|
||||
${options.map(
|
||||
(option) => html`
|
||||
<ha-list-item .value=${option.value}>${option.label}</ha-list-item>
|
||||
`
|
||||
)}
|
||||
</ha-select>
|
||||
.getItems=${this._getItems}
|
||||
.searchKeys=${SEARCH_KEYS}
|
||||
@value-changed=${this._changed}
|
||||
hide-clear-icon
|
||||
></ha-generic-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-select {
|
||||
ha-generic-picker {
|
||||
width: 100%;
|
||||
min-width: 200px;
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
private _changed(ev): void {
|
||||
const target = ev.target as HaSelect;
|
||||
if (target.value === "" || target.value === this.value) {
|
||||
return;
|
||||
}
|
||||
this.value = target.value;
|
||||
private _changed(ev: ValueChangedEvent<string>): void {
|
||||
ev.stopPropagation();
|
||||
this.value = ev.detail.value;
|
||||
fireEvent(this, "value-changed", { value: this.value });
|
||||
}
|
||||
|
||||
private _notFoundLabel = (search: string) => {
|
||||
const term = html`<b>'${search}'</b>`;
|
||||
return this.hass
|
||||
? this.hass.localize("ui.components.country-picker.no_match", { term })
|
||||
: html`No countries found for ${term}`;
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -2,11 +2,16 @@ import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { stopPropagation } from "../common/dom/stop_propagation";
|
||||
import { caseInsensitiveStringCompare } from "../common/string/compare";
|
||||
import "./ha-list-item";
|
||||
import "./ha-select";
|
||||
import type { HaSelect } from "./ha-select";
|
||||
import type { FrontendLocaleData } from "../data/translation";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import "./ha-generic-picker";
|
||||
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
|
||||
|
||||
const SEARCH_KEYS = [
|
||||
{ name: "primary", weight: 10 },
|
||||
{ name: "secondary", weight: 8 },
|
||||
];
|
||||
|
||||
const CURRENCIES = [
|
||||
"AED",
|
||||
@@ -172,9 +177,31 @@ const curSymbol = (currency: string, locale?: string) =>
|
||||
new Intl.NumberFormat(locale, { style: "currency", currency })
|
||||
.formatToParts(1)
|
||||
.find((x) => x.type === "currency")?.value;
|
||||
|
||||
export const getCurrencyOptions = (
|
||||
locale?: FrontendLocaleData
|
||||
): PickerComboBoxItem[] => {
|
||||
const language = locale?.language ?? "en";
|
||||
const currencyDisplayNames = new Intl.DisplayNames(language, {
|
||||
type: "currency",
|
||||
fallback: "code",
|
||||
});
|
||||
|
||||
const options: PickerComboBoxItem[] = CURRENCIES.map((currency) => ({
|
||||
id: currency,
|
||||
primary: `${currencyDisplayNames.of(currency)} (${curSymbol(currency, language)})`,
|
||||
secondary: currency,
|
||||
}));
|
||||
|
||||
options.sort((a, b) =>
|
||||
caseInsensitiveStringCompare(a.primary, b.primary, language)
|
||||
);
|
||||
return options;
|
||||
};
|
||||
|
||||
@customElement("ha-currency-picker")
|
||||
export class HaCurrencyPicker extends LitElement {
|
||||
@property() public language = "en";
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property() public value?: string;
|
||||
|
||||
@@ -184,60 +211,62 @@ export class HaCurrencyPicker extends LitElement {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
|
||||
private _getOptions = memoizeOne((language?: string) => {
|
||||
const currencyDisplayNames = new Intl.DisplayNames(language, {
|
||||
type: "currency",
|
||||
fallback: "code",
|
||||
});
|
||||
const options = CURRENCIES.map((currency) => ({
|
||||
value: currency,
|
||||
label: `${
|
||||
currencyDisplayNames ? currencyDisplayNames.of(currency)! : currency
|
||||
} (${curSymbol(currency, language)})`,
|
||||
}));
|
||||
options.sort((a, b) =>
|
||||
caseInsensitiveStringCompare(a.label, b.label, language)
|
||||
);
|
||||
return options;
|
||||
});
|
||||
private _getCurrencyOptions = memoizeOne(getCurrencyOptions);
|
||||
|
||||
private _getItems = () => this._getCurrencyOptions(this.hass?.locale);
|
||||
|
||||
private _getCurrencyName = (currency?: string) =>
|
||||
this._getItems().find((c) => c.id === currency)?.primary;
|
||||
|
||||
private _valueRenderer = (value: string) =>
|
||||
html`<span slot="headline">${this._getCurrencyName(value) ?? value}</span>`;
|
||||
|
||||
protected render() {
|
||||
const options = this._getOptions(this.language);
|
||||
const label =
|
||||
this.label ??
|
||||
(this.hass?.localize("ui.components.currency-picker.currency") ||
|
||||
"Currency");
|
||||
|
||||
return html`
|
||||
<ha-select
|
||||
.label=${this.label}
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.notFoundLabel=${this._notFoundLabel}
|
||||
.emptyLabel=${this.hass?.localize(
|
||||
"ui.components.currency-picker.no_currencies"
|
||||
) || "No currencies available"}
|
||||
.label=${label}
|
||||
.value=${this.value}
|
||||
.required=${this.required}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
.disabled=${this.disabled}
|
||||
@selected=${this._changed}
|
||||
@closed=${stopPropagation}
|
||||
fixedMenuPosition
|
||||
naturalMenuWidth
|
||||
>
|
||||
${options.map(
|
||||
(option) => html`
|
||||
<ha-list-item .value=${option.value}>${option.label}</ha-list-item>
|
||||
`
|
||||
)}
|
||||
</ha-select>
|
||||
.required=${this.required}
|
||||
.getItems=${this._getItems}
|
||||
.searchKeys=${SEARCH_KEYS}
|
||||
@value-changed=${this._changed}
|
||||
hide-clear-icon
|
||||
></ha-generic-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-select {
|
||||
ha-generic-picker {
|
||||
width: 100%;
|
||||
min-width: 200px;
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
private _changed(ev): void {
|
||||
const target = ev.target as HaSelect;
|
||||
if (target.value === "" || target.value === this.value) {
|
||||
return;
|
||||
}
|
||||
this.value = target.value;
|
||||
private _changed(ev: ValueChangedEvent<string>): void {
|
||||
ev.stopPropagation();
|
||||
this.value = ev.detail.value;
|
||||
fireEvent(this, "value-changed", { value: this.value });
|
||||
}
|
||||
|
||||
private _notFoundLabel = (search: string) => {
|
||||
const term = html`<b>'${search}'</b>`;
|
||||
return this.hass
|
||||
? this.hass.localize("ui.components.currency-picker.no_match", { term })
|
||||
: html`No currencies found for ${term}`;
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -76,6 +76,18 @@ export class HaDialog extends DialogBase {
|
||||
var(--divider-color)
|
||||
);
|
||||
z-index: var(--dialog-z-index, 8);
|
||||
--mdc-dialog-box-shadow: var(--dialog-box-shadow, none);
|
||||
--mdc-typography-headline6-font-weight: var(--ha-font-weight-normal);
|
||||
--mdc-typography-headline6-font-size: 1.574rem;
|
||||
}
|
||||
.mdc-dialog::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
-webkit-backdrop-filter: var(
|
||||
--ha-dialog-scrim-backdrop-filter,
|
||||
var(--dialog-backdrop-filter, none)
|
||||
@@ -84,9 +96,9 @@ export class HaDialog extends DialogBase {
|
||||
--ha-dialog-scrim-backdrop-filter,
|
||||
var(--dialog-backdrop-filter, none)
|
||||
);
|
||||
--mdc-dialog-box-shadow: var(--dialog-box-shadow, none);
|
||||
--mdc-typography-headline6-font-weight: var(--ha-font-weight-normal);
|
||||
--mdc-typography-headline6-font-size: 1.574rem;
|
||||
}
|
||||
.mdc-dialog .mdc-dialog__scrim {
|
||||
background-color: var(--mdc-dialog-scrim-color, none);
|
||||
}
|
||||
.mdc-dialog__actions {
|
||||
justify-content: var(--justify-action-buttons, flex-end);
|
||||
|
||||
@@ -37,6 +37,7 @@ export class HaDropdownItem extends DropdownItem {
|
||||
|
||||
#check {
|
||||
visibility: visible;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#icon ::slotted(*) {
|
||||
|
||||
@@ -39,9 +39,7 @@ export class HaFilterDomains extends LitElement {
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.entities.picker.headers.domain"
|
||||
)}
|
||||
${this.hass.localize("ui.panel.config.domains.caption")}
|
||||
${this.value?.length
|
||||
? html`<div class="badge">${this.value?.length}</div>
|
||||
<ha-icon-button
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { mdiMenuDown, mdiMenuUp } from "@mdi/js";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "../ha-check-list-item";
|
||||
import "../ha-checkbox";
|
||||
import type { HaCheckbox } from "../ha-checkbox";
|
||||
import "../ha-dropdown";
|
||||
import "../ha-dropdown-item";
|
||||
import "../ha-formfield";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-md-button-menu";
|
||||
import "../ha-md-menu-item";
|
||||
import "../ha-textfield";
|
||||
import "../ha-picker-field";
|
||||
|
||||
import type { HaDropdown } from "../ha-dropdown";
|
||||
import type { HaDropdownItem } from "../ha-dropdown-item";
|
||||
import type {
|
||||
HaFormElement,
|
||||
HaFormMultiSelectData,
|
||||
@@ -36,16 +37,12 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement {
|
||||
|
||||
@property() public label!: string;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
|
||||
@state() private _opened = false;
|
||||
|
||||
@query("ha-md-button-menu") private _input?: HTMLElement;
|
||||
@query("ha-dropdown") private _dropdown!: HaDropdown;
|
||||
|
||||
public focus(): void {
|
||||
if (this._input) {
|
||||
this._input.focus();
|
||||
}
|
||||
this._dropdown?.focus();
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
@@ -74,13 +71,14 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement {
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-md-button-menu
|
||||
.disabled=${this.disabled}
|
||||
@opening=${this._handleOpen}
|
||||
@closing=${this._handleClose}
|
||||
positioning="fixed"
|
||||
<ha-dropdown
|
||||
@wa-select=${this._toggleItem}
|
||||
@wa-show=${this._showDropdown}
|
||||
placement="bottom"
|
||||
tabindex="0"
|
||||
@keydown=${this._handleKeydown}
|
||||
>
|
||||
<ha-textfield
|
||||
<ha-picker-field
|
||||
slot="trigger"
|
||||
.label=${this.label}
|
||||
.value=${data
|
||||
@@ -91,71 +89,42 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement {
|
||||
)
|
||||
.join(", ")}
|
||||
.disabled=${this.disabled}
|
||||
tabindex="-1"
|
||||
></ha-textfield>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.label=${this.label}
|
||||
.path=${this._opened ? mdiMenuUp : mdiMenuDown}
|
||||
></ha-icon-button>
|
||||
hide-clear-icon
|
||||
>
|
||||
</ha-picker-field>
|
||||
${options.map((item: string | [string, string]) => {
|
||||
const value = optionValue(item);
|
||||
const selected = data.includes(value);
|
||||
return html`<ha-md-menu-item
|
||||
type="option"
|
||||
aria-checked=${selected}
|
||||
return html`<ha-dropdown-item
|
||||
.value=${value}
|
||||
.action=${selected ? "remove" : "add"}
|
||||
.activated=${selected}
|
||||
@click=${this._toggleItem}
|
||||
@keydown=${this._keydown}
|
||||
keep-open
|
||||
type="checkbox"
|
||||
.checked=${selected}
|
||||
>
|
||||
<ha-checkbox
|
||||
slot="start"
|
||||
tabindex="-1"
|
||||
.checked=${selected}
|
||||
></ha-checkbox>
|
||||
${optionLabel(item)}
|
||||
</ha-md-menu-item>`;
|
||||
</ha-dropdown-item>`;
|
||||
})}
|
||||
</ha-md-button-menu>
|
||||
</ha-dropdown>
|
||||
`;
|
||||
}
|
||||
|
||||
protected _keydown(ev) {
|
||||
if (ev.code === "Space" || ev.code === "Enter") {
|
||||
ev.preventDefault();
|
||||
this._toggleItem(ev);
|
||||
}
|
||||
}
|
||||
protected _toggleItem(ev: CustomEvent<{ item: HaDropdownItem }>) {
|
||||
ev.preventDefault(); // keep the dropdown open
|
||||
const value = ev.detail.item.value;
|
||||
const action = (ev.detail.item as any).action;
|
||||
|
||||
protected _toggleItem(ev) {
|
||||
const oldData = this.data || [];
|
||||
let newData: string[];
|
||||
if (ev.currentTarget.action === "add") {
|
||||
newData = [...oldData, ev.currentTarget.value];
|
||||
if (action === "add") {
|
||||
newData = [...oldData, value];
|
||||
} else {
|
||||
newData = oldData.filter((d) => d !== ev.currentTarget.value);
|
||||
newData = oldData.filter((d) => d !== value);
|
||||
}
|
||||
fireEvent(this, "value-changed", {
|
||||
value: newData,
|
||||
});
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
this.updateComplete.then(() => {
|
||||
const { formElement, mdcRoot } =
|
||||
this.shadowRoot?.querySelector("ha-textfield") || ({} as any);
|
||||
if (formElement) {
|
||||
formElement.style.textOverflow = "ellipsis";
|
||||
}
|
||||
if (mdcRoot) {
|
||||
mdcRoot.style.cursor = "pointer";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
if (changedProps.has("schema")) {
|
||||
this.toggleAttribute(
|
||||
@@ -194,25 +163,28 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _handleOpen(ev: Event): void {
|
||||
ev.stopPropagation();
|
||||
this._opened = true;
|
||||
this.toggleAttribute("opened", true);
|
||||
private _showDropdown(ev) {
|
||||
if (this.disabled) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
this.style.setProperty(
|
||||
"--dropdown-width",
|
||||
`${this._dropdown.offsetWidth}px`
|
||||
);
|
||||
}
|
||||
|
||||
private _handleClose(ev: Event): void {
|
||||
ev.stopPropagation();
|
||||
this._opened = false;
|
||||
this.toggleAttribute("opened", false);
|
||||
private _handleKeydown(ev) {
|
||||
if ((ev.code === "Space" || ev.code === "Enter") && this._dropdown) {
|
||||
this._dropdown.open = true;
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host([own-margin]) {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
ha-md-button-menu {
|
||||
ha-dropdown {
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
}
|
||||
ha-formfield {
|
||||
display: block;
|
||||
@@ -239,9 +211,15 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement {
|
||||
:host([opened]) ha-icon-button {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
:host([opened]) ha-md-button-menu {
|
||||
--mdc-text-field-idle-line-color: var(--input-hover-line-color);
|
||||
--mdc-text-field-label-ink-color: var(--primary-color);
|
||||
|
||||
ha-dropdown::part(menu) {
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
width: var(--dropdown-width);
|
||||
}
|
||||
|
||||
:host([disabled]) ha-dropdown ha-picker-field {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mdiDotsVertical } from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { stopPropagation } from "../common/dom/stop_propagation";
|
||||
import { haStyle } from "../resources/styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-dropdown";
|
||||
@@ -39,7 +40,10 @@ export class HaIconOverflowMenu extends LitElement {
|
||||
return html`
|
||||
${this.narrow
|
||||
? html` <!-- Collapsed representation for small screens -->
|
||||
<ha-dropdown @wa-show=${this._handleIconOverflowMenuOpened}>
|
||||
<ha-dropdown
|
||||
@wa-show=${this._handleIconOverflowMenuOpened}
|
||||
@click=${stopPropagation}
|
||||
>
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize("ui.common.overflow_menu")}
|
||||
.path=${mdiDotsVertical}
|
||||
|
||||
@@ -156,6 +156,10 @@ export class HaIcon extends LitElement {
|
||||
);
|
||||
chunks[chunk] = iconPromise;
|
||||
this._setPath(iconPromise, iconName, requestedIcon);
|
||||
// Remove chunk from cache on failure so next attempt retries
|
||||
iconPromise.catch(() => {
|
||||
delete chunks[chunk];
|
||||
});
|
||||
debouncedWriteCache();
|
||||
}
|
||||
|
||||
@@ -177,11 +181,15 @@ export class HaIcon extends LitElement {
|
||||
iconName: string,
|
||||
requestedIcon: string
|
||||
) {
|
||||
const iconPack = await promise;
|
||||
if (this.icon === requestedIcon) {
|
||||
this._path = iconPack[iconName];
|
||||
try {
|
||||
const iconPack = await promise;
|
||||
if (this.icon === requestedIcon) {
|
||||
this._path = iconPack[iconName];
|
||||
}
|
||||
cachedIcons[iconName] = iconPack[iconName];
|
||||
} catch (_err) {
|
||||
// Chunk failed to load, already evicted from cache for retry
|
||||
}
|
||||
cachedIcons[iconName] = iconPack[iconName];
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { FOCUS_TARGET } from "../dialogs/make-dialog-manager";
|
||||
import type { HaButton } from "./ha-button";
|
||||
import type { HaIconButton } from "./ha-icon-button";
|
||||
import "./ha-md-menu";
|
||||
import type { HaMdMenu } from "./ha-md-menu";
|
||||
|
||||
@customElement("ha-md-button-menu")
|
||||
export class HaMdButtonMenu extends LitElement {
|
||||
protected readonly [FOCUS_TARGET];
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property() public positioning?: "fixed" | "absolute" | "popover";
|
||||
|
||||
@property({ attribute: "anchor-corner" }) public anchorCorner:
|
||||
| "start-start"
|
||||
| "start-end"
|
||||
| "end-start"
|
||||
| "end-end" = "end-start";
|
||||
|
||||
@property({ attribute: "menu-corner" }) public menuCorner:
|
||||
| "start-start"
|
||||
| "start-end"
|
||||
| "end-start"
|
||||
| "end-end" = "start-start";
|
||||
|
||||
@property({ type: Boolean, attribute: "has-overflow" }) public hasOverflow =
|
||||
false;
|
||||
|
||||
@property({ type: Boolean }) public quick = false;
|
||||
|
||||
@query("ha-md-menu", true) private _menu!: HaMdMenu;
|
||||
|
||||
public get items() {
|
||||
return this._menu.items;
|
||||
}
|
||||
|
||||
public override focus() {
|
||||
if (this._menu.open) {
|
||||
this._menu.focus();
|
||||
} else {
|
||||
this._triggerButton?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<div @click=${this._handleClick}>
|
||||
<slot name="trigger" @slotchange=${this._setTriggerAria}></slot>
|
||||
</div>
|
||||
<ha-md-menu
|
||||
.quick=${this.quick}
|
||||
.positioning=${this.positioning}
|
||||
.hasOverflow=${this.hasOverflow}
|
||||
.anchorCorner=${this.anchorCorner}
|
||||
.menuCorner=${this.menuCorner}
|
||||
@opening=${this._handleOpening}
|
||||
@closing=${this._handleClosing}
|
||||
>
|
||||
<slot></slot>
|
||||
</ha-md-menu>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleOpening(): void {
|
||||
fireEvent(this, "opening", undefined, { composed: false });
|
||||
}
|
||||
|
||||
private _handleClosing(): void {
|
||||
fireEvent(this, "closing", undefined, { composed: false });
|
||||
}
|
||||
|
||||
private _handleClick(): void {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
this._menu.anchorElement = this;
|
||||
if (this._menu.open) {
|
||||
this._menu.close();
|
||||
} else {
|
||||
this._menu.show();
|
||||
}
|
||||
}
|
||||
|
||||
private get _triggerButton() {
|
||||
return this.querySelector(
|
||||
'ha-icon-button[slot="trigger"], ha-button[slot="trigger"], ha-assist-chip[slot="trigger"]'
|
||||
) as HaIconButton | HaButton | null;
|
||||
}
|
||||
|
||||
private _setTriggerAria() {
|
||||
if (this._triggerButton) {
|
||||
this._triggerButton.ariaHasPopup = "menu";
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
::slotted([disabled]) {
|
||||
color: var(--disabled-text-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-md-button-menu": HaMdButtonMenu;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
opening: undefined;
|
||||
closing: undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,38 @@
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import Fuse from "fuse.js";
|
||||
import { mdiDevices, mdiTextureBox } from "@mdi/js";
|
||||
import { html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { caseInsensitiveStringCompare } from "../common/string/compare";
|
||||
import { titleCase } from "../common/string/title-case";
|
||||
import { getConfigEntries, type ConfigEntry } from "../data/config_entries";
|
||||
import { fetchConfig } from "../data/lovelace/config/types";
|
||||
import { getPanelIcon, getPanelTitle } from "../data/panel";
|
||||
import { findRelated, type RelatedResult } from "../data/search";
|
||||
import { PANEL_DASHBOARDS } from "../panels/config/lovelace/dashboards/ha-config-lovelace-dashboards";
|
||||
import { multiTermSortedSearch } from "../resources/fuseMultiTerm";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import type { ActionRelatedContext } from "../panels/lovelace/components/hui-action-editor";
|
||||
import "./ha-generic-picker";
|
||||
import "./ha-domain-icon";
|
||||
import "./ha-icon";
|
||||
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
|
||||
import {
|
||||
DEFAULT_SEARCH_KEYS,
|
||||
type PickerComboBoxItem,
|
||||
} from "./ha-picker-combo-box";
|
||||
|
||||
type NavigationGroup = "related" | "dashboards" | "views" | "other_routes";
|
||||
|
||||
const RELATED_SORT_PREFIX = {
|
||||
area: "0_area",
|
||||
device: "1_device",
|
||||
} as const;
|
||||
|
||||
interface NavigationItem extends PickerComboBoxItem {
|
||||
group: NavigationGroup;
|
||||
domain?: string;
|
||||
}
|
||||
|
||||
@customElement("ha-navigation-picker")
|
||||
export class HaNavigationPicker extends LitElement {
|
||||
@@ -25,13 +50,57 @@ export class HaNavigationPicker extends LitElement {
|
||||
|
||||
@state() private _loading = true;
|
||||
|
||||
@property({ attribute: false }) public context?: ActionRelatedContext;
|
||||
|
||||
protected firstUpdated() {
|
||||
this._loadNavigationItems();
|
||||
}
|
||||
|
||||
private _navigationItems: PickerComboBoxItem[] = [];
|
||||
private _navigationItems: NavigationItem[] = [];
|
||||
|
||||
private _configEntryLookup: Record<string, ConfigEntry> = {};
|
||||
|
||||
private _navigationGroups: Record<NavigationGroup, NavigationItem[]> = {
|
||||
related: [],
|
||||
dashboards: [],
|
||||
views: [],
|
||||
other_routes: [],
|
||||
};
|
||||
|
||||
private _getRelatedItems = memoizeOne(
|
||||
async (_cacheKey: string, context: ActionRelatedContext) =>
|
||||
this._fetchRelatedItems(context),
|
||||
(newArgs, lastArgs) => newArgs[0] === lastArgs[0]
|
||||
);
|
||||
|
||||
protected render() {
|
||||
const sections = [
|
||||
...(this._navigationGroups.related.length
|
||||
? [
|
||||
{
|
||||
id: "related",
|
||||
label: this.hass.localize(
|
||||
"ui.components.navigation-picker.related"
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "dashboards",
|
||||
label: this.hass.localize("ui.components.navigation-picker.dashboards"),
|
||||
},
|
||||
{
|
||||
id: "views",
|
||||
label: this.hass.localize("ui.components.navigation-picker.views"),
|
||||
},
|
||||
{
|
||||
id: "other_routes",
|
||||
label: this.hass.localize(
|
||||
"ui.components.navigation-picker.other_routes"
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
@@ -43,6 +112,8 @@ export class HaNavigationPicker extends LitElement {
|
||||
.required=${this.required}
|
||||
.getItems=${this._getItems}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
.rowRenderer=${this._rowRenderer}
|
||||
.sections=${sections}
|
||||
.customValueLabel=${this.hass.localize(
|
||||
"ui.components.navigation-picker.add_custom_path"
|
||||
)}
|
||||
@@ -55,9 +126,23 @@ export class HaNavigationPicker extends LitElement {
|
||||
private _valueRenderer = (itemId: string) => {
|
||||
const item = this._navigationItems.find((navItem) => navItem.id === itemId);
|
||||
return html`
|
||||
${item?.icon
|
||||
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
|
||||
: nothing}
|
||||
${item?.domain
|
||||
? html`
|
||||
<ha-domain-icon
|
||||
slot="start"
|
||||
.hass=${this.hass}
|
||||
.domain=${item.domain}
|
||||
brand-fallback
|
||||
></ha-domain-icon>
|
||||
`
|
||||
: item?.icon
|
||||
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
|
||||
: item?.icon_path
|
||||
? html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${item.icon_path}
|
||||
></ha-svg-icon>`
|
||||
: nothing}
|
||||
<span slot="headline">${item?.primary || itemId}</span>
|
||||
${item?.primary
|
||||
? html`<span slot="supporting-text">${itemId}</span>`
|
||||
@@ -65,9 +150,106 @@ export class HaNavigationPicker extends LitElement {
|
||||
`;
|
||||
};
|
||||
|
||||
private _getItems = () => this._navigationItems;
|
||||
private _rowRenderer = (item: NavigationItem) => html`
|
||||
<ha-combo-box-item type="button" compact>
|
||||
${item.domain
|
||||
? html`
|
||||
<ha-domain-icon
|
||||
slot="start"
|
||||
.hass=${this.hass}
|
||||
.domain=${item.domain}
|
||||
brand-fallback
|
||||
></ha-domain-icon>
|
||||
`
|
||||
: item.icon
|
||||
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
|
||||
: item.icon_path
|
||||
? html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${item.icon_path}
|
||||
></ha-svg-icon>`
|
||||
: nothing}
|
||||
<span slot="headline">${item.primary}</span>
|
||||
${item.secondary
|
||||
? html`<span slot="supporting-text">${item.secondary}</span>`
|
||||
: nothing}
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
|
||||
private _fuseIndexes = {
|
||||
related: memoizeOne((items: NavigationItem[]) =>
|
||||
Fuse.createIndex(DEFAULT_SEARCH_KEYS, items)
|
||||
),
|
||||
dashboards: memoizeOne((items: NavigationItem[]) =>
|
||||
Fuse.createIndex(DEFAULT_SEARCH_KEYS, items)
|
||||
),
|
||||
views: memoizeOne((items: NavigationItem[]) =>
|
||||
Fuse.createIndex(DEFAULT_SEARCH_KEYS, items)
|
||||
),
|
||||
other_routes: memoizeOne((items: NavigationItem[]) =>
|
||||
Fuse.createIndex(DEFAULT_SEARCH_KEYS, items)
|
||||
),
|
||||
};
|
||||
|
||||
private _getItems = (searchString?: string, section?: string) => {
|
||||
const getGroupItems = (group: NavigationGroup) => {
|
||||
let items = [...this._navigationGroups[group]].sort(
|
||||
this._sortBySortingLabel
|
||||
);
|
||||
|
||||
if (searchString) {
|
||||
const fuseIndex = this._fuseIndexes[group](items);
|
||||
items = multiTermSortedSearch(
|
||||
items,
|
||||
searchString,
|
||||
DEFAULT_SEARCH_KEYS,
|
||||
(item) => item.id,
|
||||
fuseIndex
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const items: (NavigationItem | string)[] = [];
|
||||
|
||||
const related = getGroupItems("related");
|
||||
const dashboards = getGroupItems("dashboards");
|
||||
const views = getGroupItems("views");
|
||||
const otherRoutes = getGroupItems("other_routes");
|
||||
|
||||
const addGroup = (group: NavigationGroup, groupItems: NavigationItem[]) => {
|
||||
if (section && section !== group) {
|
||||
return;
|
||||
}
|
||||
if (!section && groupItems.length) {
|
||||
items.push(
|
||||
this.hass.localize(`ui.components.navigation-picker.${group}`)
|
||||
);
|
||||
}
|
||||
items.push(...groupItems);
|
||||
};
|
||||
|
||||
addGroup("related", related);
|
||||
addGroup("dashboards", dashboards);
|
||||
addGroup("views", views);
|
||||
addGroup("other_routes", otherRoutes);
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
private _sortBySortingLabel = (
|
||||
itemA: PickerComboBoxItem,
|
||||
itemB: PickerComboBoxItem
|
||||
) =>
|
||||
caseInsensitiveStringCompare(
|
||||
itemA.sorting_label!,
|
||||
itemB.sorting_label!,
|
||||
this.hass.locale.language
|
||||
);
|
||||
|
||||
private async _loadNavigationItems() {
|
||||
await this._loadConfigEntries();
|
||||
const panels = Object.entries(this.hass!.panels).map(([id, panel]) => ({
|
||||
id,
|
||||
...panel,
|
||||
@@ -78,12 +260,7 @@ export class HaNavigationPicker extends LitElement {
|
||||
|
||||
const viewConfigs = await Promise.all(
|
||||
lovelacePanels.map((panel) =>
|
||||
fetchConfig(
|
||||
this.hass!.connection,
|
||||
// path should be null to fetch default lovelace panel
|
||||
panel.url_path === "lovelace" ? null : panel.url_path,
|
||||
true
|
||||
)
|
||||
fetchConfig(this.hass!.connection, panel.url_path, true)
|
||||
.then((config) => [panel.id, config] as [string, typeof config])
|
||||
.catch((_) => [panel.id, undefined] as [string, undefined])
|
||||
)
|
||||
@@ -91,13 +268,19 @@ export class HaNavigationPicker extends LitElement {
|
||||
|
||||
const panelViewConfig = new Map(viewConfigs);
|
||||
|
||||
this._navigationItems = [];
|
||||
const related = this._navigationGroups.related;
|
||||
const dashboards: NavigationItem[] = [];
|
||||
const views: NavigationItem[] = [];
|
||||
const otherRoutes: NavigationItem[] = [];
|
||||
|
||||
for (const panel of panels) {
|
||||
const path = `/${panel.url_path}`;
|
||||
const panelTitle = getPanelTitle(this.hass, panel);
|
||||
const primary = panelTitle || path;
|
||||
this._navigationItems.push({
|
||||
const isDashboardPanel =
|
||||
panel.component_name === "lovelace" ||
|
||||
PANEL_DASHBOARDS.includes(panel.id);
|
||||
const panelItem: NavigationItem = {
|
||||
id: path,
|
||||
primary,
|
||||
secondary: panelTitle ? path : undefined,
|
||||
@@ -108,7 +291,14 @@ export class HaNavigationPicker extends LitElement {
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("_"),
|
||||
});
|
||||
group: isDashboardPanel ? "dashboards" : "other_routes",
|
||||
};
|
||||
|
||||
if (isDashboardPanel) {
|
||||
dashboards.push(panelItem);
|
||||
} else {
|
||||
otherRoutes.push(panelItem);
|
||||
}
|
||||
|
||||
const config = panelViewConfig.get(panel.id);
|
||||
|
||||
@@ -118,7 +308,7 @@ export class HaNavigationPicker extends LitElement {
|
||||
const viewPath = `/${panel.url_path}/${view.path ?? index}`;
|
||||
const viewPrimary =
|
||||
view.title ?? (view.path ? titleCase(view.path) : `${index}`);
|
||||
this._navigationItems.push({
|
||||
views.push({
|
||||
id: viewPath,
|
||||
secondary: viewPath,
|
||||
icon: view.icon ?? "mdi:view-compact",
|
||||
@@ -127,13 +317,156 @@ export class HaNavigationPicker extends LitElement {
|
||||
viewPrimary.startsWith("/") ? `zzz${viewPrimary}` : viewPrimary,
|
||||
viewPath,
|
||||
].join("_"),
|
||||
group: "views",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this._navigationGroups = {
|
||||
related,
|
||||
dashboards,
|
||||
views,
|
||||
other_routes: otherRoutes,
|
||||
};
|
||||
|
||||
this._navigationItems = [
|
||||
...related,
|
||||
...dashboards,
|
||||
...views,
|
||||
...otherRoutes,
|
||||
];
|
||||
|
||||
this._loading = false;
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
if (changedProps.has("context")) {
|
||||
this._loadRelatedItems();
|
||||
}
|
||||
}
|
||||
|
||||
private async _loadRelatedItems() {
|
||||
const updateRelatedItems = (relatedItems: NavigationItem[]) => {
|
||||
this._navigationGroups = {
|
||||
...this._navigationGroups,
|
||||
related: relatedItems,
|
||||
};
|
||||
this._navigationItems = [
|
||||
...relatedItems,
|
||||
...this._navigationGroups.dashboards,
|
||||
...this._navigationGroups.views,
|
||||
...this._navigationGroups.other_routes,
|
||||
];
|
||||
};
|
||||
|
||||
if (!this.hass || (!this.context?.entity_id && !this.context?.area_id)) {
|
||||
updateRelatedItems([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const context = this.context;
|
||||
const contextMatches = () =>
|
||||
this.context?.entity_id === context?.entity_id &&
|
||||
this.context?.area_id === context?.area_id;
|
||||
|
||||
const items = await this._getRelatedItems(
|
||||
`${context.entity_id ?? ""}|${context.area_id ?? ""}`,
|
||||
context
|
||||
);
|
||||
if (contextMatches()) {
|
||||
updateRelatedItems(items);
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetchRelatedItems(
|
||||
context: ActionRelatedContext
|
||||
): Promise<NavigationItem[]> {
|
||||
let relatedResult: RelatedResult | undefined;
|
||||
try {
|
||||
relatedResult = context.entity_id
|
||||
? await findRelated(this.hass, "entity", context.entity_id)
|
||||
: await findRelated(this.hass, "area", context.area_id!);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error fetching related items for navigation picker", err);
|
||||
return [];
|
||||
}
|
||||
|
||||
const relatedDeviceIds = new Set(relatedResult?.device ?? []);
|
||||
const relatedAreaIds = new Set(relatedResult?.area ?? []);
|
||||
if (context.area_id) {
|
||||
relatedAreaIds.add(context.area_id);
|
||||
}
|
||||
|
||||
const createSortingLabel = (
|
||||
prefix: string,
|
||||
primary: string,
|
||||
path: string
|
||||
) =>
|
||||
[prefix, primary.startsWith("/") ? `zzz${primary}` : primary, path]
|
||||
.filter(Boolean)
|
||||
.join("_");
|
||||
|
||||
const relatedItems: NavigationItem[] = [];
|
||||
for (const deviceId of relatedDeviceIds) {
|
||||
const device = this.hass.devices[deviceId];
|
||||
const primary = device?.name_by_user ?? device?.name ?? deviceId;
|
||||
const path = `/config/devices/device/${deviceId}`;
|
||||
relatedItems.push({
|
||||
id: path,
|
||||
primary,
|
||||
secondary: path,
|
||||
icon_path: mdiDevices,
|
||||
sorting_label: createSortingLabel(
|
||||
RELATED_SORT_PREFIX.device,
|
||||
primary,
|
||||
path
|
||||
),
|
||||
group: "related",
|
||||
domain: device?.primary_config_entry
|
||||
? this._configEntryLookup[device.primary_config_entry]?.domain
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
for (const areaId of relatedAreaIds) {
|
||||
const area = this.hass.areas[areaId];
|
||||
const primary = area?.name ?? areaId;
|
||||
const path = `/config/areas/area/${areaId}`;
|
||||
relatedItems.push({
|
||||
id: path,
|
||||
primary,
|
||||
secondary: path,
|
||||
icon: area?.icon ?? undefined,
|
||||
icon_path: area?.icon ? undefined : mdiTextureBox,
|
||||
sorting_label: createSortingLabel(
|
||||
RELATED_SORT_PREFIX.area,
|
||||
primary,
|
||||
path
|
||||
),
|
||||
group: "related",
|
||||
});
|
||||
}
|
||||
|
||||
return relatedItems;
|
||||
}
|
||||
|
||||
private async _loadConfigEntries() {
|
||||
if (Object.keys(this._configEntryLookup).length) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const configEntries = await getConfigEntries(this.hass);
|
||||
this._configEntryLookup = Object.fromEntries(
|
||||
configEntries.map((entry) => [entry.entry_id, entry])
|
||||
);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error fetching config entries for navigation picker", err);
|
||||
}
|
||||
}
|
||||
|
||||
private _valueChanged(ev: ValueChangedEvent<string>) {
|
||||
ev.stopPropagation();
|
||||
this._setValue(ev.detail.value);
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import { loadVirtualizer } from "../resources/virtualizer";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { isTouch } from "../util/is_touch";
|
||||
import "./chips/ha-chip-set";
|
||||
import "./chips/ha-filter-chip";
|
||||
import "./ha-combo-box-item";
|
||||
@@ -56,6 +57,11 @@ export interface PickerComboBoxItem {
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface PickerComboBoxIndexSelectedDetail {
|
||||
index: number;
|
||||
newTab?: boolean;
|
||||
}
|
||||
|
||||
export const NO_ITEMS_AVAILABLE_ID = "___no_items_available___";
|
||||
const PADDING_ID = "___padding___";
|
||||
|
||||
@@ -157,6 +163,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
@state() private _items: PickerComboBoxItem[] = [];
|
||||
|
||||
@state() private _selectedSection?: string;
|
||||
|
||||
public setFieldValue(value: string) {
|
||||
if (this._searchFieldElement) {
|
||||
this._searchFieldElement.value = value;
|
||||
@@ -191,6 +199,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
public willUpdate() {
|
||||
if (!this.hasUpdated) {
|
||||
loadVirtualizer();
|
||||
this._selectedSection = this.selectedSection;
|
||||
this._allItems = this._getItems();
|
||||
this._items = this._allItems;
|
||||
}
|
||||
@@ -227,7 +236,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
? html`
|
||||
<div class="section-title-wrapper">
|
||||
<div
|
||||
class="section-title ${!this.selectedSection &&
|
||||
class="section-title ${!this._selectedSection &&
|
||||
this._sectionTitle
|
||||
? "show"
|
||||
: ""}"
|
||||
@@ -276,9 +285,10 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
section === "separator"
|
||||
? html`<div class="separator"></div>`
|
||||
: html`<ha-filter-chip
|
||||
@mousedown=${isTouch ? undefined : this._preventBlur}
|
||||
@click=${this._toggleSection}
|
||||
.section-id=${section.id}
|
||||
.selected=${this.selectedSection === section.id}
|
||||
.selected=${this._selectedSection === section.id}
|
||||
.label=${section.label}
|
||||
>
|
||||
</ha-filter-chip>`
|
||||
@@ -315,7 +325,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
this.getAdditionalItems?.(searchString) || [];
|
||||
|
||||
private _getItems = () => {
|
||||
let items = [...(this.getItems(this._search, this.selectedSection) || [])];
|
||||
let items = [...(this.getItems(this._search, this._selectedSection) || [])];
|
||||
|
||||
if (!this.sections?.length) {
|
||||
items = items.sort((entityA, entityB) => {
|
||||
@@ -414,18 +424,19 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
return this.value || "";
|
||||
}
|
||||
|
||||
private _valueSelected = (ev: Event) => {
|
||||
private _valueSelected = (ev: MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
const value = (ev.currentTarget as any).value as string;
|
||||
const index = Number((ev.currentTarget as any).index);
|
||||
const newValue = value?.trim();
|
||||
const newTab = ev.ctrlKey || ev.metaKey;
|
||||
|
||||
this._fireSelectedEvents(newValue, index);
|
||||
this._fireSelectedEvents(newValue, index, newTab);
|
||||
};
|
||||
|
||||
private _fireSelectedEvents(value: string, index: number) {
|
||||
private _fireSelectedEvents(value: string, index: number, newTab = false) {
|
||||
fireEvent(this, "value-changed", { value });
|
||||
fireEvent(this, "index-selected", { index });
|
||||
fireEvent(this, "index-selected", { index, newTab });
|
||||
}
|
||||
|
||||
private _clearSearch = () => {
|
||||
@@ -497,6 +508,10 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
this._valuePinned = true;
|
||||
};
|
||||
|
||||
private _preventBlur(ev: Event) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
private _toggleSection(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
this._resetSelectedItem();
|
||||
@@ -505,18 +520,16 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
if (this.selectedSection === section) {
|
||||
this.selectedSection = undefined;
|
||||
if (this._selectedSection === section) {
|
||||
this._selectedSection = undefined;
|
||||
} else {
|
||||
this.selectedSection = section;
|
||||
this._selectedSection = section;
|
||||
}
|
||||
|
||||
this._items = this._getItems();
|
||||
|
||||
// Reset scroll position when filter changes
|
||||
if (this.virtualizerElement) {
|
||||
this.virtualizerElement.scrollToIndex(0);
|
||||
}
|
||||
this.virtualizerElement?.element(0)?.scrollIntoView();
|
||||
}
|
||||
|
||||
private _registerKeyboardShortcuts() {
|
||||
@@ -526,15 +539,42 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
Home: this._selectFirstItem,
|
||||
End: this._selectLastItem,
|
||||
Enter: this._pickSelectedItem,
|
||||
"$mod+Enter": this._pickSelectedItemNewTab,
|
||||
});
|
||||
}
|
||||
|
||||
private _focusList() {
|
||||
if (this._selectedItemIndex === -1) {
|
||||
this._selectNextItem();
|
||||
this._initializeSelectedIndex();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize keyboard selection to the currently selected value,
|
||||
* or fall back to the first item when searching (skipping section titles).
|
||||
*/
|
||||
private _initializeSelectedIndex(): void {
|
||||
if (!this.virtualizerElement?.items?.length) {
|
||||
return;
|
||||
}
|
||||
const initialIndex = this._getInitialSelectedIndex();
|
||||
// Only initialize to first item if searching, otherwise require a selected value
|
||||
if (initialIndex === 0 && !this._search) {
|
||||
return;
|
||||
}
|
||||
let index = initialIndex;
|
||||
// Skip section titles (strings)
|
||||
if (typeof this.virtualizerElement.items[index] === "string") {
|
||||
index += 1;
|
||||
}
|
||||
// Bounds check: ensure index is valid after skipping section title
|
||||
if (index >= this.virtualizerElement.items.length) {
|
||||
return;
|
||||
}
|
||||
this._selectedItemIndex = index;
|
||||
this._scrollToSelectedItem();
|
||||
}
|
||||
|
||||
private _selectNextItem = (ev?: KeyboardEvent) => {
|
||||
ev?.stopPropagation();
|
||||
ev?.preventDefault();
|
||||
@@ -553,6 +593,14 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If no item is selected yet, start from the currently selected value
|
||||
if (this._selectedItemIndex === -1) {
|
||||
this._initializeSelectedIndex();
|
||||
if (this._selectedItemIndex !== -1) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const nextIndex =
|
||||
maxItems === this._selectedItemIndex
|
||||
? this._selectedItemIndex
|
||||
@@ -644,7 +692,9 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
?.querySelector(".selected")
|
||||
?.classList.remove("selected");
|
||||
|
||||
this.virtualizerElement?.scrollToIndex(this._selectedItemIndex, "end");
|
||||
this.virtualizerElement
|
||||
?.element(this._selectedItemIndex)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.virtualizerElement
|
||||
@@ -654,6 +704,14 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
};
|
||||
|
||||
private _pickSelectedItem = (ev: KeyboardEvent) => {
|
||||
this._pickItem(ev, false);
|
||||
};
|
||||
|
||||
private _pickSelectedItemNewTab = (ev: KeyboardEvent) => {
|
||||
this._pickItem(ev, true);
|
||||
};
|
||||
|
||||
private _pickItem = (ev: KeyboardEvent, newTab: boolean) => {
|
||||
ev.stopPropagation();
|
||||
if (
|
||||
this.virtualizerElement?.items?.length !== undefined &&
|
||||
@@ -665,14 +723,17 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
this.virtualizerElement?.items as (PickerComboBoxItem | string)[]
|
||||
).forEach((item, index) => {
|
||||
if (typeof item !== "string") {
|
||||
this._fireSelectedEvents(item.id, index);
|
||||
this._fireSelectedEvents(item.id, index, newTab);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._selectedItemIndex === -1) {
|
||||
return;
|
||||
this._initializeSelectedIndex();
|
||||
if (this._selectedItemIndex === -1) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// if filter button is focused
|
||||
@@ -682,7 +743,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
this._selectedItemIndex
|
||||
] as PickerComboBoxItem;
|
||||
if (item) {
|
||||
this._fireSelectedEvents(item.id, this._selectedItemIndex);
|
||||
this._fireSelectedEvents(item.id, this._selectedItemIndex, newTab);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -888,6 +949,6 @@ declare global {
|
||||
}
|
||||
|
||||
interface HASSDomEvents {
|
||||
"index-selected": { index: number };
|
||||
"index-selected": PickerComboBoxIndexSelectedDetail;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,8 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { AddonSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-addon-picker";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { HaAppSelector } from "./ha-selector-app";
|
||||
|
||||
@customElement("ha-selector-addon")
|
||||
export class HaAddonSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: AddonSelector;
|
||||
|
||||
@property() public value?: any;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
protected render() {
|
||||
return html`<ha-addon-picker
|
||||
.hass=${this.hass}
|
||||
.value=${this.value}
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
></ha-addon-picker>`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-addon-picker {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
export class HaAddonSelector extends HaAppSelector {}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
|
||||
45
src/components/ha-selector/ha-selector-app.ts
Normal file
45
src/components/ha-selector/ha-selector-app.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { AppSelector, AddonSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-addon-picker";
|
||||
|
||||
@customElement("ha-selector-app")
|
||||
export class HaAppSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: AppSelector | AddonSelector;
|
||||
|
||||
@property() public value?: any;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
protected render() {
|
||||
return html`<ha-addon-picker
|
||||
.hass=${this.hass}
|
||||
.value=${this.value}
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
></ha-addon-picker>`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-addon-picker {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-selector-app": HaAppSelector;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { NavigationSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { ActionRelatedContext } from "../../panels/lovelace/components/hui-action-editor";
|
||||
import "../ha-navigation-picker";
|
||||
|
||||
@customElement("ha-selector-navigation")
|
||||
@@ -21,6 +22,8 @@ export class HaNavigationSelector extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
@property({ attribute: false }) public context?: ActionRelatedContext;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-navigation-picker
|
||||
@@ -30,6 +33,7 @@ export class HaNavigationSelector extends LitElement {
|
||||
.required=${this.required}
|
||||
.disabled=${this.disabled}
|
||||
.helper=${this.helper}
|
||||
.context=${this.selector.navigation ?? this.context}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-navigation-picker>
|
||||
`;
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, nothing, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { documentationUrl } from "../../util/documentation-url";
|
||||
import "../ha-code-editor";
|
||||
import "../ha-input-helper-text";
|
||||
import "../ha-alert";
|
||||
import type { RenderTemplateResult } from "../../data/ws-templates";
|
||||
import { subscribeRenderTemplate } from "../../data/ws-templates";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import type { TemplateSelector } from "../../data/selector";
|
||||
|
||||
const WARNING_STRINGS = ["template:", "sensor:", "state:", "trigger: template"];
|
||||
|
||||
@@ -13,6 +19,8 @@ const WARNING_STRINGS = ["template:", "sensor:", "state:", "trigger: template"];
|
||||
export class HaTemplateSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: TemplateSelector;
|
||||
|
||||
@property() public value?: string;
|
||||
|
||||
@property() public label?: string;
|
||||
@@ -27,6 +35,45 @@ export class HaTemplateSelector extends LitElement {
|
||||
|
||||
@state() private warn: string | undefined = undefined;
|
||||
|
||||
@state() private _test = false;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@state() private _errorLevel?: "ERROR" | "WARNING";
|
||||
|
||||
@state() private _templateResult?: RenderTemplateResult;
|
||||
|
||||
@state() private _unsubRenderTemplate?: Promise<UnsubscribeFunc>;
|
||||
|
||||
private _debounceError = debounce(
|
||||
(error, level) => {
|
||||
this._error = error;
|
||||
this._errorLevel = level;
|
||||
this._templateResult = undefined;
|
||||
},
|
||||
500,
|
||||
false
|
||||
);
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._debounceError.cancel();
|
||||
this._unsubscribeTemplate();
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this._test) {
|
||||
this._subscribeTemplate();
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
if (changedProps.has("value") && this._test) {
|
||||
this._subscribeTemplate();
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
${this.warn
|
||||
@@ -61,10 +108,22 @@ export class HaTemplateSelector extends LitElement {
|
||||
autofocus
|
||||
autocomplete-entities
|
||||
autocomplete-icons
|
||||
.hasTest=${this.selector.template?.preview !== false}
|
||||
.testing=${this._test}
|
||||
@value-changed=${this._handleChange}
|
||||
@test-toggle=${this._testToggle}
|
||||
dir="ltr"
|
||||
linewrap
|
||||
></ha-code-editor>
|
||||
${this._test && this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: this._test && this._templateResult
|
||||
? html`<pre class="rendered">
|
||||
${typeof this._templateResult.result === "object"
|
||||
? JSON.stringify(this._templateResult.result, null, 2)
|
||||
: this._templateResult.result}</pre
|
||||
>`
|
||||
: nothing}
|
||||
${this.helper
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
@@ -73,6 +132,69 @@ export class HaTemplateSelector extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _testToggle() {
|
||||
this._test = !this._test;
|
||||
if (this._test) {
|
||||
this._subscribeTemplate();
|
||||
} else {
|
||||
this._unsubscribeTemplate();
|
||||
}
|
||||
}
|
||||
|
||||
private async _subscribeTemplate() {
|
||||
await this._unsubscribeTemplate();
|
||||
|
||||
const template = this.value || "";
|
||||
|
||||
try {
|
||||
this._unsubRenderTemplate = subscribeRenderTemplate(
|
||||
this.hass.connection,
|
||||
(result) => {
|
||||
if ("error" in result) {
|
||||
// We show the latest error, or a warning if there are no errors
|
||||
if (result.level === "ERROR" || this._errorLevel !== "ERROR") {
|
||||
this._debounceError(result.error, result.level);
|
||||
}
|
||||
} else {
|
||||
this._debounceError.cancel();
|
||||
this._error = undefined;
|
||||
this._errorLevel = undefined;
|
||||
this._templateResult = result;
|
||||
}
|
||||
},
|
||||
{
|
||||
template,
|
||||
timeout: 3,
|
||||
report_errors: true,
|
||||
}
|
||||
);
|
||||
await this._unsubRenderTemplate;
|
||||
} catch (err: any) {
|
||||
this._error = err.message || "Unknown error";
|
||||
this._errorLevel = undefined;
|
||||
this._templateResult = undefined;
|
||||
this._unsubRenderTemplate = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async _unsubscribeTemplate(): Promise<void> {
|
||||
if (!this._unsubRenderTemplate) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const unsub = await this._unsubRenderTemplate;
|
||||
unsub();
|
||||
this._unsubRenderTemplate = undefined;
|
||||
} catch (err: any) {
|
||||
if (err.code === "not_found") {
|
||||
// If we get here, the connection was probably already closed. Ignore.
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _handleChange(ev) {
|
||||
ev.stopPropagation();
|
||||
let value = ev.target.value;
|
||||
@@ -90,6 +212,20 @@ export class HaTemplateSelector extends LitElement {
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
.rendered {
|
||||
font-family: var(--ha-font-family-code);
|
||||
-webkit-font-smoothing: var(--ha-font-smoothing);
|
||||
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
|
||||
clear: both;
|
||||
white-space: pre-wrap;
|
||||
background-color: var(--secondary-background-color);
|
||||
padding: var(--ha-space-2);
|
||||
margin-top: var(--ha-space-3);
|
||||
margin-bottom: 0;
|
||||
direction: ltr;
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
47
src/components/ha-selector/ha-selector-timezone.ts
Normal file
47
src/components/ha-selector/ha-selector-timezone.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { TimezoneSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-timezone-picker";
|
||||
|
||||
@customElement("ha-selector-timezone")
|
||||
export class HaTimezoneSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: TimezoneSelector;
|
||||
|
||||
@property() public value?: string;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = false;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-timezone-picker
|
||||
.hass=${this.hass}
|
||||
.value=${this.value}
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
></ha-timezone-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-timezone-picker {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-selector-timezone": HaTimezoneSelector;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { ActionConfig } from "../../data/lovelace/config/action";
|
||||
import type { UiActionSelector } from "../../data/selector";
|
||||
import "../../panels/lovelace/components/hui-action-editor";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { ActionRelatedContext } from "../../panels/lovelace/components/hui-action-editor";
|
||||
|
||||
@customElement("ha-selector-ui_action")
|
||||
export class HaSelectorUiAction extends LitElement {
|
||||
@@ -14,6 +15,8 @@ export class HaSelectorUiAction extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public value?: ActionConfig;
|
||||
|
||||
@property({ attribute: false }) public context?: ActionRelatedContext;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
@@ -24,6 +27,7 @@ export class HaSelectorUiAction extends LitElement {
|
||||
.label=${this.label}
|
||||
.hass=${this.hass}
|
||||
.config=${this.value}
|
||||
.context=${this.context}
|
||||
.actions=${this.selector.ui_action?.actions}
|
||||
.defaultAction=${this.selector.ui_action?.default_action}
|
||||
.tooltipText=${this.helper}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { HomeAssistant } from "../../types";
|
||||
const LOAD_ELEMENTS = {
|
||||
action: () => import("./ha-selector-action"),
|
||||
addon: () => import("./ha-selector-addon"),
|
||||
app: () => import("./ha-selector-app"),
|
||||
area: () => import("./ha-selector-area"),
|
||||
areas_display: () => import("./ha-selector-areas-display"),
|
||||
attribute: () => import("./ha-selector-attribute"),
|
||||
@@ -52,6 +53,7 @@ const LOAD_ELEMENTS = {
|
||||
icon: () => import("./ha-selector-icon"),
|
||||
media: () => import("./ha-selector-media"),
|
||||
theme: () => import("./ha-selector-theme"),
|
||||
timezone: () => import("./ha-selector-timezone"),
|
||||
button_toggle: () => import("./ha-selector-button-toggle"),
|
||||
trigger: () => import("./ha-selector-trigger"),
|
||||
tts: () => import("./ha-selector-tts"),
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
mdiMenuOpen,
|
||||
} from "@mdi/js";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import {
|
||||
customElement,
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
getPanelIcon,
|
||||
getPanelIconPath,
|
||||
getPanelTitle,
|
||||
SHOW_AFTER_SPACER_PANELS,
|
||||
} from "../data/panel";
|
||||
import type { PersistentNotification } from "../data/persistent_notification";
|
||||
import { subscribeNotifications } from "../data/persistent_notification";
|
||||
@@ -39,6 +38,7 @@ import type { UpdateEntity } from "../data/update";
|
||||
import { updateCanInstall } from "../data/update";
|
||||
import { showEditSidebarDialog } from "../dialogs/sidebar/show-dialog-edit-sidebar";
|
||||
import { SubscribeMixin } from "../mixins/subscribe-mixin";
|
||||
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
|
||||
import { actionHandler } from "../panels/lovelace/common/directives/action-handler-directive";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import type { HomeAssistant, PanelInfo, Route } from "../types";
|
||||
@@ -57,8 +57,6 @@ const SORT_VALUE_URL_PATHS = {
|
||||
map: 2,
|
||||
logbook: 3,
|
||||
history: 4,
|
||||
"developer-tools": 9,
|
||||
config: 11,
|
||||
};
|
||||
|
||||
const panelSorter = (
|
||||
@@ -135,7 +133,6 @@ export const computePanels = memoizeOne(
|
||||
}
|
||||
|
||||
const beforeSpacer: PanelInfo[] = [];
|
||||
const afterSpacer: PanelInfo[] = [];
|
||||
|
||||
const allPanels = Object.values(panels).filter(
|
||||
(panel) => !FIXED_PANELS.includes(panel.url_path)
|
||||
@@ -153,10 +150,7 @@ export const computePanels = memoizeOne(
|
||||
) {
|
||||
return;
|
||||
}
|
||||
(SHOW_AFTER_SPACER_PANELS.includes(panel.url_path)
|
||||
? afterSpacer
|
||||
: beforeSpacer
|
||||
).push(panel);
|
||||
beforeSpacer.push(panel);
|
||||
});
|
||||
|
||||
const reverseSort = [...panelsOrder].reverse();
|
||||
@@ -164,16 +158,13 @@ export const computePanels = memoizeOne(
|
||||
beforeSpacer.sort((a, b) =>
|
||||
panelSorter(reverseSort, defaultPanel, a, b, locale.language)
|
||||
);
|
||||
afterSpacer.sort((a, b) =>
|
||||
panelSorter(reverseSort, defaultPanel, a, b, locale.language)
|
||||
);
|
||||
|
||||
return [beforeSpacer, afterSpacer];
|
||||
return [beforeSpacer, []];
|
||||
}
|
||||
);
|
||||
|
||||
@customElement("ha-sidebar")
|
||||
class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
@@ -205,6 +196,12 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
|
||||
@query(".tooltip") private _tooltip!: HTMLDivElement;
|
||||
|
||||
@query(".before-spacer") private _scrollableList?: HTMLDivElement;
|
||||
|
||||
protected get scrollableElement(): HTMLElement | null {
|
||||
return this._scrollableList as HTMLElement | null;
|
||||
}
|
||||
|
||||
public hassSubscribe() {
|
||||
return [
|
||||
subscribeFrontendUserData(
|
||||
@@ -260,14 +257,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
return html`
|
||||
${this._renderHeader()}
|
||||
${this._renderAllPanels(selectedPanel)}
|
||||
${this._renderDivider()}
|
||||
<ha-md-list>
|
||||
${this._renderNotifications()}
|
||||
${this._renderUserItem(selectedPanel)}
|
||||
</ha-md-list>
|
||||
<div disabled class="bottom-spacer"></div>
|
||||
<div class="tooltip"></div>
|
||||
`;
|
||||
<div class="tooltip"></div>`;
|
||||
}
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
||||
@@ -275,12 +265,13 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
changedProps.has("expanded") ||
|
||||
changedProps.has("narrow") ||
|
||||
changedProps.has("alwaysExpand") ||
|
||||
changedProps.has("_externalConfig") ||
|
||||
changedProps.has("_updatesCount") ||
|
||||
changedProps.has("_issuesCount") ||
|
||||
changedProps.has("_notifications") ||
|
||||
changedProps.has("_hiddenPanels") ||
|
||||
changedProps.has("_panelOrder")
|
||||
changedProps.has("_panelOrder") ||
|
||||
changedProps.has("_contentScrolled") ||
|
||||
changedProps.has("_contentScrollable")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@@ -384,11 +375,30 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
private _renderAllPanels(selectedPanel: string) {
|
||||
const renderList = (content, cls: string, scrollable: boolean) =>
|
||||
html`<ha-md-list
|
||||
class=${classMap({
|
||||
"ha-scrollbar": scrollable,
|
||||
[cls]: true,
|
||||
})}
|
||||
@focusin=${this._listboxFocusIn}
|
||||
@focusout=${this._listboxFocusOut}
|
||||
@touchend=${this._listboxTouchend}
|
||||
@scroll=${this._listboxScroll}
|
||||
@keydown=${this._listboxKeydown}
|
||||
>${content}</ha-md-list
|
||||
>`;
|
||||
|
||||
if (!this._panelOrder || !this._hiddenPanels) {
|
||||
return html`
|
||||
<ha-fade-in .delay=${500}>
|
||||
<ha-spinner size="small"></ha-spinner>
|
||||
</ha-fade-in>
|
||||
${renderList(
|
||||
html`${this._renderFixedPanels(selectedPanel)}`,
|
||||
"after-spacer",
|
||||
false
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -402,22 +412,36 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
this.hass.locale
|
||||
);
|
||||
|
||||
// prettier-ignore
|
||||
return html`<div class="panels-list">
|
||||
<div class="wrapper">
|
||||
${renderList(
|
||||
this._renderPanels(beforeSpacer, selectedPanel),
|
||||
"before-spacer",
|
||||
true
|
||||
)}
|
||||
${this.renderScrollableFades()}
|
||||
</div>
|
||||
${this._renderSpacer()}
|
||||
${renderList(
|
||||
html`
|
||||
${this._renderPanels(afterSpacer, selectedPanel)}
|
||||
${this._renderFixedPanels(selectedPanel)}
|
||||
`,
|
||||
"after-spacer",
|
||||
false
|
||||
)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _renderFixedPanels(selectedPanel: string) {
|
||||
// prettier-ignore
|
||||
return html`
|
||||
<ha-md-list
|
||||
class="ha-scrollbar"
|
||||
@focusin=${this._listboxFocusIn}
|
||||
@focusout=${this._listboxFocusOut}
|
||||
@touchend=${this._listboxTouchend}
|
||||
@scroll=${this._listboxScroll}
|
||||
@keydown=${this._listboxKeydown}
|
||||
>
|
||||
${this._renderPanels(beforeSpacer, selectedPanel)}
|
||||
${this._renderSpacer()}
|
||||
${this._renderPanels(afterSpacer, selectedPanel)}
|
||||
${this.hass.user?.is_admin
|
||||
? this._renderConfiguration(selectedPanel)
|
||||
: this._renderExternalConfiguration()}
|
||||
</ha-md-list>
|
||||
${this.hass.user?.is_admin
|
||||
? this._renderConfiguration(selectedPanel)
|
||||
: this._renderExternalConfiguration()}
|
||||
${this._renderNotifications()}
|
||||
${this._renderUserItem(selectedPanel)}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -449,10 +473,6 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderDivider() {
|
||||
return html`<div class="divider"></div>`;
|
||||
}
|
||||
|
||||
private _renderSpacer() {
|
||||
return html`<div class="spacer" disabled></div>`;
|
||||
}
|
||||
@@ -474,21 +494,17 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
<ha-svg-icon slot="start" .path=${mdiCog}></ha-svg-icon>
|
||||
${!this.alwaysExpand &&
|
||||
(this._updatesCount > 0 || this._issuesCount > 0)
|
||||
? html`
|
||||
<span class="badge" slot="start">
|
||||
${this._updatesCount + this._issuesCount}
|
||||
</span>
|
||||
`
|
||||
? html`<span class="badge" slot="start"
|
||||
>${this._updatesCount + this._issuesCount}</span
|
||||
>`
|
||||
: nothing}
|
||||
<span class="item-text" slot="headline"
|
||||
>${this.hass.localize("panel.config")}</span
|
||||
>
|
||||
${this.alwaysExpand && (this._updatesCount > 0 || this._issuesCount > 0)
|
||||
? html`
|
||||
<span class="badge" slot="end"
|
||||
>${this._updatesCount + this._issuesCount}</span
|
||||
>
|
||||
`
|
||||
? html`<span class="badge" slot="end"
|
||||
>${this._updatesCount + this._issuesCount}</span
|
||||
>`
|
||||
: nothing}
|
||||
</ha-md-list-item>
|
||||
`;
|
||||
@@ -509,9 +525,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiBell}></ha-svg-icon>
|
||||
${!this.alwaysExpand && notificationCount > 0
|
||||
? html`
|
||||
<span class="badge" slot="start"> ${notificationCount} </span>
|
||||
`
|
||||
? html`<span class="badge" slot="start">${notificationCount}</span>`
|
||||
: nothing}
|
||||
<span class="item-text" slot="headline"
|
||||
>${this.hass.localize("ui.notification_drawer.title")}</span
|
||||
@@ -544,9 +558,9 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
.user=${this.hass.user}
|
||||
.hass=${this.hass}
|
||||
></ha-user-badge>
|
||||
<span class="item-text" slot="headline">
|
||||
${this.hass.user ? this.hass.user.name : ""}
|
||||
</span>
|
||||
<span class="item-text" slot="headline"
|
||||
>${this.hass.user ? this.hass.user.name : ""}</span
|
||||
>
|
||||
</ha-md-list-item>
|
||||
`;
|
||||
}
|
||||
@@ -563,9 +577,9 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
@mouseleave=${this._itemMouseLeave}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiCellphoneCog}></ha-svg-icon>
|
||||
<span class="item-text" slot="headline">
|
||||
${this.hass.localize("ui.sidebar.external_app_configuration")}
|
||||
</span>
|
||||
<span class="item-text" slot="headline"
|
||||
>${this.hass.localize("ui.sidebar.external_app_configuration")}</span
|
||||
>
|
||||
</ha-md-list-item>
|
||||
`;
|
||||
}
|
||||
@@ -658,20 +672,13 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
const tooltip = this._tooltip;
|
||||
const allListbox = this.shadowRoot!.querySelectorAll("ha-md-list")!;
|
||||
const listbox = [...allListbox].find((lb) => lb.contains(item));
|
||||
|
||||
const top =
|
||||
item.offsetTop +
|
||||
11 +
|
||||
(listbox?.offsetTop ?? 0) -
|
||||
(listbox?.scrollTop ?? 0);
|
||||
const itemRect = item.getBoundingClientRect();
|
||||
|
||||
tooltip.innerText = itemText?.innerText ?? "";
|
||||
tooltip.style.display = "block";
|
||||
tooltip.style.position = "fixed";
|
||||
tooltip.style.top = `${top}px`;
|
||||
tooltip.style.left = `calc(${item.offsetLeft + item.clientWidth + 8}px + var(--safe-area-inset-left, 0px))`;
|
||||
tooltip.style.top = `${itemRect.top + itemRect.height / 2 - tooltip.offsetHeight / 2}px`;
|
||||
tooltip.style.left = `calc(${itemRect.right + 8}px)`;
|
||||
}
|
||||
|
||||
private _hideTooltip() {
|
||||
@@ -695,8 +702,9 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
fireEvent(this, "hass-toggle-menu");
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
static get styles() {
|
||||
return [
|
||||
...super.styles,
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
:host {
|
||||
@@ -763,15 +771,12 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
:host([expanded]) .title {
|
||||
display: initial;
|
||||
}
|
||||
.hidden-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
ha-fade-in,
|
||||
ha-md-list {
|
||||
.panels-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(
|
||||
100% - var(--header-height) - var(--safe-area-inset-top, 0px) -
|
||||
132px
|
||||
100vh - var(--header-height) - var(--safe-area-inset-top, 0px)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -781,6 +786,10 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: calc(
|
||||
100vh - var(--header-height) - var(--safe-area-inset-top, 0px) -
|
||||
152px
|
||||
); /* 152px = three list items w/o padding-top */
|
||||
}
|
||||
|
||||
ha-md-list {
|
||||
@@ -789,6 +798,21 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
margin-left: var(--safe-area-inset-left, 0px);
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
ha-md-list.before-spacer {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
ha-md-list.after-spacer {
|
||||
padding-top: 0;
|
||||
min-height: fit-content;
|
||||
}
|
||||
|
||||
ha-md-list-item {
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
@@ -854,16 +878,6 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.divider {
|
||||
bottom: 112px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
.divider::before {
|
||||
content: " ";
|
||||
display: block;
|
||||
height: 1px;
|
||||
background-color: var(--divider-color);
|
||||
}
|
||||
.badge {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -902,18 +916,10 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
margin-top: auto;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.subheader {
|
||||
color: var(--sidebar-text-color);
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
padding: var(--ha-space-4);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
display: none;
|
||||
position: absolute;
|
||||
|
||||
@@ -1,56 +1,102 @@
|
||||
import timezones from "google-timezones-json";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { stopPropagation } from "../common/dom/stop_propagation";
|
||||
import "./ha-list-item";
|
||||
import "./ha-select";
|
||||
import type { HaSelect } from "./ha-select";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import "./ha-generic-picker";
|
||||
|
||||
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
|
||||
|
||||
const SEARCH_KEYS = [
|
||||
{ name: "primary", weight: 10 },
|
||||
{ name: "secondary", weight: 8 },
|
||||
];
|
||||
|
||||
export const getTimezoneOptions = (): PickerComboBoxItem[] =>
|
||||
Object.entries(timezones as Record<string, string>).map(([key, value]) => ({
|
||||
id: key,
|
||||
primary: value,
|
||||
secondary: key,
|
||||
}));
|
||||
|
||||
@customElement("ha-timezone-picker")
|
||||
export class HaTimeZonePicker extends LitElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property() public value?: string;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property() public placeholder?: string;
|
||||
|
||||
@property({ type: Boolean }) public required = false;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean, attribute: "hide-clear-icon" })
|
||||
public hideClearIcon = false;
|
||||
|
||||
private _getTimezoneOptions = memoizeOne(getTimezoneOptions);
|
||||
|
||||
private _getItems = () => this._getTimezoneOptions();
|
||||
|
||||
private _getTimezoneName = (tz?: string) =>
|
||||
this._getItems().find((t) => t.id === tz)?.primary;
|
||||
|
||||
private _valueRenderer = (value: string) =>
|
||||
html`<span slot="headline">${this._getTimezoneName(value) ?? value}</span>`;
|
||||
|
||||
protected render() {
|
||||
const label =
|
||||
this.label ??
|
||||
(this.hass?.localize("ui.components.timezone-picker.time_zone") ||
|
||||
"Time zone");
|
||||
|
||||
return html`
|
||||
<ha-select
|
||||
.label=${this.label}
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.notFoundLabel=${this._notFoundLabel}
|
||||
.emptyLabel=${this.hass?.localize(
|
||||
"ui.components.timezone-picker.no_timezones"
|
||||
) || "No time zones available"}
|
||||
.label=${label}
|
||||
.helper=${this.helper}
|
||||
.placeholder=${this.placeholder}
|
||||
.value=${this.value}
|
||||
.required=${this.required}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
.disabled=${this.disabled}
|
||||
@selected=${this._changed}
|
||||
@closed=${stopPropagation}
|
||||
fixedMenuPosition
|
||||
naturalMenuWidth
|
||||
>
|
||||
${Object.entries(timezones).map(
|
||||
([key, value]) =>
|
||||
html`<ha-list-item value=${key}>${value}</ha-list-item>`
|
||||
)}
|
||||
</ha-select>
|
||||
.required=${this.required}
|
||||
.getItems=${this._getItems}
|
||||
.searchKeys=${SEARCH_KEYS}
|
||||
.hideClearIcon=${this.hideClearIcon}
|
||||
@value-changed=${this._changed}
|
||||
></ha-generic-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-select {
|
||||
ha-generic-picker {
|
||||
width: 100%;
|
||||
min-width: 200px;
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
private _changed(ev): void {
|
||||
const target = ev.target as HaSelect;
|
||||
if (target.value === "" || target.value === this.value) {
|
||||
return;
|
||||
}
|
||||
this.value = target.value;
|
||||
private _changed(ev: ValueChangedEvent<string>): void {
|
||||
ev.stopPropagation();
|
||||
this.value = ev.detail.value;
|
||||
fireEvent(this, "value-changed", { value: this.value });
|
||||
}
|
||||
|
||||
private _notFoundLabel = (search: string) => {
|
||||
const term = html`<b>'${search}'</b>`;
|
||||
return this.hass
|
||||
? this.hass.localize("ui.components.timezone-picker.no_match", { term })
|
||||
: html`No time zones found for ${term}`;
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -42,9 +42,12 @@ export class HaTopAppBarFixed extends TopAppBarFixedBase {
|
||||
}
|
||||
.mdc-top-app-bar__title {
|
||||
font-size: var(--ha-font-size-xl);
|
||||
padding-inline-start: 24px;
|
||||
padding-inline-start: var(--ha-space-6);
|
||||
padding-inline-end: initial;
|
||||
}
|
||||
:host([narrow]) .mdc-top-app-bar__title {
|
||||
padding-inline-start: var(--ha-space-2);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { stopPropagation } from "../../common/dom/stop_propagation";
|
||||
import type { ActionHandlerOptions } from "../../data/lovelace/action_handler";
|
||||
import { actionHandler } from "../../panels/lovelace/common/directives/action-handler-directive";
|
||||
import "../ha-ripple";
|
||||
@@ -47,7 +48,11 @@ export class HaTileContainer extends LitElement {
|
||||
>
|
||||
<ha-ripple .disabled=${!this.interactive}></ha-ripple>
|
||||
</div>
|
||||
<div class="container ${containerOrientationClass}">
|
||||
<div
|
||||
class="container ${containerOrientationClass}"
|
||||
@action=${stopPropagation}
|
||||
@click=${stopPropagation}
|
||||
>
|
||||
<div class="content ${classMap(contentClasses)}">
|
||||
<slot name="icon"></slot>
|
||||
<slot name="info" id="info"></slot>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import {
|
||||
computeCssColor,
|
||||
isValidColorString,
|
||||
} from "../common/color/compute-color";
|
||||
import { getColorByIndex } from "../common/color/colors";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { computeStateName } from "../common/entity/compute_state_name";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { isUnavailableState } from "./entity/entity";
|
||||
import type { EntityRegistryEntry } from "./entity/entity_registry";
|
||||
|
||||
export interface Calendar {
|
||||
entity_id: string;
|
||||
@@ -139,9 +144,13 @@ const getCalendarDate = (dateObj: any): string | undefined => {
|
||||
|
||||
export const getCalendars = (
|
||||
hass: HomeAssistant,
|
||||
element: Element
|
||||
element: Element,
|
||||
entityRegistry?: EntityRegistryEntry[]
|
||||
): Calendar[] => {
|
||||
const computedStyles = getComputedStyle(element);
|
||||
const entityOptionsMap = new Map(
|
||||
entityRegistry?.map((entry) => [entry.entity_id, entry.options]) ?? []
|
||||
);
|
||||
return Object.keys(hass.states)
|
||||
.filter(
|
||||
(eid) =>
|
||||
@@ -150,11 +159,23 @@ export const getCalendars = (
|
||||
hass.entities[eid]?.hidden !== true
|
||||
)
|
||||
.sort()
|
||||
.map((eid, idx) => ({
|
||||
...hass.states[eid],
|
||||
name: computeStateName(hass.states[eid]),
|
||||
backgroundColor: getColorByIndex(idx, computedStyles),
|
||||
}));
|
||||
.map((eid, idx) => {
|
||||
const stateObj = hass.states[eid];
|
||||
const entityColor = entityOptionsMap.get(eid)?.calendar?.color;
|
||||
let backgroundColor: string;
|
||||
// Validate and use the color from entity registry if valid
|
||||
if (entityColor && isValidColorString(entityColor)) {
|
||||
backgroundColor = computeCssColor(entityColor);
|
||||
} else {
|
||||
// Fall back to default color by index
|
||||
backgroundColor = getColorByIndex(idx, computedStyles);
|
||||
}
|
||||
return {
|
||||
...stateObj,
|
||||
name: computeStateName(stateObj),
|
||||
backgroundColor,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const createCalendarEvent = (
|
||||
|
||||
@@ -131,11 +131,30 @@ export interface FlowToGridSourceEnergyPreference {
|
||||
number_energy_price: number | null;
|
||||
}
|
||||
|
||||
export interface GridPowerSourceEnergyPreference {
|
||||
// W meter
|
||||
stat_rate: string;
|
||||
export interface PowerConfig {
|
||||
stat_rate?: string; // Standard single sensor
|
||||
stat_rate_inverted?: string; // Inverted single sensor
|
||||
stat_rate_from?: string; // Battery: discharge / Grid: consumption
|
||||
stat_rate_to?: string; // Battery: charge / Grid: return
|
||||
}
|
||||
|
||||
export interface GridPowerSourceEnergyPreference {
|
||||
stat_rate: string;
|
||||
power_config?: PowerConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input type for saving grid power sources.
|
||||
* Core requires EITHER stat_rate (legacy) OR power_config (new format).
|
||||
* When reading from backend, stat_rate is always populated.
|
||||
*/
|
||||
export type GridPowerSourceInput = Omit<
|
||||
GridPowerSourceEnergyPreference,
|
||||
"stat_rate"
|
||||
> & {
|
||||
stat_rate?: string;
|
||||
};
|
||||
|
||||
export interface GridSourceTypeEnergyPreference {
|
||||
type: "grid";
|
||||
|
||||
@@ -159,6 +178,7 @@ export interface BatterySourceTypeEnergyPreference {
|
||||
stat_energy_from: string;
|
||||
stat_energy_to: string;
|
||||
stat_rate?: string;
|
||||
power_config?: PowerConfig;
|
||||
}
|
||||
export interface GasSourceTypeEnergyPreference {
|
||||
type: "gas";
|
||||
|
||||
@@ -103,6 +103,10 @@ export interface AlarmControlPanelEntityOptions {
|
||||
default_code?: string | null;
|
||||
}
|
||||
|
||||
export interface CalendarEntityOptions {
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
export interface WeatherEntityOptions {
|
||||
precipitation_unit?: string | null;
|
||||
pressure_unit?: string | null;
|
||||
@@ -120,6 +124,7 @@ export interface EntityRegistryOptions {
|
||||
number?: NumberEntityOptions;
|
||||
sensor?: SensorEntityOptions;
|
||||
alarm_control_panel?: AlarmControlPanelEntityOptions;
|
||||
calendar?: CalendarEntityOptions;
|
||||
lock?: LockEntityOptions;
|
||||
weather?: WeatherEntityOptions;
|
||||
light?: LightEntityOptions;
|
||||
@@ -143,6 +148,7 @@ export interface EntityRegistryEntryUpdateParams {
|
||||
| NumberEntityOptions
|
||||
| LockEntityOptions
|
||||
| AlarmControlPanelEntityOptions
|
||||
| CalendarEntityOptions
|
||||
| WeatherEntityOptions
|
||||
| LightEntityOptions;
|
||||
aliases?: string[];
|
||||
|
||||
@@ -13,10 +13,13 @@ export interface SidebarFrontendUserData {
|
||||
|
||||
export interface CoreFrontendSystemData {
|
||||
default_panel?: string;
|
||||
onboarded_version?: string;
|
||||
onboarded_date?: string;
|
||||
}
|
||||
|
||||
export interface HomeFrontendSystemData {
|
||||
favorite_entities?: string[];
|
||||
welcome_banner_dismissed?: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -78,14 +78,16 @@ export const findIconChunk = (icon: string): string => {
|
||||
|
||||
export const writeCache = async (chunks: Chunks) => {
|
||||
const keys = Object.keys(chunks);
|
||||
const iconsSets: Icons[] = await Promise.all(Object.values(chunks));
|
||||
const results = await Promise.allSettled(Object.values(chunks));
|
||||
const iconStore = await getStore();
|
||||
// We do a batch opening the store just once, for (considerable) performance
|
||||
iconStore("readwrite", (store) => {
|
||||
iconsSets.forEach((icons, idx) => {
|
||||
Object.entries(icons).forEach(([name, path]) => {
|
||||
store.put(path, name);
|
||||
});
|
||||
results.forEach((result, idx) => {
|
||||
if (result.status === "fulfilled") {
|
||||
Object.entries(result.value).forEach(([name, path]) => {
|
||||
store.put(path, name);
|
||||
});
|
||||
}
|
||||
delete chunks[keys[idx]];
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
export interface LovelaceInfo {
|
||||
resource_mode: "yaml" | "storage";
|
||||
}
|
||||
|
||||
export interface LovelaceResource {
|
||||
id: string;
|
||||
type: "css" | "js" | "module" | "html";
|
||||
@@ -42,3 +46,8 @@ export const deleteResource = (hass: HomeAssistant, id: string) =>
|
||||
type: "lovelace/resources/delete",
|
||||
resource_id: id,
|
||||
});
|
||||
|
||||
export const fetchLovelaceInfo = (hass: HomeAssistant): Promise<LovelaceInfo> =>
|
||||
hass.callWS({
|
||||
type: "lovelace/info",
|
||||
});
|
||||
|
||||
@@ -4,29 +4,37 @@ import {
|
||||
mdiChartBox,
|
||||
mdiClipboardList,
|
||||
mdiFormatListBulletedType,
|
||||
mdiHammer,
|
||||
mdiLightningBolt,
|
||||
mdiPlayBoxMultiple,
|
||||
mdiTooltipAccount,
|
||||
mdiViewDashboard,
|
||||
} from "@mdi/js";
|
||||
import type { HomeAssistant, PanelInfo } from "../types";
|
||||
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
|
||||
import type { LocalizeKeys } from "../common/translations/localize";
|
||||
|
||||
/** Panel to show when no panel is picked. */
|
||||
export const DEFAULT_PANEL = "lovelace";
|
||||
export const DEFAULT_PANEL = "home";
|
||||
|
||||
export const hasLegacyOverviewPanel = (hass: HomeAssistant): boolean =>
|
||||
Boolean(hass.panels.lovelace?.config);
|
||||
|
||||
export const getLegacyDefaultPanelUrlPath = (): string | null => {
|
||||
const defaultPanel = window.localStorage.getItem("defaultPanel");
|
||||
return defaultPanel ? JSON.parse(defaultPanel) : null;
|
||||
};
|
||||
|
||||
export const getDefaultPanelUrlPath = (hass: HomeAssistant): string =>
|
||||
hass.userData?.default_panel ||
|
||||
hass.systemData?.default_panel ||
|
||||
getLegacyDefaultPanelUrlPath() ||
|
||||
DEFAULT_PANEL;
|
||||
export const getDefaultPanelUrlPath = (hass: HomeAssistant): string => {
|
||||
const defaultPanel =
|
||||
hass.userData?.default_panel ||
|
||||
hass.systemData?.default_panel ||
|
||||
getLegacyDefaultPanelUrlPath() ||
|
||||
DEFAULT_PANEL;
|
||||
// If default panel is lovelace and no old overview exists, fall back to home
|
||||
if (defaultPanel === "lovelace" && !hasLegacyOverviewPanel(hass)) {
|
||||
return DEFAULT_PANEL;
|
||||
}
|
||||
return defaultPanel;
|
||||
};
|
||||
|
||||
export const getDefaultPanel = (hass: HomeAssistant): PanelInfo => {
|
||||
const panel = getDefaultPanelUrlPath(hass);
|
||||
@@ -35,10 +43,6 @@ export const getDefaultPanel = (hass: HomeAssistant): PanelInfo => {
|
||||
};
|
||||
|
||||
export const getPanelNameTranslationKey = (panel: PanelInfo) => {
|
||||
if (panel.url_path === "lovelace") {
|
||||
return "panel.states" as const;
|
||||
}
|
||||
|
||||
if (panel.url_path === "profile") {
|
||||
return "panel.profile" as const;
|
||||
}
|
||||
@@ -113,8 +117,6 @@ export const getPanelIcon = (panel: PanelInfo): string | undefined => {
|
||||
switch (panel.component_name) {
|
||||
case "profile":
|
||||
return "mdi:account";
|
||||
case "lovelace":
|
||||
return "mdi:view-dashboard";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,13 +125,11 @@ export const getPanelIcon = (panel: PanelInfo): string | undefined => {
|
||||
|
||||
export const PANEL_ICON_PATHS = {
|
||||
calendar: mdiCalendar,
|
||||
"developer-tools": mdiHammer,
|
||||
energy: mdiLightningBolt,
|
||||
history: mdiChartBox,
|
||||
logbook: mdiFormatListBulletedType,
|
||||
lovelace: mdiViewDashboard,
|
||||
profile: mdiAccount,
|
||||
map: mdiTooltipAccount,
|
||||
profile: mdiAccount,
|
||||
"media-browser": mdiPlayBoxMultiple,
|
||||
todo: mdiClipboardList,
|
||||
};
|
||||
@@ -138,4 +138,3 @@ export const getPanelIconPath = (panel: PanelInfo): string | undefined =>
|
||||
PANEL_ICON_PATHS[panel.url_path];
|
||||
|
||||
export const FIXED_PANELS = ["profile", "config"];
|
||||
export const SHOW_AFTER_SPACER_PANELS = ["developer-tools"];
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import {
|
||||
mdiKeyboard,
|
||||
mdiNavigationVariant,
|
||||
mdiPuzzle,
|
||||
mdiReload,
|
||||
mdiServerNetwork,
|
||||
mdiStorePlus,
|
||||
} from "@mdi/js";
|
||||
import { canShowPage } from "../common/config/can_show_page";
|
||||
import {
|
||||
filterNavigationPages,
|
||||
type NavigationFilterOptions,
|
||||
} from "../common/config/filter_navigation_pages";
|
||||
import { componentsWithService } from "../common/config/components_with_service";
|
||||
import { isComponentLoaded } from "../common/config/is_component_loaded";
|
||||
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
|
||||
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
|
||||
import { configSections } from "../panels/config/ha-panel-config";
|
||||
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { HassioAddonInfo } from "./hassio/addon";
|
||||
import { domainToName } from "./integration";
|
||||
import { getPanelIcon, getPanelNameTranslationKey } from "./panel";
|
||||
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
|
||||
|
||||
export interface NavigationComboBoxItem extends PickerComboBoxItem {
|
||||
path: string;
|
||||
@@ -27,6 +29,7 @@ export interface NavigationComboBoxItem extends PickerComboBoxItem {
|
||||
export interface BaseNavigationCommand {
|
||||
path: string;
|
||||
primary: string;
|
||||
secondary?: string;
|
||||
icon_path?: string;
|
||||
iconPath?: string;
|
||||
iconColor?: string;
|
||||
@@ -45,11 +48,14 @@ export interface NavigationInfo extends PageNavigation {
|
||||
const generateNavigationPanelCommands = (
|
||||
localize: HomeAssistant["localize"],
|
||||
panels: HomeAssistant["panels"],
|
||||
addons?: HassioAddonInfo[]
|
||||
apps?: HassioAddonInfo[]
|
||||
): BaseNavigationCommand[] =>
|
||||
Object.entries(panels)
|
||||
.filter(
|
||||
([panelKey]) => panelKey !== "_my_redirect" && panelKey !== "hassio"
|
||||
([panelKey]) =>
|
||||
panelKey !== "_my_redirect" &&
|
||||
panelKey !== "hassio" &&
|
||||
panelKey !== "app"
|
||||
)
|
||||
.map(([_panelKey, panel]) => {
|
||||
const translationKey = getPanelNameTranslationKey(panel);
|
||||
@@ -59,12 +65,10 @@ const generateNavigationPanelCommands = (
|
||||
|
||||
let image: string | undefined;
|
||||
|
||||
if (addons) {
|
||||
const addon = addons.find(({ slug }) => slug === panel.url_path);
|
||||
if (addon) {
|
||||
image = addon.icon
|
||||
? `/api/hassio/addons/${addon.slug}/icon`
|
||||
: undefined;
|
||||
if (apps) {
|
||||
const app = apps.find(({ slug }) => slug === panel.url_path);
|
||||
if (app) {
|
||||
image = app.icon ? `/api/hassio/addons/${app.slug}/icon` : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,33 +102,30 @@ const getNavigationInfoFromConfig = (
|
||||
};
|
||||
|
||||
const generateNavigationConfigSectionCommands = (
|
||||
hass: HomeAssistant
|
||||
hass: HomeAssistant,
|
||||
filterOptions: NavigationFilterOptions = {}
|
||||
): BaseNavigationCommand[] => {
|
||||
if (!hass.user?.is_admin) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items: NavigationInfo[] = [];
|
||||
const allPages = Object.values(configSections).flat();
|
||||
const visiblePages = filterNavigationPages(hass, allPages, filterOptions);
|
||||
|
||||
Object.values(configSections).forEach((sectionPages) => {
|
||||
sectionPages.forEach((page) => {
|
||||
if (!canShowPage(hass, page)) {
|
||||
return;
|
||||
}
|
||||
for (const page of visiblePages) {
|
||||
const info = getNavigationInfoFromConfig(hass.localize, page);
|
||||
|
||||
const info = getNavigationInfoFromConfig(hass.localize, page);
|
||||
if (!info) {
|
||||
continue;
|
||||
}
|
||||
// Add to list, but only if we do not already have an entry for the same path and component
|
||||
if (items.some((e) => e.path === info.path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!info) {
|
||||
return;
|
||||
}
|
||||
// Add to list, but only if we do not already have an entry for the same path and component
|
||||
if (items.some((e) => e.path === info.path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
items.push(info);
|
||||
});
|
||||
});
|
||||
items.push(info);
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
@@ -140,7 +141,7 @@ const finalizeNavigationCommands = (
|
||||
return {
|
||||
id: `navigation_${index}_${item.path}`,
|
||||
icon_path: item.iconPath || mdiNavigationVariant,
|
||||
secondary,
|
||||
secondary: item.secondary || secondary,
|
||||
sorting_label: `${item.primary}_${secondary}`,
|
||||
...item,
|
||||
};
|
||||
@@ -148,41 +149,42 @@ const finalizeNavigationCommands = (
|
||||
|
||||
export const generateNavigationCommands = (
|
||||
hass: HomeAssistant,
|
||||
addons?: HassioAddonInfo[]
|
||||
apps?: HassioAddonInfo[],
|
||||
filterOptions: NavigationFilterOptions = {}
|
||||
): NavigationComboBoxItem[] => {
|
||||
const panelItems = generateNavigationPanelCommands(
|
||||
hass.localize,
|
||||
hass.panels,
|
||||
addons
|
||||
apps
|
||||
);
|
||||
const sectionItems = generateNavigationConfigSectionCommands(hass);
|
||||
const supervisorItems: BaseNavigationCommand[] = [];
|
||||
|
||||
const sectionItems = generateNavigationConfigSectionCommands(
|
||||
hass,
|
||||
filterOptions
|
||||
);
|
||||
const appItems: BaseNavigationCommand[] = [];
|
||||
if (hass.user?.is_admin && isComponentLoaded(hass, "hassio")) {
|
||||
supervisorItems.push({
|
||||
path: "/hassio/store",
|
||||
appItems.push({
|
||||
path: "/config/apps/available",
|
||||
icon_path: mdiStorePlus,
|
||||
primary: hass.localize(
|
||||
"ui.dialogs.quick-bar.commands.navigation.addon_store"
|
||||
"ui.dialogs.quick-bar.commands.navigation.app_store"
|
||||
),
|
||||
iconColor: "#F1C447",
|
||||
});
|
||||
supervisorItems.push({
|
||||
path: "/hassio/dashboard",
|
||||
icon_path: mdiPuzzle,
|
||||
primary: hass.localize(
|
||||
"ui.dialogs.quick-bar.commands.navigation.addon_dashboard"
|
||||
),
|
||||
});
|
||||
if (addons) {
|
||||
for (const addon of addons.filter((a) => a.version)) {
|
||||
supervisorItems.push({
|
||||
path: `/hassio/addon/${addon.slug}`,
|
||||
image: addon.icon
|
||||
? `/api/hassio/addons/${addon.slug}/icon`
|
||||
: undefined,
|
||||
if (apps) {
|
||||
for (const app of apps.filter((a) => a.version)) {
|
||||
appItems.push({
|
||||
path: `/config/app/${app.slug}`,
|
||||
image: app.icon ? `/api/hassio/addons/${app.slug}/icon` : undefined,
|
||||
primary: hass.localize(
|
||||
"ui.dialogs.quick-bar.commands.navigation.addon_info",
|
||||
{ addon: addon.name }
|
||||
"ui.dialogs.quick-bar.commands.navigation.app_info",
|
||||
{ app: app.name }
|
||||
),
|
||||
secondary: hass.localize(
|
||||
"ui.dialogs.quick-bar.commands.types.app_settings"
|
||||
),
|
||||
iconColor: "#F1C447",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -201,7 +203,7 @@ export const generateNavigationCommands = (
|
||||
return finalizeNavigationCommands(hass.localize, [
|
||||
...panelItems,
|
||||
...sectionItems,
|
||||
...supervisorItems,
|
||||
...appItems,
|
||||
...additionalItems,
|
||||
]);
|
||||
};
|
||||
@@ -265,16 +267,16 @@ const generateServerControlCommands = (
|
||||
|
||||
return serverActions.map((action, index) => {
|
||||
const primary = hass.localize(
|
||||
"ui.dialogs.quick-bar.commands.server_control.perform_action",
|
||||
"ui.dialogs.quick-bar.commands.home_assistant_control.perform_action",
|
||||
{
|
||||
action: hass.localize(
|
||||
`ui.dialogs.quick-bar.commands.server_control.${action}`
|
||||
`ui.dialogs.quick-bar.commands.home_assistant_control.${action}`
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
const secondary = hass.localize(
|
||||
"ui.dialogs.quick-bar.commands.types.server_control"
|
||||
"ui.dialogs.quick-bar.commands.types.home_assistant_control"
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -7,7 +7,10 @@ import type { EntityNameItem } from "../common/entity/compute_entity_name_displa
|
||||
import { computeStateDomain } from "../common/entity/compute_state_domain";
|
||||
import { supportsFeature } from "../common/entity/supports-feature";
|
||||
import { isHelperDomain } from "../panels/config/helpers/const";
|
||||
import type { UiAction } from "../panels/lovelace/components/hui-action-editor";
|
||||
import type {
|
||||
ActionRelatedContext,
|
||||
UiAction,
|
||||
} from "../panels/lovelace/components/hui-action-editor";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import {
|
||||
type DeviceRegistryEntry,
|
||||
@@ -22,6 +25,7 @@ import type { EntitySources } from "./entity/entity_sources";
|
||||
export type Selector =
|
||||
| ActionSelector
|
||||
| AddonSelector
|
||||
| AppSelector
|
||||
| AreaSelector
|
||||
| AreasDisplaySelector
|
||||
| AttributeSelector
|
||||
@@ -65,6 +69,7 @@ export type Selector =
|
||||
| TemplateSelector
|
||||
| ThemeSelector
|
||||
| TimeSelector
|
||||
| TimezoneSelector
|
||||
| TriggerSelector
|
||||
| TTSSelector
|
||||
| TTSVoiceSelector
|
||||
@@ -80,7 +85,11 @@ export interface ActionSelector {
|
||||
}
|
||||
|
||||
export interface AddonSelector {
|
||||
addon: {
|
||||
addon: AppSelector["app"];
|
||||
}
|
||||
|
||||
export interface AppSelector {
|
||||
app: {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
} | null;
|
||||
@@ -293,6 +302,10 @@ export interface LanguageSelector {
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface TimezoneSelector {
|
||||
timezone: {} | null;
|
||||
}
|
||||
|
||||
export interface LocationSelector {
|
||||
location: {
|
||||
radius?: boolean;
|
||||
@@ -332,7 +345,7 @@ export interface MediaSelectorValue {
|
||||
}
|
||||
|
||||
export interface NavigationSelector {
|
||||
navigation: {} | null;
|
||||
navigation: ActionRelatedContext | null;
|
||||
}
|
||||
|
||||
export interface NumberSelector {
|
||||
@@ -460,7 +473,9 @@ export interface TargetSelector {
|
||||
}
|
||||
|
||||
export interface TemplateSelector {
|
||||
template: {} | null;
|
||||
template: {
|
||||
preview?: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ThemeSelector {
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "../../../components/ha-attributes";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "../../../components/ha-assist-chat";
|
||||
import "../../../components/ha-spinner";
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { supportsFeature } from "../../../common/entity/supports-feature";
|
||||
import "../../../components/ha-attributes";
|
||||
import "../../../components/ha-icon-button-group";
|
||||
import "../../../components/ha-icon-button-toggle";
|
||||
import type { CoverEntity } from "../../../data/cover";
|
||||
@@ -176,11 +175,6 @@ class MoreInfoCover extends LitElement {
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<ha-attributes
|
||||
.hass=${this.hass}
|
||||
.stateObj=${this.stateObj}
|
||||
extra-filters="current_position,current_tilt_position"
|
||||
></ha-attributes>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../../../components/ha-attributes";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
|
||||
@customElement("more-info-default")
|
||||
@@ -15,10 +14,7 @@ class MoreInfoDefault extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<ha-attributes
|
||||
.hass=${this.hass}
|
||||
.stateObj=${this.stateObj}
|
||||
></ha-attributes>`;
|
||||
return nothing;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../../../components/ha-attributes";
|
||||
import "../../../state-control/ha-state-control-toggle";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "../components/ha-more-info-state-header";
|
||||
@@ -33,10 +32,6 @@ class MoreInfoInputBoolean extends LitElement {
|
||||
.iconPathOff=${mdiPowerOff}
|
||||
></ha-state-control-toggle>
|
||||
</div>
|
||||
<ha-attributes
|
||||
.hass=${this.hass}
|
||||
.stateObj=${this.stateObj}
|
||||
></ha-attributes>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { stopPropagation } from "../../../common/dom/stop_propagation";
|
||||
import { supportsFeature } from "../../../common/entity/supports-feature";
|
||||
import "../../../components/ha-attribute-icon";
|
||||
import "../../../components/ha-attributes";
|
||||
import "../../../components/ha-control-select-menu";
|
||||
import "../../../components/ha-icon-button-group";
|
||||
import "../../../components/ha-icon-button-toggle";
|
||||
@@ -299,11 +298,6 @@ class MoreInfoLight extends LitElement {
|
||||
`
|
||||
: nothing}
|
||||
</ha-more-info-control-select-container>
|
||||
<ha-attributes
|
||||
.hass=${this.hass}
|
||||
.stateObj=${this.stateObj}
|
||||
extra-filters="brightness,color_temp,color_temp_kelvin,white_value,effect_list,effect,hs_color,rgb_color,rgbw_color,rgbww_color,xy_color,min_mireds,max_mireds,min_color_temp_kelvin,max_color_temp_kelvin,entity_id,supported_color_modes,color_mode"
|
||||
></ha-attributes>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { stateColorCss } from "../../../common/entity/state_color";
|
||||
import { supportsFeature } from "../../../common/entity/supports-feature";
|
||||
import "../../../components/ha-attributes";
|
||||
import "../../../components/ha-control-button";
|
||||
import "../../../components/ha-control-button-group";
|
||||
import "../../../components/ha-outlined-icon-button";
|
||||
@@ -151,11 +150,6 @@ class MoreInfoLock extends LitElement {
|
||||
</ha-control-button-group>
|
||||
`
|
||||
: nothing}
|
||||
<ha-attributes
|
||||
.hass=${this.hass}
|
||||
.stateObj=${this.stateObj}
|
||||
extra-filters="code_format"
|
||||
></ha-attributes>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -191,9 +185,6 @@ class MoreInfoLock extends LitElement {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
ha-control-button-group + ha-attributes:not([empty]) {
|
||||
margin-top: var(--ha-space-4);
|
||||
}
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/ha-attributes";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/map/ha-map";
|
||||
import { showZoneEditor } from "../../../data/zone";
|
||||
@@ -50,11 +49,6 @@ class MoreInfoPerson extends LitElement {
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
<ha-attributes
|
||||
.hass=${this.hass}
|
||||
.stateObj=${this.stateObj}
|
||||
extra-filters="id,user_id,editable,device_trackers"
|
||||
></ha-attributes>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,15 +2,12 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { stopPropagation } from "../../../common/dom/stop_propagation";
|
||||
import { supportsFeature } from "../../../common/entity/supports-feature";
|
||||
import "../../../components/ha-attributes";
|
||||
import type { RemoteEntity } from "../../../data/remote";
|
||||
import { REMOTE_SUPPORT_ACTIVITY } from "../../../data/remote";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "../../../components/ha-select";
|
||||
import "../../../components/ha-list-item";
|
||||
|
||||
const filterExtraAttributes = "activity_list,current_activity";
|
||||
|
||||
@customElement("more-info-remote")
|
||||
class MoreInfoRemote extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -51,12 +48,6 @@ class MoreInfoRemote extends LitElement {
|
||||
</ha-select>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
<ha-attributes
|
||||
.hass=${this.hass}
|
||||
.stateObj=${this.stateObj}
|
||||
.extraFilters=${filterExtraAttributes}
|
||||
></ha-attributes>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../../../components/ha-attributes";
|
||||
import "../../../state-control/ha-state-control-toggle";
|
||||
import "../../../components/ha-button";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
@@ -60,10 +59,6 @@ class MoreInfoSiren extends LitElement {
|
||||
</ha-button>`
|
||||
: nothing}
|
||||
</div>
|
||||
<ha-attributes
|
||||
.hass=${this.hass}
|
||||
.stateObj=${this.stateObj}
|
||||
></ha-attributes>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../../../components/ha-attributes";
|
||||
import "../../../state-control/ha-state-control-toggle";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "../components/ha-more-info-state-header";
|
||||
@@ -33,10 +32,6 @@ class MoreInfoSwitch extends LitElement {
|
||||
.iconPathOff=${mdiPowerOff}
|
||||
></ha-state-control-toggle>
|
||||
</div>
|
||||
<ha-attributes
|
||||
.hass=${this.hass}
|
||||
.stateObj=${this.stateObj}
|
||||
></ha-attributes>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../../../components/ha-attributes";
|
||||
import "../../../components/ha-button";
|
||||
import type { TimerEntity } from "../../../data/timer";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
@@ -63,11 +62,6 @@ class MoreInfoTimer extends LitElement {
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
<ha-attributes
|
||||
.hass=${this.hass}
|
||||
.stateObj=${this.stateObj}
|
||||
extra-filters="remaining,restore"
|
||||
></ha-attributes>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import { stopPropagation } from "../../../common/dom/stop_propagation";
|
||||
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
|
||||
import { supportsFeature } from "../../../common/entity/supports-feature";
|
||||
import "../../../components/entity/ha-battery-icon";
|
||||
import "../../../components/ha-attributes";
|
||||
import "../../../components/ha-icon";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-list-item";
|
||||
@@ -110,9 +109,6 @@ class MoreInfoVacuum extends LitElement {
|
||||
|
||||
const stateObj = this.stateObj;
|
||||
|
||||
const filterExtraAttributes =
|
||||
"fan_speed,fan_speed_list,status,battery_level,battery_icon";
|
||||
|
||||
return html`
|
||||
${stateObj.state !== UNAVAILABLE
|
||||
? html` <div class="flex-horizontal">
|
||||
@@ -208,12 +204,6 @@ class MoreInfoVacuum extends LitElement {
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
|
||||
<ha-attributes
|
||||
.hass=${this.hass}
|
||||
.stateObj=${this.stateObj}
|
||||
.extraFilters=${filterExtraAttributes}
|
||||
></ha-attributes>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { supportsFeature } from "../../../common/entity/supports-feature";
|
||||
import "../../../components/ha-attributes";
|
||||
import "../../../components/ha-icon-button-group";
|
||||
import "../../../components/ha-icon-button-toggle";
|
||||
import type { ValveEntity } from "../../../data/valve";
|
||||
@@ -155,11 +154,6 @@ class MoreInfoValve extends LitElement {
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<ha-attributes
|
||||
.hass=${this.hass}
|
||||
.stateObj=${this.stateObj}
|
||||
extra-filters="current_position,current_tilt_position"
|
||||
></ha-attributes>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
152
src/dialogs/more-info/ha-more-info-attributes.ts
Normal file
152
src/dialogs/more-info/ha-more-info-attributes.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { computeAttributeNameDisplay } from "../../common/entity/compute_attribute_display";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
import "../../components/ha-attribute-value";
|
||||
import "../../components/ha-card";
|
||||
import {
|
||||
STATE_ATTRIBUTES,
|
||||
STATE_ATTRIBUTES_DOMAIN_CLASS,
|
||||
} from "../../data/entity/entity_attributes";
|
||||
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
interface AttributesViewParams {
|
||||
entityId: string;
|
||||
}
|
||||
|
||||
@customElement("ha-more-info-attributes")
|
||||
class HaMoreInfoAttributes extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public entry?: ExtEntityRegistryEntry | null;
|
||||
|
||||
@property({ attribute: false }) public params?: AttributesViewParams;
|
||||
|
||||
@state() private _stateObj?: HassEntity;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.has("params") || changedProps.has("hass")) {
|
||||
if (this.params?.entityId && this.hass) {
|
||||
this._stateObj = this.hass.states[this.params.entityId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _computeDisplayAttributes(stateObj: HassEntity): string[] {
|
||||
const domain = computeStateDomain(stateObj);
|
||||
const filtersArray = STATE_ATTRIBUTES.concat(
|
||||
(STATE_ATTRIBUTES_DOMAIN_CLASS[domain]?.[
|
||||
stateObj.attributes?.device_class
|
||||
] || []) as string[]
|
||||
);
|
||||
return Object.keys(stateObj.attributes).filter(
|
||||
(key) => filtersArray.indexOf(key) === -1
|
||||
);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.params || !this._stateObj) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const attributes = this._computeDisplayAttributes(this._stateObj);
|
||||
|
||||
return html`
|
||||
<div class="content">
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
${attributes.map(
|
||||
(attribute) => html`
|
||||
<div class="data-entry">
|
||||
<div class="key">
|
||||
${computeAttributeNameDisplay(
|
||||
this.hass.localize,
|
||||
this._stateObj!,
|
||||
this.hass.entities,
|
||||
attribute
|
||||
)}
|
||||
</div>
|
||||
<div class="value">
|
||||
<ha-attribute-value
|
||||
.hass=${this.hass}
|
||||
.attribute=${attribute}
|
||||
.stateObj=${this._stateObj}
|
||||
></ha-attribute-value>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</ha-card>
|
||||
${this._stateObj.attributes.attribution
|
||||
? html`
|
||||
<div class="attribution">
|
||||
${this._stateObj.attributes.attribution}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: var(--ha-space-6);
|
||||
padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-6));
|
||||
}
|
||||
|
||||
ha-card {
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: var(--ha-space-2) var(--ha-space-4);
|
||||
}
|
||||
|
||||
.data-entry {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: var(--ha-space-2) 0;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.data-entry:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.data-entry .value {
|
||||
max-width: 60%;
|
||||
overflow-wrap: break-word;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.key {
|
||||
flex-grow: 1;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.attribution {
|
||||
color: var(--secondary-text-color);
|
||||
text-align: center;
|
||||
margin-top: var(--ha-space-4);
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-more-info-attributes": HaMoreInfoAttributes;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
mdiCogOutline,
|
||||
mdiDevices,
|
||||
mdiDotsVertical,
|
||||
mdiFormatListBulletedSquare,
|
||||
mdiInformationOutline,
|
||||
mdiPencil,
|
||||
mdiPencilOff,
|
||||
@@ -24,6 +25,7 @@ import { stopPropagation } from "../../common/dom/stop_propagation";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import { computeDeviceName } from "../../common/entity/compute_device_name";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
import {
|
||||
computeEntityEntryName,
|
||||
computeEntityName,
|
||||
@@ -43,6 +45,10 @@ import type { HaDropdownItem } from "../../components/ha-dropdown-item";
|
||||
import "../../components/ha-icon-button";
|
||||
import "../../components/ha-icon-button-prev";
|
||||
import "../../components/ha-related-items";
|
||||
import {
|
||||
STATE_ATTRIBUTES,
|
||||
STATE_ATTRIBUTES_DOMAIN_CLASS,
|
||||
} from "../../data/entity/entity_attributes";
|
||||
import type {
|
||||
EntityRegistryEntry,
|
||||
ExtEntityRegistryEntry,
|
||||
@@ -89,6 +95,7 @@ interface ChildView {
|
||||
viewTitle?: string;
|
||||
viewImport?: () => Promise<unknown>;
|
||||
viewParams?: any;
|
||||
keepHeader?: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -331,9 +338,41 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
|
||||
case "info":
|
||||
this._resetInitialView();
|
||||
break;
|
||||
case "attributes":
|
||||
this._showAttributes();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private _showAttributes(): void {
|
||||
import("./ha-more-info-attributes");
|
||||
this._childView = {
|
||||
viewTag: "ha-more-info-attributes",
|
||||
viewParams: { entityId: this._entityId },
|
||||
keepHeader: true,
|
||||
};
|
||||
}
|
||||
|
||||
private _hasDisplayableAttributes(): boolean {
|
||||
if (!this._entityId) {
|
||||
return false;
|
||||
}
|
||||
const stateObj = this.hass.states[this._entityId];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
}
|
||||
const domain = computeStateDomain(stateObj);
|
||||
const filtersArray = STATE_ATTRIBUTES.concat(
|
||||
(STATE_ATTRIBUTES_DOMAIN_CLASS[domain]?.[
|
||||
stateObj.attributes?.device_class
|
||||
] || []) as string[]
|
||||
);
|
||||
const displayAttributes = Object.keys(stateObj.attributes).filter(
|
||||
(key) => filtersArray.indexOf(key) === -1
|
||||
);
|
||||
return displayAttributes.length > 0;
|
||||
}
|
||||
|
||||
private _goToAddEntityTo(ev) {
|
||||
// Only check for request-selected events (from menu items), not regular clicks (from icon button)
|
||||
if (ev.type === "request-selected" && !shouldHandleRequestSelectedEvent(ev))
|
||||
@@ -366,12 +405,17 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
|
||||
const deviceType =
|
||||
(deviceId && this.hass.devices[deviceId].entry_type) || "device";
|
||||
|
||||
const isDefaultView = this._currView === DEFAULT_VIEW && !this._childView;
|
||||
const isDefaultView =
|
||||
this._currView === DEFAULT_VIEW &&
|
||||
(!this._childView || this._childView.keepHeader);
|
||||
const isSpecificInitialView =
|
||||
this._initialView !== DEFAULT_VIEW && !this._childView;
|
||||
this._initialView !== DEFAULT_VIEW &&
|
||||
(!this._childView || this._childView.keepHeader);
|
||||
const showCloseIcon =
|
||||
(isDefaultView && this._parentEntityIds.length === 0) ||
|
||||
isSpecificInitialView;
|
||||
(isDefaultView &&
|
||||
this._parentEntityIds.length === 0 &&
|
||||
!this._childView) ||
|
||||
(isSpecificInitialView && !this._childView);
|
||||
|
||||
const context = stateObj
|
||||
? getEntityContext(
|
||||
@@ -405,7 +449,12 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
|
||||
const breadcrumb = [areaName, deviceName, entityName].filter(
|
||||
(v): v is string => Boolean(v)
|
||||
);
|
||||
const title = this._childView?.viewTitle || breadcrumb.pop() || entityId;
|
||||
const title =
|
||||
(this._childView && !this._childView.keepHeader
|
||||
? this._childView.viewTitle
|
||||
: undefined) ||
|
||||
breadcrumb.pop() ||
|
||||
entityId;
|
||||
|
||||
const isRTL = computeRTL(this.hass);
|
||||
|
||||
@@ -554,6 +603,19 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
|
||||
"ui.dialogs.more_info_control.related"
|
||||
)}
|
||||
</ha-dropdown-item>
|
||||
${this._hasDisplayableAttributes()
|
||||
? html`
|
||||
<ha-dropdown-item value="attributes">
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiFormatListBulletedSquare}
|
||||
></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.attributes"
|
||||
)}
|
||||
</ha-dropdown-item>
|
||||
`
|
||||
: nothing}
|
||||
${this._shouldShowAddEntityTo()
|
||||
? html`
|
||||
<ha-dropdown-item value="add_to">
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { mdiDevices } from "@mdi/js";
|
||||
import Fuse from "fuse.js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { NavigationFilterOptions } from "../../common/config/filter_navigation_pages";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { navigate } from "../../common/navigate";
|
||||
@@ -15,6 +17,7 @@ import "../../components/ha-icon";
|
||||
import "../../components/ha-picker-combo-box";
|
||||
import type {
|
||||
HaPickerComboBox,
|
||||
PickerComboBoxIndexSelectedDetail,
|
||||
PickerComboBoxItem,
|
||||
} from "../../components/ha-picker-combo-box";
|
||||
import "../../components/ha-spinner";
|
||||
@@ -48,8 +51,10 @@ import {
|
||||
multiTermSortedSearch,
|
||||
type FuseWeightedKey,
|
||||
} from "../../resources/fuseMultiTerm";
|
||||
import { buttonLinkStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { isIosApp } from "../../util/is_ios";
|
||||
import { isMac } from "../../util/is_mac";
|
||||
import { showConfirmationDialog } from "../generic/show-dialog-box";
|
||||
import { showShortcutsDialog } from "../shortcuts/show-shortcuts-dialog";
|
||||
import type { QuickBarParams, QuickBarSection } from "./show-dialog-quick-bar";
|
||||
@@ -64,7 +69,7 @@ export class QuickBar extends LitElement {
|
||||
|
||||
@state() private _loading = true;
|
||||
|
||||
@state() private _hint?: string;
|
||||
@state() private _showHint = false;
|
||||
|
||||
@state() private _selectedSection?: QuickBarSection;
|
||||
|
||||
@@ -80,8 +85,12 @@ export class QuickBar extends LitElement {
|
||||
|
||||
private _addons?: HassioAddonInfo[];
|
||||
|
||||
private _navigationFilterOptions: NavigationFilterOptions = {};
|
||||
|
||||
private _translationsLoaded = false;
|
||||
|
||||
private _itemSelected = false;
|
||||
|
||||
// #region lifecycle
|
||||
public async showDialog(params: QuickBarParams) {
|
||||
if (!this._translationsLoaded) {
|
||||
@@ -90,7 +99,7 @@ export class QuickBar extends LitElement {
|
||||
}
|
||||
this._initialize();
|
||||
this._selectedSection = params.mode;
|
||||
this._hint = params.hint;
|
||||
this._showHint = params.showHint ?? false;
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
@@ -104,6 +113,12 @@ export class QuickBar extends LitElement {
|
||||
this._configEntryLookup = Object.fromEntries(
|
||||
configEntries.map((entry) => [entry.entry_id, entry])
|
||||
);
|
||||
// Derive Bluetooth config entries status for navigation filtering
|
||||
this._navigationFilterOptions = {
|
||||
hasBluetoothConfigEntries: configEntries.some(
|
||||
(entry) => entry.domain === "bluetooth"
|
||||
),
|
||||
};
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error fetching config entries for quick bar", err);
|
||||
@@ -152,15 +167,28 @@ export class QuickBar extends LitElement {
|
||||
this._selectedSection = undefined;
|
||||
this._opened = false;
|
||||
this._open = false;
|
||||
this._itemSelected = false;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
};
|
||||
|
||||
// fallback in case the closed event is not fired
|
||||
private _dialogCloseStarted = () => {
|
||||
setTimeout(
|
||||
() => {
|
||||
if (this._opened) {
|
||||
this._dialogClosed();
|
||||
}
|
||||
},
|
||||
350 // close animation timeout is 300ms
|
||||
);
|
||||
};
|
||||
|
||||
// #endregion lifecycle
|
||||
|
||||
// #region render
|
||||
|
||||
protected render() {
|
||||
if (!this._open) {
|
||||
if (!this._open && !this._opened) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
@@ -210,6 +238,7 @@ export class QuickBar extends LitElement {
|
||||
hideActions
|
||||
@wa-show=${this._showTriggered}
|
||||
@wa-after-show=${this._dialogOpened}
|
||||
@wa-hide=${this._dialogCloseStarted}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
${!this._loading && this._opened
|
||||
@@ -230,9 +259,17 @@ export class QuickBar extends LitElement {
|
||||
clearable
|
||||
></ha-picker-combo-box>`
|
||||
: nothing}
|
||||
${this._hint
|
||||
${this._showHint
|
||||
? html`<ha-tip slot="footer" .hass=${this.hass}
|
||||
>${this._hint}</ha-tip
|
||||
>${this.hass.localize("ui.tips.key_shortcut_quick_search", {
|
||||
keyboard_shortcut: html`<button
|
||||
class="link"
|
||||
@click=${this._openShortcutDialog}
|
||||
>
|
||||
${this.hass.localize("ui.tips.keyboard_shortcut")}
|
||||
</button>`,
|
||||
modifier: isMac ? "⌘" : "Ctrl",
|
||||
})}</ha-tip
|
||||
>`
|
||||
: nothing}
|
||||
</ha-adaptive-dialog>
|
||||
@@ -281,6 +318,9 @@ export class QuickBar extends LitElement {
|
||||
slot="start"
|
||||
alt=${item.primary ?? "Unknown"}
|
||||
.src=${item.image}
|
||||
style=${"iconColor" in item && item.iconColor
|
||||
? `background-color: ${item.iconColor}; padding: 4px; border-radius: var(--ha-border-radius-circle); width: 24px; height: 24px`
|
||||
: ""}
|
||||
/>
|
||||
`
|
||||
: item.icon
|
||||
@@ -393,7 +433,8 @@ export class QuickBar extends LitElement {
|
||||
if (!section || section === "navigate") {
|
||||
let navigateItems = this._generateNavigationCommandsMemoized(
|
||||
this.hass,
|
||||
this._addons
|
||||
this._addons,
|
||||
this._navigationFilterOptions
|
||||
).sort(this._sortBySortingLabel);
|
||||
|
||||
if (filter) {
|
||||
@@ -559,7 +600,11 @@ export class QuickBar extends LitElement {
|
||||
);
|
||||
|
||||
private _generateNavigationCommandsMemoized = memoizeOne(
|
||||
generateNavigationCommands
|
||||
(
|
||||
hass: HomeAssistant,
|
||||
apps: HassioAddonInfo[] | undefined,
|
||||
filterOptions: NavigationFilterOptions
|
||||
) => generateNavigationCommands(hass, apps, filterOptions)
|
||||
);
|
||||
|
||||
private _generateActionCommandsMemoized = memoizeOne(generateActionCommands);
|
||||
@@ -613,13 +658,29 @@ export class QuickBar extends LitElement {
|
||||
|
||||
// #region interaction
|
||||
|
||||
private async _handleItemSelected(ev: CustomEvent<{ index: number }>) {
|
||||
if (this._comboBox && this._comboBox.virtualizerElement) {
|
||||
const index = ev.detail.index;
|
||||
private _navigate(path: string, newTab = false) {
|
||||
if (newTab) {
|
||||
window.open(path, "_blank", "noreferrer");
|
||||
} else {
|
||||
navigate(path);
|
||||
}
|
||||
}
|
||||
|
||||
private async _handleItemSelected(
|
||||
ev: CustomEvent<PickerComboBoxIndexSelectedDetail>
|
||||
) {
|
||||
if (
|
||||
!this._itemSelected &&
|
||||
this._comboBox &&
|
||||
this._comboBox.virtualizerElement
|
||||
) {
|
||||
const { index, newTab } = ev.detail;
|
||||
const item = this._comboBox.virtualizerElement.items[
|
||||
index
|
||||
] as PickerComboBoxItem;
|
||||
|
||||
this._itemSelected = true;
|
||||
|
||||
// entity selected
|
||||
if (item && "stateObj" in item) {
|
||||
this.closeDialog();
|
||||
@@ -631,15 +692,17 @@ export class QuickBar extends LitElement {
|
||||
|
||||
// device selected
|
||||
if (item && item.id.startsWith(`device${SEPARATOR}`)) {
|
||||
const path = `/config/devices/device/${item.id.split(SEPARATOR)[1]}`;
|
||||
this.closeDialog();
|
||||
navigate(`/config/devices/device/${item.id.split(SEPARATOR)[1]}`);
|
||||
this._navigate(path, newTab);
|
||||
return;
|
||||
}
|
||||
|
||||
// area selected
|
||||
if (item && item.id.startsWith(`area${SEPARATOR}`)) {
|
||||
const path = `/config/areas/area/${item.id.split(SEPARATOR)[1]}`;
|
||||
this.closeDialog();
|
||||
navigate(`/config/areas/area/${item.id.split(SEPARATOR)[1]}`);
|
||||
this._navigate(path, newTab);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -693,53 +756,65 @@ export class QuickBar extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
navigate((item as NavigationComboBoxItem).path);
|
||||
const path = (item as NavigationComboBoxItem).path;
|
||||
this._navigate(path, newTab);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _openShortcutDialog(ev: Event): void {
|
||||
ev.preventDefault();
|
||||
showShortcutsDialog(this);
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
// #endregion interaction
|
||||
|
||||
// #region styles
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
--dialog-surface-margin-top: var(--ha-space-10);
|
||||
--ha-dialog-min-height: 620px;
|
||||
--ha-bottom-sheet-height: calc(
|
||||
100vh - max(var(--safe-area-inset-top), 48px)
|
||||
);
|
||||
--ha-bottom-sheet-height: calc(
|
||||
100dvh - max(var(--safe-area-inset-top), 48px)
|
||||
);
|
||||
--ha-bottom-sheet-max-height: calc(
|
||||
100vh - max(var(--safe-area-inset-top), 48px)
|
||||
);
|
||||
--ha-bottom-sheet-max-height: calc(
|
||||
100dvh - max(var(--safe-area-inset-top), 48px)
|
||||
);
|
||||
--dialog-content-padding: 0;
|
||||
--safe-area-inset-bottom: 0px;
|
||||
}
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
buttonLinkStyle,
|
||||
css`
|
||||
:host {
|
||||
--dialog-surface-margin-top: var(--ha-space-10);
|
||||
--ha-dialog-min-height: 620px;
|
||||
--ha-bottom-sheet-height: calc(
|
||||
100vh - max(var(--safe-area-inset-top), 48px)
|
||||
);
|
||||
--ha-bottom-sheet-height: calc(
|
||||
100dvh - max(var(--safe-area-inset-top), 48px)
|
||||
);
|
||||
--ha-bottom-sheet-max-height: calc(
|
||||
100vh - max(var(--safe-area-inset-top), 48px)
|
||||
);
|
||||
--ha-bottom-sheet-max-height: calc(
|
||||
100dvh - max(var(--safe-area-inset-top), 48px)
|
||||
);
|
||||
--dialog-content-padding: 0;
|
||||
--safe-area-inset-bottom: 0px;
|
||||
}
|
||||
|
||||
ha-tip {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--secondary-text-color);
|
||||
gap: var(--ha-space-1);
|
||||
}
|
||||
ha-tip {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--secondary-text-color);
|
||||
gap: var(--ha-space-1);
|
||||
}
|
||||
|
||||
ha-tip a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
ha-tip a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
@media all and (max-width: 450px), all and (max-height: 690px) {
|
||||
ha-tip {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@media all and (max-width: 450px), all and (max-height: 690px) {
|
||||
ha-tip {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
// #endregion styles
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { closeDialog } from "../make-dialog-manager";
|
||||
|
||||
export type QuickBarSection =
|
||||
| "entity"
|
||||
@@ -10,7 +11,7 @@ export type QuickBarSection =
|
||||
export interface QuickBarParams {
|
||||
entityFilter?: string;
|
||||
mode?: QuickBarSection;
|
||||
hint?: string;
|
||||
showHint?: boolean;
|
||||
}
|
||||
|
||||
export const loadQuickBar = () => import("./ha-quick-bar");
|
||||
@@ -26,3 +27,7 @@ export const showQuickBar = (
|
||||
addHistory: false,
|
||||
});
|
||||
};
|
||||
|
||||
export const closeQuickBar = (): void => {
|
||||
closeDialog("ha-quick-bar");
|
||||
};
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
getPanelIcon,
|
||||
getPanelIconPath,
|
||||
getPanelTitle,
|
||||
SHOW_AFTER_SPACER_PANELS,
|
||||
} from "../../data/panel";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { showConfirmationDialog } from "../generic/show-dialog-box";
|
||||
@@ -144,7 +143,6 @@ class DialogEditSidebar extends LitElement {
|
||||
`${defaultPanel === panel.url_path ? " (default)" : ""}`,
|
||||
icon: getPanelIcon(panel),
|
||||
iconPath: getPanelIconPath(panel),
|
||||
disableSorting: SHOW_AFTER_SPACER_PANELS.includes(panel.url_path),
|
||||
disableHiding: panel.url_path === defaultPanel,
|
||||
}));
|
||||
|
||||
|
||||
@@ -8,8 +8,10 @@ import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { formatLanguageCode } from "../../common/language/format_language";
|
||||
import "../../components/chips/ha-assist-chip";
|
||||
import "../../components/ha-dialog";
|
||||
import "../../components/ha-dropdown";
|
||||
import "../../components/ha-dropdown-item";
|
||||
import type { HaDropdownItem } from "../../components/ha-dropdown-item";
|
||||
import { getLanguageOptions } from "../../components/ha-language-picker";
|
||||
import "../../components/ha-md-button-menu";
|
||||
import type { AssistSatelliteConfiguration } from "../../data/assist_satellite";
|
||||
import { fetchAssistSatelliteConfiguration } from "../../data/assist_satellite";
|
||||
import { getLanguageScores } from "../../data/conversation";
|
||||
@@ -169,9 +171,9 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
|
||||
>`
|
||||
: this._step === STEP.PIPELINE
|
||||
? this._language
|
||||
? html`<ha-md-button-menu
|
||||
? html`<ha-dropdown
|
||||
slot="actionItems"
|
||||
positioning="fixed"
|
||||
@wa-select=${this._handlePickLanguage}
|
||||
>
|
||||
<ha-assist-chip
|
||||
.label=${formatLanguageCode(
|
||||
@@ -192,16 +194,14 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
|
||||
this.hass.locale
|
||||
).map(
|
||||
(lang) =>
|
||||
html`<ha-md-menu-item
|
||||
html`<ha-dropdown-item
|
||||
.value=${lang.id}
|
||||
@click=${this._handlePickLanguage}
|
||||
@keydown=${this._handlePickLanguage}
|
||||
.selected=${this._language === lang.id}
|
||||
class=${this._language === lang.id ? "selected" : ""}
|
||||
>
|
||||
${lang.primary}
|
||||
</ha-md-menu-item>`
|
||||
</ha-dropdown-item>`
|
||||
)}
|
||||
</ha-md-button-menu>`
|
||||
</ha-dropdown>`
|
||||
: nothing
|
||||
: nothing}
|
||||
</ha-dialog-header>
|
||||
@@ -328,10 +328,8 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _handlePickLanguage(ev) {
|
||||
if (ev.type === "keydown" && ev.key !== "Enter" && ev.key !== " ") return;
|
||||
|
||||
this._language = ev.target.value;
|
||||
private _handlePickLanguage(ev: CustomEvent<{ item: HaDropdownItem }>) {
|
||||
this._language = ev.detail.item.value;
|
||||
}
|
||||
|
||||
private _languageChanged(ev: CustomEvent) {
|
||||
@@ -401,7 +399,7 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
|
||||
margin: 24px;
|
||||
display: block;
|
||||
}
|
||||
ha-md-button-menu {
|
||||
ha-dropdown {
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -409,6 +407,13 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
|
||||
margin-inline-end: 12px;
|
||||
margin-inline-start: initial;
|
||||
}
|
||||
ha-dropdown-item.selected {
|
||||
border: 1px solid var(--primary-color);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
color: var(--primary-color);
|
||||
background-color: var(--ha-color-fill-primary-quiet-resting);
|
||||
--icon-primary-color: var(--primary-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -8,6 +8,14 @@ export const demoPanels: Panels = {
|
||||
config: { mode: "storage" },
|
||||
url_path: "lovelace",
|
||||
},
|
||||
home: {
|
||||
component_name: "home",
|
||||
icon: "mdi:home",
|
||||
title: "home",
|
||||
default_visible: false,
|
||||
config: null,
|
||||
url_path: "home",
|
||||
},
|
||||
"dev-state": {
|
||||
component_name: "dev-state",
|
||||
icon: null,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeFormatFunctions } from "../common/translations/entity-state";
|
||||
import { computeLocalize } from "../common/translations/localize";
|
||||
import type { EntityRegistryDisplayEntry } from "../data/entity/entity_registry";
|
||||
import { DEFAULT_PANEL } from "../data/panel";
|
||||
import {
|
||||
DateFormat,
|
||||
FirstWeekday,
|
||||
@@ -268,7 +267,9 @@ export const provideHass = (
|
||||
name: "Demo User",
|
||||
},
|
||||
panelUrl: "lovelace",
|
||||
defaultPanel: DEFAULT_PANEL,
|
||||
systemData: {
|
||||
default_panel: "lovelace",
|
||||
},
|
||||
language: localLanguage,
|
||||
selectedLanguage: localLanguage,
|
||||
locale: {
|
||||
@@ -367,6 +368,7 @@ export const provideHass = (
|
||||
areas: {},
|
||||
devices: {},
|
||||
entities: {},
|
||||
floors: {},
|
||||
formatEntityState: (stateObj, state) =>
|
||||
(state !== null ? state : stateObj.state) ?? "",
|
||||
formatEntityAttributeName: (_stateObj, attribute) => attribute,
|
||||
|
||||
@@ -57,6 +57,8 @@ export class HassRouterPage extends ReactiveElement {
|
||||
|
||||
private _initialLoadDone = false;
|
||||
|
||||
private _showLoadingScreenTimeout?: number;
|
||||
|
||||
private _computeTail = memoizeOne(computeRouteTail);
|
||||
|
||||
protected createRenderRoot() {
|
||||
@@ -143,7 +145,11 @@ export class HassRouterPage extends ReactiveElement {
|
||||
? routeOptions.load()
|
||||
: Promise.resolve();
|
||||
|
||||
let showLoadingScreenTimeout: undefined | number;
|
||||
// Clear any existing loading screen timeout from previous navigation
|
||||
if (this._showLoadingScreenTimeout) {
|
||||
clearTimeout(this._showLoadingScreenTimeout);
|
||||
this._showLoadingScreenTimeout = undefined;
|
||||
}
|
||||
|
||||
// Check when loading the page source failed.
|
||||
loadProm.catch((err) => {
|
||||
@@ -160,8 +166,9 @@ export class HassRouterPage extends ReactiveElement {
|
||||
this.removeChild(this.lastChild!);
|
||||
}
|
||||
|
||||
if (showLoadingScreenTimeout) {
|
||||
clearTimeout(showLoadingScreenTimeout);
|
||||
if (this._showLoadingScreenTimeout) {
|
||||
clearTimeout(this._showLoadingScreenTimeout);
|
||||
this._showLoadingScreenTimeout = undefined;
|
||||
}
|
||||
|
||||
// Show error screen
|
||||
@@ -181,7 +188,7 @@ export class HassRouterPage extends ReactiveElement {
|
||||
// That way we won't have a double fast flash on fast connections.
|
||||
let created = false;
|
||||
|
||||
showLoadingScreenTimeout = window.setTimeout(() => {
|
||||
this._showLoadingScreenTimeout = window.setTimeout(() => {
|
||||
if (created || this._currentPage !== newPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -27,7 +28,7 @@ class HassSubpage extends LitElement {
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<div class="toolbar">
|
||||
<div class="toolbar ${classMap({ narrow: this.narrow })}">
|
||||
<div class="toolbar-content">
|
||||
${this.mainPage || history.state?.root
|
||||
? html`
|
||||
@@ -132,7 +133,7 @@ class HassSubpage extends LitElement {
|
||||
}
|
||||
|
||||
.main-title {
|
||||
margin: var(--margin-title);
|
||||
margin-inline-start: var(--ha-space-6);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
min-width: 0;
|
||||
flex-grow: 1;
|
||||
@@ -143,6 +144,9 @@ class HassSubpage extends LitElement {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.narrow .main-title {
|
||||
margin-inline-start: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
|
||||
@@ -131,7 +131,7 @@ class HassTabsSubpage extends LitElement {
|
||||
);
|
||||
const showTabs = tabs.length > 1;
|
||||
return html`
|
||||
<div class="toolbar">
|
||||
<div class="toolbar ${classMap({ narrow: this.narrow })}">
|
||||
<slot name="toolbar">
|
||||
<div class="toolbar-content">
|
||||
${this.mainPage || (!this.backPath && history.state?.root)
|
||||
@@ -320,7 +320,10 @@ class HassTabsSubpage extends LitElement {
|
||||
max-height: var(--header-height);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
color: var(--sidebar-text-color);
|
||||
margin: var(--main-title-margin, var(--margin-title));
|
||||
margin-inline-start: var(--main-title-margin, var(--ha-space-6));
|
||||
}
|
||||
.narrow .main-title {
|
||||
margin-inline-start: var(--main-title-margin, var(--ha-space-2));
|
||||
}
|
||||
|
||||
.content {
|
||||
|
||||
@@ -106,6 +106,10 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
|
||||
// Navigation
|
||||
const updateRoute = (path = curPath()) => {
|
||||
// Developer tools panel was moved to config in 2026.2
|
||||
if (path.startsWith("/developer-tools")) {
|
||||
path = path.replace("/developer-tools", "/config/developer-tools");
|
||||
}
|
||||
if (this._route && path === this._route.path) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -14,15 +14,13 @@ import { removeLaunchScreen } from "../util/launch-screen";
|
||||
import type { RouteOptions, RouterOptions } from "./hass-router-page";
|
||||
import { HassRouterPage } from "./hass-router-page";
|
||||
|
||||
const CACHE_URL_PATHS = ["lovelace", "developer-tools"];
|
||||
const CACHE_URL_PATHS = ["lovelace", "home", "config"];
|
||||
const COMPONENTS = {
|
||||
app: () => import("../panels/app/ha-panel-app"),
|
||||
energy: () => import("../panels/energy/ha-panel-energy"),
|
||||
calendar: () => import("../panels/calendar/ha-panel-calendar"),
|
||||
config: () => import("../panels/config/ha-panel-config"),
|
||||
custom: () => import("../panels/custom/ha-panel-custom"),
|
||||
"developer-tools": () =>
|
||||
import("../panels/developer-tools/ha-panel-developer-tools"),
|
||||
lovelace: () => import("../panels/lovelace/ha-panel-lovelace"),
|
||||
history: () => import("../panels/history/ha-panel-history"),
|
||||
iframe: () => import("../panels/iframe/ha-panel-iframe"),
|
||||
|
||||
@@ -25,6 +25,7 @@ import { subscribeOne } from "../common/util/subscribe-one";
|
||||
import "../components/ha-card";
|
||||
import type { AuthUrlSearchParams } from "../data/auth";
|
||||
import { hassUrl } from "../data/auth";
|
||||
import { saveFrontendSystemData } from "../data/frontend";
|
||||
import type { OnboardingResponses, OnboardingStep } from "../data/onboarding";
|
||||
import {
|
||||
fetchInstallationType,
|
||||
@@ -406,6 +407,11 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
|
||||
),
|
||||
};
|
||||
|
||||
await saveFrontendSystemData(this.hass!.connection, "core", {
|
||||
onboarded_version: this.hass!.config.version,
|
||||
onboarded_date: new Date().toISOString(),
|
||||
});
|
||||
|
||||
let result: OnboardingResponses["integration"];
|
||||
|
||||
try {
|
||||
|
||||
@@ -68,7 +68,7 @@ class OnboardingCoreConfig extends LitElement {
|
||||
|
||||
<ha-country-picker
|
||||
class="flex"
|
||||
.language=${this.hass.locale.language}
|
||||
.hass=${this.hass}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.core.section.core.core_config.country"
|
||||
) || "Country"}
|
||||
@@ -76,8 +76,7 @@ class OnboardingCoreConfig extends LitElement {
|
||||
.disabled=${this._working}
|
||||
.value=${this._countryValue}
|
||||
@value-changed=${this._handleCountryChanged}
|
||||
>
|
||||
</ha-country-picker>
|
||||
></ha-country-picker>
|
||||
|
||||
<div class="footer">
|
||||
<ha-button @click=${this._save} .disabled=${this._working}>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { navigate } from "../../common/navigate";
|
||||
@@ -116,7 +117,7 @@ class HaPanelApp extends LitElement {
|
||||
${!this._kioskMode &&
|
||||
(this.narrow || this.hass.dockedSidebar === "always_hidden")
|
||||
? html`
|
||||
<div class="header">
|
||||
<div class="header ${classMap({ narrow: this.narrow })}">
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize("ui.sidebar.sidebar_toggle")}
|
||||
.path=${mdiMenu}
|
||||
@@ -452,10 +453,13 @@ class HaPanelApp extends LitElement {
|
||||
}
|
||||
|
||||
.main-title {
|
||||
margin: var(--margin-title);
|
||||
margin-inline-start: var(--ha-space-6);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
flex-grow: 1;
|
||||
}
|
||||
.narrow .main-title {
|
||||
margin-inline-start: var(--ha-space-2);
|
||||
}
|
||||
|
||||
ha-icon-button {
|
||||
pointer-events: auto;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import "@home-assistant/webawesome/dist/components/divider/divider";
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import { mdiChevronDown, mdiPlus, mdiRefresh } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { storage } from "../../common/decorators/storage";
|
||||
@@ -16,19 +17,23 @@ import "../../components/ha-icon-button";
|
||||
import "../../components/ha-list";
|
||||
import "../../components/ha-list-item";
|
||||
import "../../components/ha-menu-button";
|
||||
import "../../components/ha-spinner";
|
||||
import "../../components/ha-state-icon";
|
||||
import "../../components/ha-svg-icon";
|
||||
import "../../components/ha-two-pane-top-app-bar-fixed";
|
||||
import type { Calendar, CalendarEvent } from "../../data/calendar";
|
||||
import { fetchCalendarEvents, getCalendars } from "../../data/calendar";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import { subscribeEntityRegistry } from "../../data/entity/entity_registry";
|
||||
import { fetchIntegrationManifest } from "../../data/integration";
|
||||
import { showConfigFlowDialog } from "../../dialogs/config-flow/show-dialog-config-flow";
|
||||
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { CalendarViewChanged, HomeAssistant } from "../../types";
|
||||
import "./ha-full-calendar";
|
||||
|
||||
@customElement("ha-panel-calendar")
|
||||
class PanelCalendar extends LitElement {
|
||||
class PanelCalendar extends SubscribeMixin(LitElement) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
@@ -41,6 +46,8 @@ class PanelCalendar extends LitElement {
|
||||
|
||||
@state() private _error?: string = undefined;
|
||||
|
||||
@state() private _entityRegistry?: EntityRegistryEntry[];
|
||||
|
||||
@state()
|
||||
@storage({
|
||||
key: "deSelectedCalendars",
|
||||
@@ -77,14 +84,46 @@ class PanelCalendar extends LitElement {
|
||||
this.mobile = ev.matches;
|
||||
};
|
||||
|
||||
public willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (!this.hasUpdated) {
|
||||
this._calendars = getCalendars(this.hass, this);
|
||||
}
|
||||
public hassSubscribe(): UnsubscribeFunc[] {
|
||||
return [
|
||||
subscribeEntityRegistry(this.hass.connection!, (entities) => {
|
||||
this._entityRegistry = entities;
|
||||
// Refresh calendars when entity registry updates (includes color changes)
|
||||
this._calendars = getCalendars(this.hass, this, this._entityRegistry);
|
||||
// Refetch events if view dates are available (handles both initial load and color updates)
|
||||
if (this._start && this._end) {
|
||||
this._fetchEvents(
|
||||
this._start,
|
||||
this._end,
|
||||
this._selectedCalendars
|
||||
).then((result) => {
|
||||
this._events = result.events;
|
||||
this._handleErrors(result.errors);
|
||||
});
|
||||
}
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
if (!this._entityRegistry) {
|
||||
return html`
|
||||
<ha-two-pane-top-app-bar-fixed .narrow=${this.narrow}>
|
||||
<ha-menu-button
|
||||
slot="navigationIcon"
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
></ha-menu-button>
|
||||
<div slot="title">
|
||||
${this.hass.localize("ui.components.calendar.my_calendars")}
|
||||
</div>
|
||||
<div class="loading">
|
||||
<ha-spinner></ha-spinner>
|
||||
</div>
|
||||
</ha-two-pane-top-app-bar-fixed>
|
||||
`;
|
||||
}
|
||||
|
||||
const calendarItems = this._calendars.map(
|
||||
(selCal) => html`
|
||||
<ha-dropdown-item
|
||||
@@ -220,7 +259,7 @@ class PanelCalendar extends LitElement {
|
||||
manifest: await fetchIntegrationManifest(this.hass, "local_calendar"),
|
||||
dialogClosedCallback: ({ flowFinished }) => {
|
||||
if (flowFinished) {
|
||||
this._calendars = getCalendars(this.hass, this);
|
||||
this._calendars = getCalendars(this.hass, this, this._entityRegistry);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -301,6 +340,13 @@ class PanelCalendar extends LitElement {
|
||||
:host([mobile]) {
|
||||
padding-left: unset;
|
||||
}
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--ha-space-8);
|
||||
min-height: 400px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { goBack } from "../../common/navigate";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
@@ -12,8 +13,8 @@ import type { HomeAssistant } from "../../types";
|
||||
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
|
||||
import type { Lovelace } from "../lovelace/types";
|
||||
import "../lovelace/views/hui-view";
|
||||
import "../lovelace/views/hui-view-container";
|
||||
import "../lovelace/views/hui-view-background";
|
||||
import "../lovelace/views/hui-view-container";
|
||||
|
||||
const CLIMATE_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
|
||||
strategy: {
|
||||
@@ -95,7 +96,7 @@ class PanelClimate extends LitElement {
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div class="header">
|
||||
<div class="header ${classMap({ narrow: this.narrow })}">
|
||||
<div class="toolbar">
|
||||
${
|
||||
this._searchParms.has("historyBack")
|
||||
@@ -175,7 +176,6 @@ class PanelClimate extends LitElement {
|
||||
.header {
|
||||
background-color: var(--app-header-background-color);
|
||||
color: var(--app-header-text-color, white);
|
||||
border-bottom: var(--app-header-border-bottom, none);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: calc(
|
||||
@@ -220,15 +220,19 @@ class PanelClimate extends LitElement {
|
||||
padding: 0px 12px;
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
box-sizing: border-box;
|
||||
border-bottom: var(--app-header-border-bottom, none);
|
||||
}
|
||||
:host([narrow]) .toolbar {
|
||||
padding: 0 4px;
|
||||
}
|
||||
.main-title {
|
||||
margin: var(--margin-title);
|
||||
margin-inline-start: var(--ha-space-6);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
flex-grow: 1;
|
||||
}
|
||||
.narrow .main-title {
|
||||
margin-inline-start: var(--ha-space-2);
|
||||
}
|
||||
hui-view-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
@@ -16,8 +16,11 @@ 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-suggest-with-ai-button";
|
||||
import type { SuggestWithAIGenerateTask } from "../../../components/ha-suggest-with-ai-button";
|
||||
import "../../../components/ha-textfield";
|
||||
import "../../../components/ha-wa-dialog";
|
||||
import type { GenDataTaskResult } from "../../../data/ai_task";
|
||||
import type {
|
||||
AreaRegistryEntry,
|
||||
AreaRegistryEntryMutableParams,
|
||||
@@ -32,6 +35,14 @@ 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 MetadataSuggestionInclude,
|
||||
type MetadataSuggestionResult,
|
||||
generateMetadataSuggestionTask,
|
||||
processMetadataSuggestion,
|
||||
} from "../common/suggest-metadata-ai";
|
||||
import { fetchLabels } from "../common/suggest-metadata-helpers";
|
||||
import { buildAreaMetadataInspirations } from "../common/suggest-metadata-inspirations";
|
||||
import type { AreaRegistryDetailDialogParams } from "./show-dialog-area-registry-detail";
|
||||
|
||||
const cropOptions: CropOptions = {
|
||||
@@ -71,10 +82,16 @@ class DialogAreaDetail
|
||||
|
||||
@state() private _params?: AreaRegistryDetailDialogParams;
|
||||
|
||||
@state() private _submitting?: boolean;
|
||||
@state() private _submitting = false;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _suggestionInclude: MetadataSuggestionInclude = {
|
||||
name: true,
|
||||
labels: true,
|
||||
floor: true,
|
||||
};
|
||||
|
||||
public async showDialog(
|
||||
params: AreaRegistryDetailDialogParams
|
||||
): Promise<void> {
|
||||
@@ -242,6 +259,76 @@ class DialogAreaDetail
|
||||
`;
|
||||
}
|
||||
|
||||
private async _getLabelNames(): Promise<string[]> {
|
||||
if (!this._labels.length) {
|
||||
return [];
|
||||
}
|
||||
const labels = await fetchLabels(this.hass.connection);
|
||||
return this._labels
|
||||
.map((labelId) => labels[labelId])
|
||||
.filter((name): name is string => Boolean(name));
|
||||
}
|
||||
|
||||
private _generateTask = async (): Promise<SuggestWithAIGenerateTask> => {
|
||||
this._suggestionInclude = {
|
||||
...this._suggestionInclude,
|
||||
name: this._name.trim() === "",
|
||||
};
|
||||
|
||||
return generateMetadataSuggestionTask<{
|
||||
name: string;
|
||||
aliases: string[];
|
||||
labels: string[];
|
||||
floor: string | null;
|
||||
temperature_entity: string | null;
|
||||
humidity_entity: string | null;
|
||||
}>(
|
||||
this.hass.connection,
|
||||
this.hass.language,
|
||||
"area",
|
||||
{
|
||||
name: this._name,
|
||||
aliases: this._aliases,
|
||||
labels: await this._getLabelNames(),
|
||||
floor: this._floor ? this.hass.floors?.[this._floor]?.name : null,
|
||||
temperature_entity: this._temperatureEntity
|
||||
? (this.hass.states[this._temperatureEntity]?.attributes
|
||||
?.friendly_name ?? null)
|
||||
: null,
|
||||
humidity_entity: this._humidityEntity
|
||||
? (this.hass.states[this._humidityEntity]?.attributes
|
||||
?.friendly_name ?? null)
|
||||
: null,
|
||||
},
|
||||
await buildAreaMetadataInspirations(this.hass.connection),
|
||||
this._suggestionInclude
|
||||
);
|
||||
};
|
||||
|
||||
private async _handleSuggestion(
|
||||
event: CustomEvent<GenDataTaskResult<MetadataSuggestionResult>>
|
||||
) {
|
||||
const result = event.detail;
|
||||
const processed = await processMetadataSuggestion(
|
||||
this.hass.connection,
|
||||
"area",
|
||||
result,
|
||||
this._suggestionInclude
|
||||
);
|
||||
|
||||
if (processed.name) {
|
||||
this._name = processed.name;
|
||||
}
|
||||
|
||||
if (processed.labels?.length) {
|
||||
this._labels = processed.labels;
|
||||
}
|
||||
|
||||
if (processed.floor) {
|
||||
this._floor = processed.floor;
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
@@ -259,6 +346,12 @@ class DialogAreaDetail
|
||||
: this.hass.localize("ui.panel.config.areas.editor.create_area")}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<ha-suggest-with-ai-button
|
||||
slot="headerActionItems"
|
||||
.hass=${this.hass}
|
||||
.generateTask=${this._generateTask}
|
||||
@suggestion=${this._handleSuggestion}
|
||||
></ha-suggest-with-ai-button>
|
||||
<div>
|
||||
${this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
@@ -285,7 +378,7 @@ class DialogAreaDetail
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._updateEntry}
|
||||
.disabled=${nameInvalid || !!this._submitting}
|
||||
.disabled=${nameInvalid || this._submitting}
|
||||
>
|
||||
${entry
|
||||
? this.hass.localize("ui.common.save")
|
||||
@@ -423,13 +516,16 @@ class DialogAreaDetail
|
||||
ha-picture-upload,
|
||||
ha-expansion-panel {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
.content {
|
||||
padding: 12px;
|
||||
padding: var(--ha-space-3);
|
||||
}
|
||||
.description {
|
||||
margin: 0 0 16px 0;
|
||||
margin: 0 0 var(--ha-space-4) 0;
|
||||
}
|
||||
ha-suggest-with-ai-button {
|
||||
margin: var(--ha-space-2) var(--ha-space-4);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -239,9 +239,8 @@ class HaConfigAreaPage extends LitElement {
|
||||
${this.hass.localize("ui.panel.config.areas.edit_settings")}
|
||||
</ha-dropdown-item>
|
||||
|
||||
<ha-dropdown-item value="delete">
|
||||
<ha-svg-icon class="warning" slot="icon" .path=${mdiDelete}>
|
||||
</ha-svg-icon>
|
||||
<ha-dropdown-item value="delete" variant="danger">
|
||||
<ha-svg-icon slot="icon" .path=${mdiDelete}> </ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.config.areas.editor.delete")}
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
|
||||
@@ -224,9 +224,8 @@ export class HaConfigAreasDashboard extends LitElement {
|
||||
"ui.panel.config.areas.picker.floor.edit_floor"
|
||||
)}</ha-dropdown-item
|
||||
>
|
||||
<ha-dropdown-item value="delete" class="warning"
|
||||
<ha-dropdown-item value="delete" variant="danger"
|
||||
><ha-svg-icon
|
||||
class="warning"
|
||||
.path=${mdiDelete}
|
||||
slot="icon"
|
||||
></ha-svg-icon
|
||||
@@ -727,9 +726,6 @@ export class HaConfigAreasDashboard extends LitElement {
|
||||
align-items: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.warning {
|
||||
color: var(--error-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -33,10 +33,10 @@ import type {
|
||||
} from "./show-dialog-automation-save";
|
||||
import {
|
||||
type MetadataSuggestionResult,
|
||||
SUGGESTION_INCLUDE_ALL,
|
||||
generateMetadataSuggestionTask,
|
||||
processMetadataSuggestion,
|
||||
} from "../../common/suggest-metadata-ai";
|
||||
import { buildEntityMetadataInspirations } from "../../common/suggest-metadata-inspirations";
|
||||
|
||||
@customElement("ha-dialog-automation-save")
|
||||
class DialogAutomationSave extends LitElement implements HassDialog {
|
||||
@@ -341,10 +341,14 @@ class DialogAutomationSave extends LitElement implements HassDialog {
|
||||
}
|
||||
return generateMetadataSuggestionTask<AutomationConfig | ScriptConfig>(
|
||||
this.hass.connection,
|
||||
this.hass.states,
|
||||
this.hass.language,
|
||||
this._params.domain,
|
||||
this._params.config
|
||||
this._params.config,
|
||||
await buildEntityMetadataInspirations(
|
||||
this.hass.connection,
|
||||
this.hass.states,
|
||||
this._params.domain
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -358,11 +362,12 @@ class DialogAutomationSave extends LitElement implements HassDialog {
|
||||
const processed = await processMetadataSuggestion(
|
||||
this.hass.connection,
|
||||
this._params.domain,
|
||||
result,
|
||||
SUGGESTION_INCLUDE_ALL
|
||||
result
|
||||
);
|
||||
|
||||
this._newName = processed.name;
|
||||
if (processed.name) {
|
||||
this._newName = processed.name;
|
||||
}
|
||||
|
||||
if (processed.description) {
|
||||
this._newDescription = processed.description;
|
||||
@@ -432,7 +437,8 @@ class DialogAutomationSave extends LitElement implements HassDialog {
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-wa-dialog {
|
||||
--dialog-content-padding: 0 24px 24px 24px;
|
||||
--dialog-content-padding: 0 var(--ha-space-6) var(--ha-space-6)
|
||||
var(--ha-space-6);
|
||||
}
|
||||
|
||||
ha-textfield,
|
||||
@@ -448,15 +454,15 @@ class DialogAutomationSave extends LitElement implements HassDialog {
|
||||
ha-labels-picker,
|
||||
ha-area-picker,
|
||||
ha-chip-set:has(> ha-assist-chip) {
|
||||
margin-top: 16px;
|
||||
margin-top: var(--ha-space-4);
|
||||
}
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
|
||||
ha-suggest-with-ai-button {
|
||||
margin: 8px 16px;
|
||||
margin: var(--ha-space-2) var(--ha-space-4);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -84,31 +84,6 @@ export interface ConditionElement extends LitElement {
|
||||
collapseAll?: () => void;
|
||||
}
|
||||
|
||||
export const handleChangeEvent = (
|
||||
element: ConditionElement,
|
||||
ev: CustomEvent
|
||||
) => {
|
||||
ev.stopPropagation();
|
||||
const name = (ev.currentTarget as any)?.name;
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
const newVal = ev.detail?.value || (ev.currentTarget as any)?.value;
|
||||
|
||||
if ((element.condition[name] || "") === newVal) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newCondition: Condition;
|
||||
if (!newVal) {
|
||||
newCondition = { ...element.condition };
|
||||
delete newCondition[name];
|
||||
} else {
|
||||
newCondition = { ...element.condition, [name]: newVal };
|
||||
}
|
||||
fireEvent(element, "value-changed", { value: newCondition });
|
||||
};
|
||||
|
||||
@customElement("ha-automation-condition-row")
|
||||
export default class HaAutomationConditionRow extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../../../../../components/ha-textarea";
|
||||
import type { TemplateCondition } from "../../../../../data/automation";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import { handleChangeEvent } from "../ha-automation-condition-row";
|
||||
import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
import "../../../../../components/ha-form/ha-form";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
|
||||
const SCHEMA = [
|
||||
{ name: "value_template", required: true, selector: { template: {} } },
|
||||
] as const;
|
||||
|
||||
@customElement("ha-automation-condition-template")
|
||||
export class HaTemplateCondition extends LitElement {
|
||||
@@ -18,36 +24,30 @@ export class HaTemplateCondition extends LitElement {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const { value_template } = this.condition;
|
||||
return html`
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.template.value_template"
|
||||
)}
|
||||
*
|
||||
</p>
|
||||
<ha-code-editor
|
||||
.name=${"value_template"}
|
||||
mode="jinja2"
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.value=${value_template}
|
||||
.readOnly=${this.disabled}
|
||||
autocomplete-entities
|
||||
.data=${this.condition}
|
||||
.schema=${SCHEMA}
|
||||
@value-changed=${this._valueChanged}
|
||||
dir="ltr"
|
||||
></ha-code-editor>
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
.disabled=${this.disabled}
|
||||
></ha-form>
|
||||
`;
|
||||
}
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
handleChangeEvent(this, ev);
|
||||
ev.stopPropagation();
|
||||
const newCondition = ev.detail.value;
|
||||
fireEvent(this, "value-changed", { value: newCondition });
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
`;
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<typeof SCHEMA>
|
||||
): string =>
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.conditions.type.template.${schema.name}`
|
||||
);
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "@home-assistant/webawesome/dist/components/divider/divider";
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import { consume } from "@lit/context";
|
||||
import {
|
||||
mdiChevronRight,
|
||||
mdiCog,
|
||||
mdiContentDuplicate,
|
||||
mdiDelete,
|
||||
@@ -50,6 +50,9 @@ import type {
|
||||
} from "../../../components/data-table/ha-data-table";
|
||||
import "../../../components/data-table/ha-data-table-labels";
|
||||
import "../../../components/entity/ha-entity-toggle";
|
||||
import "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
import type { HaDropdownItem } from "../../../components/ha-dropdown-item";
|
||||
import "../../../components/ha-fab";
|
||||
import "../../../components/ha-filter-blueprints";
|
||||
import "../../../components/ha-filter-categories";
|
||||
@@ -59,7 +62,6 @@ import "../../../components/ha-filter-floor-areas";
|
||||
import "../../../components/ha-filter-labels";
|
||||
import "../../../components/ha-filter-voice-assistants";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-md-divider";
|
||||
import "../../../components/ha-md-menu";
|
||||
import type { HaMdMenu } from "../../../components/ha-md-menu";
|
||||
import "../../../components/ha-md-menu-item";
|
||||
@@ -87,9 +89,9 @@ import { fullEntitiesContext } from "../../../data/context";
|
||||
import type { DataTableFilters } from "../../../data/data_table_filters";
|
||||
import {
|
||||
deserializeFilters,
|
||||
serializeFilters,
|
||||
isUsedFilter as isFilterUsed,
|
||||
isUsedRelatedItemsFilter as isRelatedItemsFilterUsed,
|
||||
serializeFilters,
|
||||
} from "../../../data/data_table_filters";
|
||||
import { UNAVAILABLE } from "../../../data/entity/entity";
|
||||
import type {
|
||||
@@ -97,6 +99,7 @@ import type {
|
||||
UpdateEntityRegistryEntryResult,
|
||||
} from "../../../data/entity/entity_registry";
|
||||
import { updateEntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
import { getEntityVoiceAssistantsIds } from "../../../data/expose";
|
||||
import type { LabelRegistryEntry } from "../../../data/label/label_registry";
|
||||
import {
|
||||
createLabelRegistryEntry,
|
||||
@@ -118,13 +121,12 @@ import { showAssignCategoryDialog } from "../category/show-dialog-assign-categor
|
||||
import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail";
|
||||
import { configSections } from "../ha-panel-config";
|
||||
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
|
||||
import { showNewAutomationDialog } from "./show-dialog-new-automation";
|
||||
import { getEntityVoiceAssistantsIds } from "../../../data/expose";
|
||||
import { getAvailableAssistants } from "../voice-assistants/expose/available-assistants";
|
||||
import {
|
||||
getAssistantsTableColumn,
|
||||
getAssistantsSortableKey,
|
||||
getAssistantsTableColumn,
|
||||
} from "../voice-assistants/expose/assistants-table-column";
|
||||
import { getAvailableAssistants } from "../voice-assistants/expose/available-assistants";
|
||||
import { showNewAutomationDialog } from "./show-dialog-new-automation";
|
||||
|
||||
type AutomationItem = AutomationEntity & {
|
||||
name: string;
|
||||
@@ -441,103 +443,6 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const categoryItems = html`${this._categories?.map(
|
||||
(category) =>
|
||||
html`<ha-md-menu-item
|
||||
.value=${category.category_id}
|
||||
.clickAction=${this._handleBulkCategory}
|
||||
>
|
||||
${category.icon
|
||||
? html`<ha-icon slot="start" .icon=${category.icon}></ha-icon>`
|
||||
: html`<ha-svg-icon slot="start" .path=${mdiTag}></ha-svg-icon>`}
|
||||
<div slot="headline">${category.name}</div>
|
||||
</ha-md-menu-item>`
|
||||
)}
|
||||
<ha-md-menu-item .value=${null} .clickAction=${this._handleBulkCategory}>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.picker.bulk_actions.no_category"
|
||||
)}
|
||||
</div>
|
||||
</ha-md-menu-item>
|
||||
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
|
||||
<ha-md-menu-item .clickAction=${this._bulkCreateCategory}>
|
||||
<div slot="headline">
|
||||
${this.hass.localize("ui.panel.config.category.editor.add")}
|
||||
</div>
|
||||
</ha-md-menu-item>`;
|
||||
|
||||
const labelItems = html`${this._labels?.map((label) => {
|
||||
const color = label.color ? computeCssColor(label.color) : undefined;
|
||||
const selected = this._selected.every((entityId) =>
|
||||
this.hass.entities[entityId]?.labels.includes(label.label_id)
|
||||
);
|
||||
const partial =
|
||||
!selected &&
|
||||
this._selected.some((entityId) =>
|
||||
this.hass.entities[entityId]?.labels.includes(label.label_id)
|
||||
);
|
||||
return html`<ha-md-menu-item
|
||||
.value=${label.label_id}
|
||||
.action=${selected ? "remove" : "add"}
|
||||
@click=${this._handleBulkLabel}
|
||||
keep-open
|
||||
>
|
||||
<ha-checkbox
|
||||
slot="start"
|
||||
.checked=${selected}
|
||||
.indeterminate=${partial}
|
||||
reducedTouchTarget
|
||||
></ha-checkbox>
|
||||
<ha-label
|
||||
style=${color ? `--color: ${color}` : ""}
|
||||
.description=${label.description}
|
||||
>
|
||||
${label.icon
|
||||
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
|
||||
: nothing}
|
||||
${label.name}
|
||||
</ha-label>
|
||||
</ha-md-menu-item>`;
|
||||
})}
|
||||
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
|
||||
<ha-md-menu-item .clickAction=${this._bulkCreateLabel}>
|
||||
<div slot="headline">
|
||||
${this.hass.localize("ui.panel.config.labels.add_label")}
|
||||
</div></ha-md-menu-item
|
||||
>`;
|
||||
|
||||
const areaItems = html`${Object.values(this.hass.areas).map(
|
||||
(area) =>
|
||||
html`<ha-md-menu-item
|
||||
.value=${area.area_id}
|
||||
.clickAction=${this._handleBulkArea}
|
||||
>
|
||||
${area.icon
|
||||
? html`<ha-icon slot="start" .icon=${area.icon}></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiTextureBox}
|
||||
></ha-svg-icon>`}
|
||||
<div slot="headline">${area.name}</div>
|
||||
</ha-md-menu-item>`
|
||||
)}
|
||||
<ha-md-menu-item .value=${null} .clickAction=${this._handleBulkArea}>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.devices.picker.bulk_actions.no_area"
|
||||
)}
|
||||
</div>
|
||||
</ha-md-menu-item>
|
||||
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
|
||||
<ha-md-menu-item .clickAction=${this._bulkCreateArea}>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.devices.picker.bulk_actions.add_area"
|
||||
)}
|
||||
</div>
|
||||
</ha-md-menu-item>`;
|
||||
|
||||
const areasInOverflow =
|
||||
(this._sizeController.value && this._sizeController.value < 900) ||
|
||||
(!this._sizeController.value && this.hass.dockedSidebar === "docked");
|
||||
@@ -558,9 +463,9 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
<hass-tabs-subpage-data-table
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config"
|
||||
}
|
||||
.backPath=${this._searchParms.has("historyBack")
|
||||
? undefined
|
||||
: "/config"}
|
||||
id="entity_id"
|
||||
.route=${this.route}
|
||||
.tabs=${configSections.automations}
|
||||
@@ -572,16 +477,14 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
.selected=${this._selected.length}
|
||||
@selection-changed=${this._handleSelectionChanged}
|
||||
has-filters
|
||||
.filters=${
|
||||
Object.values(this._filters).filter((filter) =>
|
||||
Array.isArray(filter.value)
|
||||
? filter.value.length
|
||||
: filter.value &&
|
||||
Object.values(filter.value).some((val) =>
|
||||
Array.isArray(val) ? val.length : val
|
||||
)
|
||||
).length
|
||||
}
|
||||
.filters=${Object.values(this._filters).filter((filter) =>
|
||||
Array.isArray(filter.value)
|
||||
? filter.value.length
|
||||
: filter.value &&
|
||||
Object.values(filter.value).some((val) =>
|
||||
Array.isArray(val) ? val.length : val
|
||||
)
|
||||
).length}
|
||||
.columns=${this._columns(
|
||||
this.narrow,
|
||||
this.hass.localize,
|
||||
@@ -684,13 +587,34 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
.narrow=${this.narrow}
|
||||
@expanded-changed=${this._filterExpanded}
|
||||
></ha-filter-blueprints>
|
||||
${
|
||||
!this.narrow
|
||||
? html`<ha-md-button-menu slot="selection-bar">
|
||||
${!this.narrow
|
||||
? html`<ha-dropdown
|
||||
slot="selection-bar"
|
||||
@wa-select=${this._handleBulkCategory}
|
||||
>
|
||||
<ha-assist-chip
|
||||
slot="trigger"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.automation.picker.bulk_actions.move_category"
|
||||
)}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="trailing-icon"
|
||||
.path=${mdiMenuDown}
|
||||
></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
${this._renderCategoryItems()}
|
||||
</ha-dropdown>
|
||||
${labelsInOverflow
|
||||
? nothing
|
||||
: html`<ha-dropdown
|
||||
slot="selection-bar"
|
||||
@wa-select=${this._handleBulkLabel}
|
||||
>
|
||||
<ha-assist-chip
|
||||
slot="trigger"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.automation.picker.bulk_actions.move_category"
|
||||
"ui.panel.config.automation.picker.bulk_actions.add_label"
|
||||
)}
|
||||
>
|
||||
<ha-svg-icon
|
||||
@@ -698,179 +622,119 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
.path=${mdiMenuDown}
|
||||
></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
${categoryItems}
|
||||
</ha-md-button-menu>
|
||||
${labelsInOverflow
|
||||
? nothing
|
||||
: html`<ha-md-button-menu slot="selection-bar">
|
||||
<ha-assist-chip
|
||||
slot="trigger"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.automation.picker.bulk_actions.add_label"
|
||||
)}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="trailing-icon"
|
||||
.path=${mdiMenuDown}
|
||||
></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
${labelItems}
|
||||
</ha-md-button-menu>`}
|
||||
${areasInOverflow
|
||||
? nothing
|
||||
: html`<ha-md-button-menu slot="selection-bar">
|
||||
<ha-assist-chip
|
||||
slot="trigger"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.devices.picker.bulk_actions.move_area"
|
||||
)}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="trailing-icon"
|
||||
.path=${mdiMenuDown}
|
||||
></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
${areaItems}
|
||||
</ha-md-button-menu>`}`
|
||||
: nothing
|
||||
}
|
||||
<ha-md-button-menu has-overflow slot="selection-bar">
|
||||
${
|
||||
this.narrow
|
||||
? html`<ha-assist-chip
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.automation.picker.bulk_action"
|
||||
)}
|
||||
slot="trigger"
|
||||
${this._renderLabelItems()}
|
||||
</ha-dropdown>`}
|
||||
${areasInOverflow
|
||||
? nothing
|
||||
: html`<ha-dropdown
|
||||
slot="selection-bar"
|
||||
@wa-select=${this._handleBulkArea}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="trailing-icon"
|
||||
.path=${mdiMenuDown}
|
||||
></ha-svg-icon>
|
||||
</ha-assist-chip>`
|
||||
: html`<ha-icon-button
|
||||
.path=${mdiDotsVertical}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.automation.picker.bulk_action"
|
||||
)}
|
||||
slot="trigger"
|
||||
></ha-icon-button>`
|
||||
}
|
||||
<ha-svg-icon
|
||||
slot="trailing-icon"
|
||||
.path=${mdiMenuDown}
|
||||
></ha-svg-icon
|
||||
></ha-assist-chip>
|
||||
${
|
||||
this.narrow
|
||||
? html`<ha-sub-menu>
|
||||
<ha-md-menu-item slot="item">
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.picker.bulk_actions.move_category"
|
||||
)}
|
||||
</div>
|
||||
<ha-assist-chip
|
||||
slot="trigger"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.devices.picker.bulk_actions.move_area"
|
||||
)}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="end"
|
||||
.path=${mdiChevronRight}
|
||||
slot="trailing-icon"
|
||||
.path=${mdiMenuDown}
|
||||
></ha-svg-icon>
|
||||
</ha-md-menu-item>
|
||||
<ha-md-menu slot="menu">${categoryItems}</ha-md-menu>
|
||||
</ha-sub-menu>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.narrow || labelsInOverflow
|
||||
? html`<ha-sub-menu>
|
||||
<ha-md-menu-item slot="item">
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.picker.bulk_actions.add_label"
|
||||
)}
|
||||
</div>
|
||||
<ha-svg-icon
|
||||
slot="end"
|
||||
.path=${mdiChevronRight}
|
||||
></ha-svg-icon>
|
||||
</ha-md-menu-item>
|
||||
<ha-md-menu slot="menu">${labelItems}</ha-md-menu>
|
||||
</ha-sub-menu>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.narrow || areasInOverflow
|
||||
? html`<ha-sub-menu>
|
||||
<ha-md-menu-item slot="item">
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.devices.picker.bulk_actions.move_area"
|
||||
)}
|
||||
</div>
|
||||
<ha-svg-icon
|
||||
slot="end"
|
||||
.path=${mdiChevronRight}
|
||||
></ha-svg-icon>
|
||||
</ha-md-menu-item>
|
||||
<ha-md-menu slot="menu">${areaItems}</ha-md-menu>
|
||||
</ha-sub-menu>`
|
||||
: nothing
|
||||
}
|
||||
<ha-md-menu-item .clickAction=${this._handleBulkEnable}>
|
||||
<ha-svg-icon slot="start" .path=${mdiToggleSwitch}></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.picker.bulk_actions.enable"
|
||||
</ha-assist-chip>
|
||||
${this._renderAreaItems()}
|
||||
</ha-dropdown>`}`
|
||||
: nothing}
|
||||
<ha-dropdown slot="selection-bar" @wa-select=${this._handleBulkAction}>
|
||||
${this.narrow
|
||||
? html`<ha-assist-chip
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.automation.picker.bulk_action"
|
||||
)}
|
||||
</div>
|
||||
</ha-md-menu-item>
|
||||
<ha-md-menu-item .clickAction=${this._handleBulkDisable}>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiToggleSwitchOffOutline}
|
||||
></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.picker.bulk_actions.disable"
|
||||
slot="trigger"
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="trailing-icon"
|
||||
.path=${mdiMenuDown}
|
||||
></ha-svg-icon>
|
||||
</ha-assist-chip>`
|
||||
: html`<ha-icon-button
|
||||
.path=${mdiDotsVertical}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.automation.picker.bulk_action"
|
||||
)}
|
||||
</div>
|
||||
</ha-md-menu-item>
|
||||
</ha-md-button-menu>
|
||||
${
|
||||
!this.automations.length
|
||||
? html`<div class="empty" slot="empty">
|
||||
<ha-svg-icon .path=${mdiRobotHappy}></ha-svg-icon>
|
||||
<h1>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.picker.empty_header"
|
||||
)}
|
||||
</h1>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.picker.empty_text_1"
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.picker.empty_text_2",
|
||||
{ user: this.hass.user?.name || "Alice" }
|
||||
)}
|
||||
</p>
|
||||
<ha-button
|
||||
href=${documentationUrl(
|
||||
this.hass,
|
||||
"/docs/automation/editor/"
|
||||
)}
|
||||
target="_blank"
|
||||
appearance="plain"
|
||||
rel="noreferrer"
|
||||
size="small"
|
||||
>
|
||||
${this.hass.localize("ui.panel.config.common.learn_more")}
|
||||
<ha-svg-icon slot="end" .path=${mdiOpenInNew}> </ha-svg-icon>
|
||||
</ha-button>
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
slot="trigger"
|
||||
></ha-icon-button>`}
|
||||
${this.narrow
|
||||
? html`<ha-dropdown-item>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.picker.bulk_actions.move_category"
|
||||
)}
|
||||
${this._renderCategoryItems("submenu")}
|
||||
</ha-dropdown-item>`
|
||||
: nothing}
|
||||
${this.narrow || labelsInOverflow
|
||||
? html`<ha-dropdown-item>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.picker.bulk_actions.add_label"
|
||||
)}
|
||||
${this._renderLabelItems("submenu")}
|
||||
</ha-dropdown-item>`
|
||||
: nothing}
|
||||
${this.narrow || areasInOverflow
|
||||
? html`<ha-dropdown-item>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.devices.picker.bulk_actions.move_area"
|
||||
)}
|
||||
${this._renderAreaItems("submenu")}
|
||||
</ha-dropdown-item>`
|
||||
: nothing}
|
||||
<ha-dropdown-item value="enable">
|
||||
<ha-svg-icon slot="icon" .path=${mdiToggleSwitch}></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.picker.bulk_actions.enable"
|
||||
)}
|
||||
</ha-dropdown-item>
|
||||
<ha-dropdown-item value="disable">
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiToggleSwitchOffOutline}
|
||||
></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.picker.bulk_actions.disable"
|
||||
)}
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
${!this.automations.length
|
||||
? html`<div class="empty" slot="empty">
|
||||
<ha-svg-icon .path=${mdiRobotHappy}></ha-svg-icon>
|
||||
<h1>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.picker.empty_header"
|
||||
)}
|
||||
</h1>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.picker.empty_text_1"
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.picker.empty_text_2",
|
||||
{ user: this.hass.user?.name || "Alice" }
|
||||
)}
|
||||
</p>
|
||||
<ha-button
|
||||
href=${documentationUrl(this.hass, "/docs/automation/editor/")}
|
||||
target="_blank"
|
||||
appearance="plain"
|
||||
rel="noreferrer"
|
||||
size="small"
|
||||
>
|
||||
${this.hass.localize("ui.panel.config.common.learn_more")}
|
||||
<ha-svg-icon slot="end" .path=${mdiOpenInNew}> </ha-svg-icon>
|
||||
</ha-button>
|
||||
</div>`
|
||||
: nothing}
|
||||
<ha-fab
|
||||
slot="fab"
|
||||
.label=${this.hass.localize(
|
||||
@@ -932,21 +796,15 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
</ha-md-menu-item>
|
||||
<ha-md-menu-item .clickAction=${this._toggle}>
|
||||
<ha-svg-icon
|
||||
.path=${
|
||||
this._overflowAutomation?.state === "off"
|
||||
? mdiToggleSwitch
|
||||
: mdiToggleSwitchOffOutline
|
||||
}
|
||||
.path=${this._overflowAutomation?.state === "off"
|
||||
? mdiToggleSwitch
|
||||
: mdiToggleSwitchOffOutline}
|
||||
slot="start"
|
||||
></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${
|
||||
this._overflowAutomation?.state === "off"
|
||||
? this.hass.localize("ui.panel.config.automation.editor.enable")
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.disable"
|
||||
)
|
||||
}
|
||||
${this._overflowAutomation?.state === "off"
|
||||
? this.hass.localize("ui.panel.config.automation.editor.enable")
|
||||
: this.hass.localize("ui.panel.config.automation.editor.disable")}
|
||||
</div>
|
||||
</ha-md-menu-item>
|
||||
<ha-md-menu-item .clickAction=${this._deleteConfirm} class="warning">
|
||||
@@ -1282,12 +1140,22 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
|
||||
private _handleBulkCategory = async (item) => {
|
||||
const category = item.value;
|
||||
this._bulkAddCategory(category);
|
||||
private _handleBulkCategory = (ev: CustomEvent<{ item: HaDropdownItem }>) => {
|
||||
const value = ev.detail.item.value;
|
||||
if (value === "category_create") {
|
||||
this._bulkCreateCategory();
|
||||
return;
|
||||
}
|
||||
if (value === "category_none") {
|
||||
this._bulkAddCategory(null);
|
||||
return;
|
||||
}
|
||||
if (value?.startsWith("category_")) {
|
||||
this._bulkAddCategory(value.substring(9));
|
||||
}
|
||||
};
|
||||
|
||||
private async _bulkAddCategory(category: string) {
|
||||
private async _bulkAddCategory(category: string | null) {
|
||||
const promises: Promise<UpdateEntityRegistryEntryResult>[] = [];
|
||||
this._selected.forEach((entityId) => {
|
||||
promises.push(
|
||||
@@ -1312,11 +1180,20 @@ ${rejected
|
||||
}
|
||||
}
|
||||
|
||||
private async _handleBulkLabel(ev) {
|
||||
const label = ev.currentTarget.value;
|
||||
const action = ev.currentTarget.action;
|
||||
this._bulkLabel(label, action);
|
||||
}
|
||||
private _handleBulkLabel = (ev) => {
|
||||
ev.preventDefault(); // keep menu open
|
||||
const item = ev.detail.item;
|
||||
const value = item.value;
|
||||
if (value === "label_create") {
|
||||
this._bulkCreateLabel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (value?.startsWith("label_")) {
|
||||
const action = item.action;
|
||||
this._bulkLabel(value.substring(6), action);
|
||||
}
|
||||
};
|
||||
|
||||
private async _bulkLabel(label: string, action: "add" | "remove") {
|
||||
const promises: Promise<UpdateEntityRegistryEntryResult>[] = [];
|
||||
@@ -1348,12 +1225,23 @@ ${rejected
|
||||
}
|
||||
}
|
||||
|
||||
private _handleBulkArea = (item) => {
|
||||
const area = item.value;
|
||||
this._bulkAddArea(area);
|
||||
private _handleBulkArea = (ev) => {
|
||||
const value = ev.detail.item.value;
|
||||
if (value === "area_create") {
|
||||
this._bulkCreateArea();
|
||||
return;
|
||||
}
|
||||
if (value === "area_none") {
|
||||
this._bulkAddArea(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (value?.startsWith("area_")) {
|
||||
this._bulkAddArea(value.substring(5));
|
||||
}
|
||||
};
|
||||
|
||||
private async _bulkAddArea(area: string) {
|
||||
private async _bulkAddArea(area: string | null) {
|
||||
const promises: Promise<UpdateEntityRegistryEntryResult>[] = [];
|
||||
this._selected.forEach((entityId) => {
|
||||
promises.push(
|
||||
@@ -1454,6 +1342,148 @@ ${rejected
|
||||
});
|
||||
};
|
||||
|
||||
private _renderCategoryItems = (slot = "") =>
|
||||
html`${this._categories?.map(
|
||||
(category) =>
|
||||
html`<ha-dropdown-item
|
||||
.slot=${slot}
|
||||
.value=${`category_${category.category_id}`}
|
||||
>
|
||||
${category.icon
|
||||
? html`<ha-icon slot="icon" .icon=${category.icon}></ha-icon>`
|
||||
: html`<ha-svg-icon slot="icon" .path=${mdiTag}></ha-svg-icon>`}
|
||||
${category.name}
|
||||
</ha-dropdown-item>`
|
||||
)}
|
||||
<ha-dropdown-item .slot=${slot} value="category_none">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.picker.bulk_actions.no_category"
|
||||
)}
|
||||
</ha-dropdown-item>
|
||||
<wa-divider .slot=${slot}></wa-divider>
|
||||
<ha-dropdown-item .slot=${slot} value="category_create">
|
||||
${this.hass.localize("ui.panel.config.category.editor.add")}
|
||||
</ha-dropdown-item>`;
|
||||
|
||||
private _renderLabelItems = (slot = "") =>
|
||||
html`${this._labels?.map((label) => {
|
||||
const color = label.color ? computeCssColor(label.color) : undefined;
|
||||
const selected = this._selected.every((entityId) =>
|
||||
this.hass.entities[entityId]?.labels.includes(label.label_id)
|
||||
);
|
||||
const partial =
|
||||
!selected &&
|
||||
this._selected.some((entityId) =>
|
||||
this.hass.entities[entityId]?.labels.includes(label.label_id)
|
||||
);
|
||||
return html`<ha-dropdown-item
|
||||
.slot=${slot}
|
||||
.value=${`label_${label.label_id}`}
|
||||
.action=${selected ? "remove" : "add"}
|
||||
>
|
||||
<ha-checkbox
|
||||
slot="icon"
|
||||
.checked=${selected}
|
||||
.indeterminate=${partial}
|
||||
reducedTouchTarget
|
||||
></ha-checkbox>
|
||||
<ha-label
|
||||
style=${color ? `--color: ${color}` : ""}
|
||||
.description=${label.description}
|
||||
>
|
||||
${label.icon
|
||||
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
|
||||
: nothing}
|
||||
${label.name}
|
||||
</ha-label>
|
||||
</ha-dropdown-item>`;
|
||||
})}
|
||||
<wa-divider .slot=${slot}></wa-divider>
|
||||
<ha-dropdown-item .slot=${slot} value="label_create">
|
||||
${this.hass.localize("ui.panel.config.labels.add_label")}
|
||||
</ha-dropdown-item>`;
|
||||
|
||||
private _renderAreaItems = (slot = "") =>
|
||||
html`${Object.values(this.hass.areas).map(
|
||||
(area) =>
|
||||
html`<ha-dropdown-item .slot=${slot} .value=${`area_${area.area_id}`}>
|
||||
${area.icon
|
||||
? html`<ha-icon slot="icon" .icon=${area.icon}></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiTextureBox}
|
||||
></ha-svg-icon>`}
|
||||
${area.name}
|
||||
</ha-dropdown-item>`
|
||||
)}
|
||||
<ha-dropdown-item .slot=${slot} value="area_none">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.devices.picker.bulk_actions.no_area"
|
||||
)}
|
||||
</ha-dropdown-item>
|
||||
<wa-divider .slot=${slot}></wa-divider>
|
||||
<ha-dropdown-item .slot=${slot} value="area_create">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.devices.picker.bulk_actions.add_area"
|
||||
)}
|
||||
</ha-dropdown-item>`;
|
||||
|
||||
private _handleBulkAction = (ev) => {
|
||||
const item = ev.detail.item;
|
||||
const value = item.value;
|
||||
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === "enable") {
|
||||
this._handleBulkEnable();
|
||||
return;
|
||||
}
|
||||
if (value === "disable") {
|
||||
this._handleBulkDisable();
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.startsWith("category_")) {
|
||||
if (value === "category_create") {
|
||||
this._bulkCreateCategory();
|
||||
return;
|
||||
}
|
||||
if (value === "category_none") {
|
||||
this._bulkAddCategory(null);
|
||||
return;
|
||||
}
|
||||
|
||||
this._bulkAddCategory(value.substring(9));
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.startsWith("label_")) {
|
||||
if (value === "label_create") {
|
||||
this._bulkCreateLabel();
|
||||
return;
|
||||
}
|
||||
|
||||
const action = item.action;
|
||||
this._bulkLabel(value.substring(6), action);
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.startsWith("area_")) {
|
||||
if (value === "area_create") {
|
||||
this._bulkCreateArea();
|
||||
return;
|
||||
}
|
||||
if (value === "area_none") {
|
||||
this._bulkAddArea(null);
|
||||
return;
|
||||
}
|
||||
|
||||
this._bulkAddArea(value.substring(5));
|
||||
}
|
||||
};
|
||||
|
||||
private _handleSortingChanged(ev: CustomEvent) {
|
||||
this._activeSorting = ev.detail;
|
||||
}
|
||||
@@ -1498,7 +1528,11 @@ ${rejected
|
||||
ha-assist-chip {
|
||||
--ha-assist-chip-container-shape: 10px;
|
||||
}
|
||||
ha-md-button-menu ha-assist-chip {
|
||||
ha-dropdown::part(menu),
|
||||
ha-dropdown::part(submenu) {
|
||||
--auto-size-available-width: calc(50vw - var(--ha-space-4));
|
||||
}
|
||||
ha-dropdown ha-assist-chip {
|
||||
--md-assist-chip-trailing-space: 8px;
|
||||
}
|
||||
ha-label {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { firstWeekdayIndex } from "../../../../../common/datetime/first_weekday";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { computeDomain } from "../../../../../common/entity/compute_domain";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
import "../../../../../components/ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
@@ -11,7 +12,6 @@ import type { TimeTrigger } from "../../../../../data/automation";
|
||||
import type { FrontendLocaleData } from "../../../../../data/translation";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import type { TriggerElement } from "../ha-automation-trigger-row";
|
||||
import { computeDomain } from "../../../../../common/entity/compute_domain";
|
||||
|
||||
const MODE_TIME = "time";
|
||||
const MODE_ENTITY = "entity";
|
||||
|
||||
@@ -123,7 +123,7 @@ class HaConfigBackupDetails extends LitElement {
|
||||
<ha-svg-icon slot="icon" .path=${mdiDownload}></ha-svg-icon>
|
||||
${this.hass.localize("ui.common.download")}
|
||||
</ha-dropdown-item>
|
||||
<ha-dropdown-item value="delete" class="warning">
|
||||
<ha-dropdown-item value="delete" variant="danger">
|
||||
<ha-svg-icon slot="icon" .path=${mdiDelete}></ha-svg-icon>
|
||||
${this.hass.localize("ui.common.delete")}
|
||||
</ha-dropdown-item>
|
||||
@@ -385,12 +385,6 @@ class HaConfigBackupDetails extends LitElement {
|
||||
--mdc-icon-size: 48px;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.warning {
|
||||
color: var(--error-color);
|
||||
}
|
||||
.warning ha-svg-icon {
|
||||
color: var(--error-color);
|
||||
}
|
||||
ha-button.danger {
|
||||
--mdc-theme-primary: var(--error-color);
|
||||
}
|
||||
|
||||
@@ -1,163 +1,84 @@
|
||||
import { dump } from "js-yaml";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { subscribeOne } from "../../../common/util/subscribe-one";
|
||||
import type { AITaskStructure, GenDataTaskResult } from "../../../data/ai_task";
|
||||
import { fetchCategoryRegistry } from "../../../data/category_registry";
|
||||
import {
|
||||
subscribeEntityRegistry,
|
||||
type EntityRegistryEntry,
|
||||
} from "../../../data/entity/entity_registry";
|
||||
import { subscribeLabelRegistry } from "../../../data/label/label_registry";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { SuggestWithAIGenerateTask } from "../../../components/ha-suggest-with-ai-button";
|
||||
import {
|
||||
fetchCategories,
|
||||
fetchFloors,
|
||||
fetchLabels,
|
||||
} from "./suggest-metadata-helpers";
|
||||
|
||||
export interface MetadataSuggestionResult {
|
||||
name: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
labels?: string[];
|
||||
floor?: string;
|
||||
}
|
||||
|
||||
export type MetadataSuggestionDomain = "automation" | "script" | "scene";
|
||||
export type MetadataSuggestionDomain =
|
||||
| "automation"
|
||||
| "script"
|
||||
| "scene"
|
||||
| "area";
|
||||
|
||||
export interface MetadataSuggestionInclude {
|
||||
name: boolean;
|
||||
description?: boolean;
|
||||
categories?: boolean;
|
||||
labels?: boolean;
|
||||
floor?: boolean;
|
||||
}
|
||||
|
||||
type Categories = Record<string, string>;
|
||||
type Entities = Record<string, EntityRegistryEntry>;
|
||||
type Labels = Record<string, string>;
|
||||
|
||||
export const SUGGESTION_INCLUDE_ALL: MetadataSuggestionInclude = {
|
||||
export const SUGGESTION_INCLUDE_DEFAULT: MetadataSuggestionInclude = {
|
||||
name: true,
|
||||
description: true,
|
||||
categories: true,
|
||||
labels: true,
|
||||
} as const;
|
||||
|
||||
const tryCatchEmptyObject = <T>(promise: Promise<T>): Promise<T> =>
|
||||
promise.catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error fetching data for suggestion: ", err);
|
||||
return {} as T;
|
||||
});
|
||||
|
||||
const fetchCategories = (
|
||||
connection: HomeAssistant["connection"],
|
||||
domain: MetadataSuggestionDomain
|
||||
): Promise<Categories> =>
|
||||
tryCatchEmptyObject<Categories>(
|
||||
fetchCategoryRegistry(connection, domain).then((cats) =>
|
||||
Object.fromEntries(cats.map((cat) => [cat.category_id, cat.name]))
|
||||
)
|
||||
);
|
||||
|
||||
const fetchEntities = (
|
||||
connection: HomeAssistant["connection"]
|
||||
): Promise<Entities> =>
|
||||
tryCatchEmptyObject<Entities>(
|
||||
subscribeOne(connection, subscribeEntityRegistry).then((ents) =>
|
||||
Object.fromEntries(ents.map((ent) => [ent.entity_id, ent]))
|
||||
)
|
||||
);
|
||||
|
||||
const fetchLabels = (
|
||||
connection: HomeAssistant["connection"]
|
||||
): Promise<Labels> =>
|
||||
tryCatchEmptyObject<Labels>(
|
||||
subscribeOne(connection, subscribeLabelRegistry).then((labs) =>
|
||||
Object.fromEntries(labs.map((lab) => [lab.label_id, lab.name]))
|
||||
)
|
||||
);
|
||||
|
||||
function buildMetadataInspirations(
|
||||
domain: MetadataSuggestionDomain,
|
||||
states: HomeAssistant["states"],
|
||||
entities: Entities,
|
||||
categories?: Categories,
|
||||
labels?: Labels
|
||||
): string[] {
|
||||
const inspirations: string[] = [];
|
||||
|
||||
for (const entityId of Object.keys(entities)) {
|
||||
const entityEntry = entities[entityId];
|
||||
if (!entityEntry || computeDomain(entityId) !== domain) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entity = states[entityId];
|
||||
if (
|
||||
!entity ||
|
||||
entity.attributes.restored ||
|
||||
!entity.attributes.friendly_name
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let inspiration = `- ${entity.attributes.friendly_name}`;
|
||||
|
||||
// Get the category for this domain
|
||||
if (categories && categories[entityEntry.categories[domain]]) {
|
||||
inspiration += ` (category: ${categories[entityEntry.categories[domain]]})`;
|
||||
}
|
||||
|
||||
if (labels && entityEntry.labels.length) {
|
||||
inspiration += ` (labels: ${entityEntry.labels
|
||||
.map((label) => labels[label])
|
||||
.join(", ")})`;
|
||||
}
|
||||
|
||||
inspirations.push(inspiration);
|
||||
}
|
||||
|
||||
return inspirations;
|
||||
}
|
||||
// Always English to format lists in the prompt
|
||||
const PROMPT_LIST_FORMAT = new Intl.ListFormat("en", {
|
||||
style: "long",
|
||||
type: "conjunction",
|
||||
});
|
||||
|
||||
/**
|
||||
* Generates an AI task for suggesting metadata
|
||||
* for automations or scripts based on their configuration.
|
||||
* Generates an AI task for suggesting metadata based on their configuration.
|
||||
*
|
||||
* @param connection - Home Assistant connection
|
||||
* @param states - Current state objects
|
||||
* @param language - User's language preference
|
||||
* @param domain - The domain to suggest metadata for (automation, script)
|
||||
* @param domain - The domain to suggest metadata for
|
||||
* @param config - The configuration to suggest metadata for
|
||||
* @param inspirations - Existing entries to use as inspiration
|
||||
* @param include - The metadata fields to include in the suggestion
|
||||
* @returns Promise resolving to the AI task structure
|
||||
*/
|
||||
export async function generateMetadataSuggestionTask<T>(
|
||||
connection: HomeAssistant["connection"],
|
||||
states: HomeAssistant["states"],
|
||||
language: HomeAssistant["language"],
|
||||
domain: MetadataSuggestionDomain,
|
||||
config: T,
|
||||
include = SUGGESTION_INCLUDE_ALL
|
||||
inspirations: string[] = [],
|
||||
include = SUGGESTION_INCLUDE_DEFAULT
|
||||
): Promise<SuggestWithAIGenerateTask> {
|
||||
const [categories, entities, labels] = await Promise.all([
|
||||
const [categories, floors] = await Promise.all([
|
||||
include.categories
|
||||
? fetchCategories(connection, domain)
|
||||
: Promise.resolve(undefined),
|
||||
fetchEntities(connection),
|
||||
include.labels ? fetchLabels(connection) : Promise.resolve(undefined),
|
||||
include.floor ? fetchFloors(connection) : Promise.resolve(undefined),
|
||||
]);
|
||||
|
||||
const inspirations = buildMetadataInspirations(
|
||||
domain,
|
||||
states,
|
||||
entities,
|
||||
categories,
|
||||
labels
|
||||
);
|
||||
|
||||
const structure: AITaskStructure = {
|
||||
name: {
|
||||
description: `The name of the ${domain}`,
|
||||
required: true,
|
||||
selector: {
|
||||
text: {},
|
||||
...(include.name && {
|
||||
name: {
|
||||
description: `The name of the ${domain}`,
|
||||
required: true,
|
||||
selector: {
|
||||
text: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
...(include.description && {
|
||||
description: {
|
||||
description: `A short description of the ${domain}`,
|
||||
@@ -193,49 +114,83 @@ export async function generateMetadataSuggestionTask<T>(
|
||||
},
|
||||
},
|
||||
}),
|
||||
...(include.floor &&
|
||||
floors && {
|
||||
floor: {
|
||||
description: `The floor of the ${domain}`,
|
||||
required: false,
|
||||
selector: {
|
||||
select: {
|
||||
options: Object.values(floors).map((floor) => ({
|
||||
value: floor.floor_id,
|
||||
label: floor.name,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const categoryLabelText: string[] = [];
|
||||
if (include.categories) {
|
||||
categoryLabelText.push("category");
|
||||
}
|
||||
if (include.labels) {
|
||||
categoryLabelText.push("labels");
|
||||
}
|
||||
const categoryLabelString =
|
||||
categoryLabelText.length > 0 ? `, ${categoryLabelText.join(" and ")}` : "";
|
||||
const requestedParts = [
|
||||
include.name ? "a name" : null,
|
||||
include.description ? "a description" : null,
|
||||
include.categories ? "a category" : null,
|
||||
include.labels ? "labels" : null,
|
||||
include.floor ? "a floor" : null,
|
||||
].filter((entry): entry is string => entry !== null);
|
||||
|
||||
const categoryLabels: string[] = [
|
||||
include.categories ? "category" : null,
|
||||
include.labels ? "labels" : null,
|
||||
include.floor ? "floor" : null,
|
||||
].filter((entry): entry is string => entry !== null);
|
||||
|
||||
const categoryLabelsText = PROMPT_LIST_FORMAT.format(categoryLabels);
|
||||
|
||||
const requestedPartsText = requestedParts.length
|
||||
? PROMPT_LIST_FORMAT.format(requestedParts)
|
||||
: "suggestions";
|
||||
|
||||
return {
|
||||
type: "data",
|
||||
task: {
|
||||
task_name: `frontend__${domain}__save`,
|
||||
instructions: `Suggest in language "${language}" a name${include.description ? ", description" : ""}${categoryLabelString} for the following Home Assistant ${domain}.
|
||||
|
||||
The name should be relevant to the ${domain}'s purpose.
|
||||
${
|
||||
inspirations.length
|
||||
? `The name should be in same style and sentence capitalization as existing ${domain}s.${
|
||||
include.categories || include.labels
|
||||
? `
|
||||
Suggest ${categoryLabelText.join(" and ")} if relevant to the ${domain}'s purpose.
|
||||
Only suggest ${categoryLabelText.join(" and ")} that are already used by existing ${domain}s.`
|
||||
: ""
|
||||
}`
|
||||
: `The name should be short, descriptive, sentence case, and written in the language ${language}.`
|
||||
}${
|
||||
include.description
|
||||
? `
|
||||
If the ${domain} contains 5+ steps, include a short description.`
|
||||
: ""
|
||||
}
|
||||
|
||||
For inspiration, here are existing ${domain}s:
|
||||
${inspirations.join("\n")}
|
||||
|
||||
The ${domain} configuration is as follows:
|
||||
|
||||
${dump(config)}
|
||||
`,
|
||||
instructions: [
|
||||
`Suggest in language "${language}" ${requestedPartsText} for the following Home Assistant ${domain}.`,
|
||||
"",
|
||||
include.name
|
||||
? `The name should be relevant to the ${domain}'s purpose.`
|
||||
: `The suggestions should be relevant to the ${domain}'s purpose.`,
|
||||
...(inspirations.length
|
||||
? [
|
||||
...(include.name
|
||||
? [
|
||||
`The name should be in same style and sentence capitalization as existing ${domain}s.`,
|
||||
]
|
||||
: []),
|
||||
...(include.categories || include.labels || include.floor
|
||||
? [
|
||||
`Suggest ${categoryLabelsText} if relevant to the ${domain}'s purpose.`,
|
||||
`Only suggest ${categoryLabelsText} that are already used by existing ${domain}s.`,
|
||||
]
|
||||
: []),
|
||||
]
|
||||
: include.name
|
||||
? [
|
||||
`The name should be short, descriptive, sentence case, and written in the language ${language}.`,
|
||||
]
|
||||
: []),
|
||||
...(include.description
|
||||
? [`If the ${domain} contains 5+ steps, include a short description.`]
|
||||
: []),
|
||||
"",
|
||||
`For inspiration, here are existing ${domain}s:`,
|
||||
inspirations.join("\n"),
|
||||
"",
|
||||
`The ${domain} configuration is as follows:`,
|
||||
"",
|
||||
`${dump(config)}`,
|
||||
].join("\n"),
|
||||
structure,
|
||||
},
|
||||
};
|
||||
@@ -243,7 +198,7 @@ ${dump(config)}
|
||||
|
||||
/**
|
||||
* Processes the result of an AI task for suggesting metadata
|
||||
* for automations or scripts based on their configuration.
|
||||
* based on their configuration.
|
||||
*
|
||||
* @param connection - Home Assistant connection
|
||||
* @param domain - The domain of the ${domain}
|
||||
@@ -255,17 +210,18 @@ export async function processMetadataSuggestion(
|
||||
connection: HomeAssistant["connection"],
|
||||
domain: MetadataSuggestionDomain,
|
||||
result: GenDataTaskResult<MetadataSuggestionResult>,
|
||||
include: MetadataSuggestionInclude
|
||||
include = SUGGESTION_INCLUDE_DEFAULT
|
||||
): Promise<MetadataSuggestionResult> {
|
||||
const [categories, labels] = await Promise.all([
|
||||
const [categories, labels, floors] = await Promise.all([
|
||||
include.categories
|
||||
? fetchCategories(connection, domain)
|
||||
: Promise.resolve(undefined),
|
||||
include.labels ? fetchLabels(connection) : Promise.resolve(undefined),
|
||||
include.floor ? fetchFloors(connection) : Promise.resolve(undefined),
|
||||
]);
|
||||
|
||||
const processed: MetadataSuggestionResult = {
|
||||
name: result.data.name,
|
||||
name: include.name ? result.data.name : undefined,
|
||||
description: include.description ? result.data.description : undefined,
|
||||
};
|
||||
|
||||
@@ -302,5 +258,17 @@ export async function processMetadataSuggestion(
|
||||
}
|
||||
}
|
||||
|
||||
if (include.floor && floors && result.data.floor) {
|
||||
const floorId =
|
||||
result.data.floor in floors
|
||||
? result.data.floor
|
||||
: Object.entries(floors).find(
|
||||
([, floor]) => floor.name === result.data.floor
|
||||
)?.[0];
|
||||
if (floorId) {
|
||||
processed.floor = floorId;
|
||||
}
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
72
src/panels/config/common/suggest-metadata-helpers.ts
Normal file
72
src/panels/config/common/suggest-metadata-helpers.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { subscribeOne } from "../../../common/util/subscribe-one";
|
||||
import { subscribeAreaRegistry } from "../../../data/area/area_registry";
|
||||
import type { AreaRegistryEntry } from "../../../data/area/area_registry";
|
||||
import { fetchCategoryRegistry } from "../../../data/category_registry";
|
||||
import {
|
||||
subscribeEntityRegistry,
|
||||
type EntityRegistryEntry,
|
||||
} from "../../../data/entity/entity_registry";
|
||||
import { subscribeFloorRegistry } from "../../../data/ws-floor_registry";
|
||||
import type { FloorRegistryEntry } from "../../../data/floor_registry";
|
||||
import { subscribeLabelRegistry } from "../../../data/label/label_registry";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { MetadataSuggestionDomain } from "./suggest-metadata-ai";
|
||||
|
||||
export type Categories = Record<string, string>;
|
||||
export type Entities = Record<string, EntityRegistryEntry>;
|
||||
export type Labels = Record<string, string>;
|
||||
export type Floors = Record<string, FloorRegistryEntry>;
|
||||
export type Areas = Record<string, AreaRegistryEntry>;
|
||||
|
||||
const tryCatchEmptyObject = <T>(promise: Promise<T>): Promise<T> =>
|
||||
promise.catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error fetching data for suggestion: ", err);
|
||||
return {} as T;
|
||||
});
|
||||
|
||||
export const fetchCategories = (
|
||||
connection: HomeAssistant["connection"],
|
||||
domain: MetadataSuggestionDomain
|
||||
): Promise<Categories> =>
|
||||
tryCatchEmptyObject<Categories>(
|
||||
fetchCategoryRegistry(connection, domain).then((cats) =>
|
||||
Object.fromEntries(cats.map((cat) => [cat.category_id, cat.name]))
|
||||
)
|
||||
);
|
||||
|
||||
export const fetchLabels = (
|
||||
connection: HomeAssistant["connection"]
|
||||
): Promise<Labels> =>
|
||||
tryCatchEmptyObject<Labels>(
|
||||
subscribeOne(connection, subscribeLabelRegistry).then((labs) =>
|
||||
Object.fromEntries(labs.map((lab) => [lab.label_id, lab.name]))
|
||||
)
|
||||
);
|
||||
|
||||
export const fetchFloors = (
|
||||
connection: HomeAssistant["connection"]
|
||||
): Promise<Floors> =>
|
||||
tryCatchEmptyObject<Floors>(
|
||||
subscribeOne(connection, subscribeFloorRegistry).then((floors) =>
|
||||
Object.fromEntries(floors.map((floor) => [floor.floor_id, floor]))
|
||||
)
|
||||
);
|
||||
|
||||
export const fetchAreas = (
|
||||
connection: HomeAssistant["connection"]
|
||||
): Promise<Areas> =>
|
||||
tryCatchEmptyObject<Areas>(
|
||||
subscribeOne(connection, subscribeAreaRegistry).then((areas) =>
|
||||
Object.fromEntries(areas.map((area) => [area.area_id, area]))
|
||||
)
|
||||
);
|
||||
|
||||
export const fetchEntities = (
|
||||
connection: HomeAssistant["connection"]
|
||||
): Promise<Entities> =>
|
||||
tryCatchEmptyObject<Entities>(
|
||||
subscribeOne(connection, subscribeEntityRegistry).then((ents) =>
|
||||
Object.fromEntries(ents.map((ent) => [ent.entity_id, ent]))
|
||||
)
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user