Compare commits

..

102 Commits

Author SHA1 Message Date
Bram Kragten
5b0bd9d577 Merge branch 'rc' 2025-12-19 17:05:32 +01:00
Bram Kragten
d839152fd1 Bumped version to 20251203.3 2025-12-19 17:05:16 +01:00
Petar Petrov
407cb79805 Fix power sources graph ordering with multiple sources (#28549) 2025-12-19 17:04:50 +01:00
karwosts
7817ebe983 Home strategy: don't link non-admin to config pages (#28512) 2025-12-19 17:04:49 +01:00
Wendelin
7e58cedd49 Fix ha-toast z-index (#28491) 2025-12-19 17:04:48 +01:00
Wendelin
06334a039c Fix automation add TCA search icons (#28490)
Fix automation add TCA seach icons
2025-12-19 17:04:47 +01:00
Silas Krause
6e5853a1c0 Support legacy table styles in markdown (#28488)
* Remove unnecessary assist styles

* Fix list styles

* Remove table styles for role="presentation"
2025-12-19 17:04:46 +01:00
Wendelin
f4f4520773 Fix target picker area in history/activity (#28474)
* Add max target picker width for history and activity

* Fix target picker  area selection in history and activity
2025-12-19 17:04:45 +01:00
karwosts
94453dfba5 Fix markdown card image sizing (#28449) 2025-12-19 17:04:44 +01:00
Paul Bottein
0ce0247a2c 20251203.2 (#28443) 2025-12-08 17:30:04 +01:00
Paul Bottein
ce8cabbad9 Bumped version to 20251203.2 2025-12-08 17:29:01 +01:00
karwosts
0802841606 More unsafe description_placeholders fixes (#28416) 2025-12-08 17:28:52 +01:00
Nils Schönwald
cb93e1b741 Update snowflake to 6 sides (#28406) 2025-12-08 17:28:51 +01:00
dcapslock
30c383a2fc Energy strategies to refresh energy collection which allows to be used in custom dashboards (#28400)
* Energy strategies to refresh energy collection which allows to be used in custom dashboards

* Update src/panels/energy/strategies/energy-overview-view-strategy.ts

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* Only refresh if no prefs

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-12-08 17:28:50 +01:00
karwosts
73ee235fef Fix for undefined description_placeholders (#28395)
Another fix for undefined description_placeholders
2025-12-08 17:28:49 +01:00
Paul Bottein
31603ea7b2 20251203.1 (#28383) 2025-12-05 20:53:17 +01:00
Paul Bottein
17c1043cfc Bumped version to 20251203.1 2025-12-05 20:51:48 +01:00
Timothy
da255dce40 Add add to button in more info topbar for non admin users (#28365) 2025-12-05 20:51:20 +01:00
Paul Bottein
0c68072f8f Use non-admin endpoint to subscribe to one lab feature (#28352) 2025-12-05 20:51:19 +01:00
Petar Petrov
d197fd8f76 Fix calendar card not showing different colors for multiple calendars (#28338) 2025-12-05 20:51:18 +01:00
Paul Bottein
a961a87872 Move reorder areas and floors to floor overflow (#28335) 2025-12-05 20:51:17 +01:00
Petar Petrov
cc96c707b9 Fix markdown sections and styling (#28333) 2025-12-05 20:51:16 +01:00
Petar Petrov
4b73713f2a Fix gauge severity using entity state instead of attribute value (#28331) 2025-12-05 20:51:15 +01:00
Petar Petrov
c001102f15 Append current state to power-sources-graph (#28330) 2025-12-05 20:51:14 +01:00
Preet Patel
c1e5e0bfcb Fix energy dashboard redirect for device-consumption-only configs (#28322)
When users configure energy with only device consumption (no
grid/solar/battery/gas/water sources), the dashboard would redirect
to /config/energy instead of displaying. This occurred because
_generateLovelaceConfig() returned an empty views array.

The fix adds hasDeviceConsumption check and includes ENERGY_VIEW
when device consumption is configured, since energy-view-strategy
already supports device consumption cards.
2025-12-05 20:51:13 +01:00
Bram Kragten
a1412e90fd Add more info to the energy demo (#28316)
* Add more info to the energy demo

* Also add battery power
2025-12-05 20:51:12 +01:00
Petar Petrov
f6f40c1679 Always show energy-sources-table in overview (#28315) 2025-12-05 20:48:59 +01:00
Bram Kragten
d77bebe96b Bumped version to 20251203.0 2025-12-03 15:38:49 +01:00
Bram Kragten
1260af0b45 Fix add matter device my link (#28313) 2025-12-03 15:36:05 +01:00
Petar Petrov
1d37eec411 Fix label filter losing selections when searching (#28312) 2025-12-03 15:36:04 +01:00
Bram Kragten
5a52f83358 Fix sticky headers in TCA dialog when target is selected (#28310) 2025-12-03 15:36:03 +01:00
Aidan Timson
60724eb952 Add subscribeLabFeature function (#28309)
* Add subscribe to lab feature function

* Add docstrings to exported functions
2025-12-03 15:36:02 +01:00
Aidan Timson
de5778079e Add small rotation to snowflakes (#28308) 2025-12-03 15:36:01 +01:00
Wendelin
f3710650f2 Hide disabled devices in automation target tree (#28307) 2025-12-03 15:36:00 +01:00
Paul Bottein
feb35dbc4f Use svg for snowflakes (#28306) 2025-12-03 15:35:59 +01:00
Paul Bottein
ee9e101fa6 Rename unassigned areas to other areas (#28305) 2025-12-03 15:35:58 +01:00
Paul Bottein
24b16360a6 Use core area sorting everywhere (#28304) 2025-12-03 15:35:57 +01:00
Wendelin
109c81a00d Revert "Migrate updates dropdown to ha-dropdown" (#28303)
Revert "Migrate updates dropdown to ha-dropdown (#28039)"

This reverts commit ba9bab38c9.
2025-12-03 15:35:56 +01:00
Wendelin
eaa1ddbf61 Fix filtering of floors in getAreasAndFloorsItems function (#28302) 2025-12-03 15:35:55 +01:00
Paul Bottein
b11cb57a1e Always set ha-wa-dialog position to fixed (#28301) 2025-12-03 15:35:55 +01:00
Petar Petrov
87b5f58779 Add Y-axis label formatter to energy charts (#28298) 2025-12-03 15:35:53 +01:00
Petar Petrov
8dac53c672 Fix binary sensor history timeline not rendering properly (#28297) 2025-12-03 15:35:52 +01:00
Petar Petrov
d0966bf35a Hide empty System message in assist debug view (#28296) 2025-12-03 15:35:51 +01:00
Paul Bottein
6ba4fc0808 Handle not existing panels in dashboard config (#28292) 2025-12-03 15:35:50 +01:00
ildar170975
bd582ff816 computeLovelaceEntityName(): allow "number" names to be processed (#28231)
* allow "number" names to be processed

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-12-03 15:35:49 +01:00
Bram Kragten
d34bf83da0 Bumped version to 20251202.0 2025-12-02 16:02:32 +01:00
Wendelin
b0cfb31bf3 Automation add TCA: fix narrow subtitles & icons (#28291) 2025-12-02 16:02:25 +01:00
Wendelin
6c39e5d2c5 Use history to manage back button click in automations add TCA (#28289) 2025-12-02 16:02:24 +01:00
Paul Bottein
7b51e71092 Only show current weather in home overview (#28288) 2025-12-02 16:02:23 +01:00
Paul Bottein
8a82483685 Fix container alignment in section view (#28287) 2025-12-02 16:02:23 +01:00
Bram Kragten
bb691fa7a2 fix paste in add tca dialog (#28286) 2025-12-02 16:02:22 +01:00
Petar Petrov
2232db9c0f Update Energy dashboard layout (#28283) 2025-12-02 16:02:21 +01:00
Petar Petrov
5375665dc6 Fix index value for grid return in power sankey card (#28281) 2025-12-02 16:02:20 +01:00
Silas Krause
480122f40a Revert custom markdown styles (#28277) 2025-12-02 16:02:18 +01:00
karwosts
ee5c54030a Safer lookup of description_placeholders when service is invalid (#28273) 2025-12-02 16:02:17 +01:00
Paul Bottein
b73f50e864 Add dialog to reorder areas and floors (#28272) 2025-12-02 16:02:16 +01:00
eringerli
b9836073b7 fix stacking of multiple power sources (#28243) 2025-12-02 16:02:15 +01:00
Bram Kragten
a40512e0b5 Bumped version to 20251201.0 2025-12-01 16:35:54 +01:00
Paul Bottein
b2122570fb Clean reference to floor compare (#28269)
Fix floor compare
2025-12-01 16:35:34 +01:00
Paul Bottein
885f9333d2 Add helper for floor level (#28268)
* Add helper for floor level

* Update src/translations/en.json

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-12-01 16:35:33 +01:00
Aidan Timson
f812e7e9fb Match more-info-update backup preferences (#28266) 2025-12-01 16:35:32 +01:00
Wendelin
64dad39f6e Fix automation trigger ha icon (#28265) 2025-12-01 16:35:31 +01:00
Simon Lamon
df0fb423ed Include background in light, climate and security views (#28264)
* Include background

* Remove background key

* Add imports
2025-12-01 16:35:30 +01:00
Wendelin
4c3156f290 Respect system area sort in automation target tree (#28263) 2025-12-01 16:35:29 +01:00
Petar Petrov
ecdf374902 Reduce the duration of init animation for charts to 500ms (#28262)
Reduce the duration of init animation for charts
2025-12-01 16:35:29 +01:00
Aidan Timson
3e924e0cde Add missing key for labs to show in quick bar (#28261) 2025-12-01 16:35:27 +01:00
Bram Kragten
6fb71e12c8 Use name instead of description_configured for triggers and conditions (#28260) 2025-12-01 16:35:27 +01:00
Wendelin
6138aa5489 Fix ha-bottom-sheet closed event (#28257) 2025-12-01 16:35:26 +01:00
Aidan Timson
61e865d3a6 Fix 1px padding for subpage titles (#28256) 2025-12-01 16:35:24 +01:00
Aidan Timson
febcbf6242 Make labs toolbar icon use default color (#28255) 2025-12-01 16:35:23 +01:00
Petar Petrov
6a2fac6a9e Fix refresh in energy panel subviews (#28252) 2025-12-01 16:35:22 +01:00
karwosts
b60c5467fc Add water devices to energy data download (#28242) 2025-12-01 16:35:21 +01:00
Petar Petrov
ecd563406e Add power view and restructure energy dashboard layout (#28240) 2025-12-01 16:35:19 +01:00
Silas Krause
d5b66315e2 Fix markdown rendering for cached html (#28229)
* Render markdown table in wrapper.

* Fix markdown styles

* Fix formatting

* fix rendering for cache
2025-12-01 16:35:18 +01:00
karwosts
5b1719fc6e Add missing helper to language selector (#28218) 2025-12-01 16:35:17 +01:00
Silas Krause
add22cf2e9 Fix markdown styles regression (#28202)
* Render markdown table in wrapper.

* Fix markdown styles

* Fix formatting
2025-12-01 16:35:16 +01:00
Paul Bottein
21509191fa Fix ha icon size (#28201) 2025-12-01 16:35:15 +01:00
Paul Bottein
1a73cccf0d Fix safe area for sidebar section views in Android (#28194) 2025-12-01 16:35:14 +01:00
Aidan Timson
407d68250a Fix ha-wa-dialog fullscreen and make alerts not fullscreen (#28175) 2025-12-01 16:35:13 +01:00
Bram Kragten
38b7bd18bb Bumped version to 20251127.0 2025-11-27 17:06:57 +01:00
Wendelin
a00e944a35 Add TCA by target sort like item collections (#28192) 2025-11-27 17:06:30 +01:00
Petar Petrov
481569804e Fix water sankey calculation to include total supply from sources (#28191) 2025-11-27 17:06:29 +01:00
Paul Bottein
a1d7e270ff Add hint to reorder areas and floors (#28189) 2025-11-27 17:06:28 +01:00
Wendelin
225ccf1d2f Fix lab automations icons and sidebar width (#28184)
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-27 17:06:27 +01:00
Wendelin
4a5e1f9f3f "Add TCA" dialog desktop height to 800px (#28182) 2025-11-27 17:06:26 +01:00
Wendelin
b27b7210fd Show hidden entities in target tree (#28181)
* Show hidden entities in target tree

* Fix types
2025-11-27 17:06:25 +01:00
Petar Petrov
acd5181449 Fix sankey chart resizing (#28180) 2025-11-27 17:06:24 +01:00
Bram Kragten
b6b2d03a80 Always store token when using develop and serve (#28179) 2025-11-27 17:06:22 +01:00
Paul Bottein
7aee2b7cb7 Fix labs back button (#28174) 2025-11-27 17:06:21 +01:00
Paul Bottein
df1914cb7a Fix disabled dashboard picker when no custom dashboard (#28172) 2025-11-27 17:06:20 +01:00
Paul Bottein
6706d5904d Fix box shadow for sidebar tabs (#28170) 2025-11-27 17:06:19 +01:00
Wendelin
221aefd764 Fix automation add TCA autofocus (#28168)
Fix automation add tca autofocus
2025-11-27 17:06:18 +01:00
Paul Bottein
670057e8e6 Restore sidebar view when clicking back (#28167) 2025-11-27 17:06:17 +01:00
Wendelin
427e46201c Fix add condition default tab and blank styles (#28166) 2025-11-27 17:06:16 +01:00
Petar Petrov
fd1240f335 Refactor power sankey hierarchy to handle devices with not power sensor (#28164) 2025-11-27 17:06:15 +01:00
Petar Petrov
aa7670cb59 Disable axis pointer on the energy devices bar chart to fix refresh issues on touch devices (#28163) 2025-11-27 17:06:14 +01:00
Petar Petrov
468139229c Handle grouping by floor and area in power sankey card (#28162) 2025-11-27 17:06:13 +01:00
Simon Lamon
39752f0e3f Don't show more info for untracked consumption (#28151) 2025-11-27 17:06:12 +01:00
Petar Petrov
4d850d067f Replace gauges with energy usage graph in energy overview (#28150) 2025-11-27 17:06:10 +01:00
Paul Bottein
bcae64df88 Use hui-root for panel energy (#28149)
* Use hui-root for panel energy

* Review feedback

* Set empty prefs
2025-11-27 17:06:09 +01:00
Iván Pereira
690fd5a061 Fix hide sidebar tooltip on touchend events (#28042)
* fix: hide sidebar tooltip on touchend events

* Add a comment recommended by Copilot

* Clear timeouts id in disconnectedCallback
2025-11-27 17:06:08 +01:00
Bram Kragten
ac56c6df9a Bumped version to 20251126.0 2025-11-26 16:11:20 +01:00
542 changed files with 6432 additions and 9465 deletions

View File

@@ -21,12 +21,12 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with: with:
ref: dev ref: dev
- name: Setup Node - name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
node-version-file: ".nvmrc" node-version-file: ".nvmrc"
cache: yarn cache: yarn
@@ -56,12 +56,12 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with: with:
ref: master ref: master
- name: Setup Node - name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
node-version-file: ".nvmrc" node-version-file: ".nvmrc"
cache: yarn cache: yarn

View File

@@ -24,9 +24,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
node-version-file: ".nvmrc" node-version-file: ".nvmrc"
cache: yarn cache: yarn
@@ -37,7 +37,7 @@ jobs:
- name: Build resources - name: Build resources
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages
- name: Setup lint cache - name: Setup lint cache
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with: with:
path: | path: |
node_modules/.cache/prettier node_modules/.cache/prettier
@@ -58,9 +58,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
node-version-file: ".nvmrc" node-version-file: ".nvmrc"
cache: yarn cache: yarn
@@ -76,9 +76,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
node-version-file: ".nvmrc" node-version-file: ".nvmrc"
cache: yarn cache: yarn
@@ -89,7 +89,7 @@ jobs:
env: env:
IS_TEST: "true" IS_TEST: "true"
- name: Upload bundle stats - name: Upload bundle stats
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with: with:
name: frontend-bundle-stats name: frontend-bundle-stats
path: build/stats/*.json path: build/stats/*.json
@@ -100,9 +100,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
node-version-file: ".nvmrc" node-version-file: ".nvmrc"
cache: yarn cache: yarn
@@ -113,7 +113,7 @@ jobs:
env: env:
IS_TEST: "true" IS_TEST: "true"
- name: Upload bundle stats - name: Upload bundle stats
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with: with:
name: supervisor-bundle-stats name: supervisor-bundle-stats
path: build/stats/*.json path: build/stats/*.json

View File

@@ -23,7 +23,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with: with:
# We must fetch at least the immediate parents so that if this is # We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head. # a pull request then we can checkout the head.
@@ -36,14 +36,14 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8 uses: github/codeql-action/init@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8 uses: github/codeql-action/autobuild@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
# Command-line programs to run using the OS shell. # Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl # 📚 https://git.io/JvXDl
@@ -57,4 +57,4 @@ jobs:
# make release # make release
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8 uses: github/codeql-action/analyze@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4

View File

@@ -22,12 +22,12 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with: with:
ref: dev ref: dev
- name: Setup Node - name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
node-version-file: ".nvmrc" node-version-file: ".nvmrc"
cache: yarn cache: yarn
@@ -57,12 +57,12 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with: with:
ref: master ref: master
- name: Setup Node - name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
node-version-file: ".nvmrc" node-version-file: ".nvmrc"
cache: yarn cache: yarn

View File

@@ -16,10 +16,10 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
node-version-file: ".nvmrc" node-version-file: ".nvmrc"
cache: yarn cache: yarn

View File

@@ -21,10 +21,10 @@ jobs:
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview') if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
node-version-file: ".nvmrc" node-version-file: ".nvmrc"
cache: yarn cache: yarn

View File

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

View File

@@ -20,15 +20,15 @@ jobs:
contents: write contents: write
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python ${{ env.PYTHON_VERSION }} - name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6 uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6
with: with:
python-version: ${{ env.PYTHON_VERSION }} python-version: ${{ env.PYTHON_VERSION }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
node-version-file: ".nvmrc" node-version-file: ".nvmrc"
cache: yarn cache: yarn
@@ -57,14 +57,14 @@ jobs:
run: tar -czvf translations.tar.gz translations run: tar -czvf translations.tar.gz translations
- name: Upload build artifacts - name: Upload build artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with: with:
name: wheels name: wheels
path: dist/home_assistant_frontend*.whl path: dist/home_assistant_frontend*.whl
if-no-files-found: error if-no-files-found: error
- name: Upload translations - name: Upload translations
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with: with:
name: translations name: translations
path: translations.tar.gz path: translations.tar.gz

View File

@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Send bundle stats and build information to RelativeCI - name: Send bundle stats and build information to RelativeCI
uses: relative-ci/agent-action@c45aaa919ef85620af54242a241ac17a8fa35983 # v3.2.1 uses: relative-ci/agent-action@feb19ddc698445db27401f1490f6ac182da0816f # v3.2.0
with: with:
key: ${{ secrets[format('RELATIVE_CI_KEY_{0}_{1}', matrix.bundle, matrix.build)] }} key: ${{ secrets[format('RELATIVE_CI_KEY_{0}_{1}', matrix.bundle, matrix.build)] }}
token: ${{ github.token }} token: ${{ github.token }}

View File

@@ -23,10 +23,10 @@ jobs:
contents: write # Required to upload release assets contents: write # Required to upload release assets
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python ${{ env.PYTHON_VERSION }} - name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with: with:
python-version: ${{ env.PYTHON_VERSION }} python-version: ${{ env.PYTHON_VERSION }}
@@ -34,7 +34,7 @@ jobs:
uses: home-assistant/actions/helpers/verify-version@master uses: home-assistant/actions/helpers/verify-version@master
- name: Setup Node - name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
node-version-file: ".nvmrc" node-version-file: ".nvmrc"
cache: yarn cache: yarn
@@ -55,7 +55,7 @@ jobs:
script/release script/release
- name: Upload release assets - name: Upload release assets
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
with: with:
files: | files: |
dist/*.whl dist/*.whl
@@ -75,7 +75,7 @@ jobs:
# home-assistant/wheels doesn't support SHA pinning # home-assistant/wheels doesn't support SHA pinning
- name: Build wheels - name: Build wheels
uses: home-assistant/wheels@2025.12.0 uses: home-assistant/wheels@2025.10.0
with: with:
abi: cp313 abi: cp313
tag: musllinux_1_2 tag: musllinux_1_2
@@ -91,9 +91,9 @@ jobs:
contents: write # Required to upload release assets contents: write # Required to upload release assets
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
node-version-file: ".nvmrc" node-version-file: ".nvmrc"
cache: yarn cache: yarn
@@ -108,7 +108,7 @@ jobs:
- name: Tar folder - name: Tar folder
run: tar -czf landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz -C landing-page/dist . run: tar -czf landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz -C landing-page/dist .
- name: Upload release asset - name: Upload release asset
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
with: with:
files: landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz files: landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz
@@ -120,9 +120,9 @@ jobs:
contents: write # Required to upload release assets contents: write # Required to upload release assets
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
node-version-file: ".nvmrc" node-version-file: ".nvmrc"
cache: yarn cache: yarn
@@ -137,6 +137,6 @@ jobs:
- name: Tar folder - name: Tar folder
run: tar -czf hassio/home_assistant_frontend_supervisor-${{ github.event.release.tag_name }}.tar.gz -C hassio/build . run: tar -czf hassio/home_assistant_frontend_supervisor-${{ github.event.release.tag_name }}.tar.gz -C hassio/build .
- name: Upload release asset - name: Upload release asset
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
with: with:
files: hassio/home_assistant_frontend_supervisor-${{ github.event.release.tag_name }}.tar.gz files: hassio/home_assistant_frontend_supervisor-${{ github.event.release.tag_name }}.tar.gz

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: 90 days stale policy - name: 90 days stale policy
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 90 days-before-stale: 90

View File

@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Upload Translations - name: Upload Translations
run: | run: |

1
.gitignore vendored
View File

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

2
.nvmrc
View File

@@ -1 +1 @@
24.12.0 22.21.1

View File

@@ -305,8 +305,9 @@ export class HcMain extends HassElement {
await llColl.refresh(); await llColl.refresh();
this._unsubLovelace = llColl.subscribe(async (rawConfig) => { this._unsubLovelace = llColl.subscribe(async (rawConfig) => {
if (isStrategyDashboard(rawConfig)) { if (isStrategyDashboard(rawConfig)) {
const { generateLovelaceDashboardStrategy } = const { generateLovelaceDashboardStrategy } = await import(
await import("../../../../src/panels/lovelace/strategies/get-strategy"); "../../../../src/panels/lovelace/strategies/get-strategy"
);
const config = await generateLovelaceDashboardStrategy( const config = await generateLovelaceDashboardStrategy(
rawConfig, rawConfig,
this.hass! this.hass!
@@ -346,8 +347,9 @@ export class HcMain extends HassElement {
} }
private async _generateDefaultLovelaceConfig() { private async _generateDefaultLovelaceConfig() {
const { generateLovelaceDashboardStrategy } = const { generateLovelaceDashboardStrategy } = await import(
await import("../../../../src/panels/lovelace/strategies/get-strategy"); "../../../../src/panels/lovelace/strategies/get-strategy"
);
this._handleNewLovelaceConfig( this._handleNewLovelaceConfig(
await generateLovelaceDashboardStrategy(DEFAULT_CONFIG, this.hass!) await generateLovelaceDashboardStrategy(DEFAULT_CONFIG, this.hass!)
); );

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +0,0 @@
---
title: Adaptive dialog (ha-adaptive-dialog)
---

View File

@@ -1,732 +0,0 @@
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import { mdiCog, mdiHelp } from "@mdi/js";
import "../../../../src/components/ha-button";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-dialog-footer";
import "../../../../src/components/ha-adaptive-dialog";
import "../../../../src/components/ha-form/ha-form";
import "../../../../src/components/ha-icon-button";
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
const SCHEMA: HaFormSchema[] = [
{ type: "string", name: "Name", default: "", autofocus: true },
{ type: "string", name: "Email", default: "" },
];
type DialogType =
| false
| "basic"
| "basic-subtitle-below"
| "basic-subtitle-above"
| "form"
| "form-block-mode"
| "actions"
| "large"
| "small";
@customElement("demo-components-ha-adaptive-dialog")
export class DemoHaAdaptiveDialog extends LitElement {
@state() private _openDialog: DialogType = false;
@state() private _hass?: HomeAssistant;
protected firstUpdated() {
const hass = provideHass(this);
this._hass = hass;
}
protected render() {
return html`
<div class="content">
<h1>Adaptive dialog <code>&lt;ha-adaptive-dialog&gt;</code></h1>
<p class="subtitle">
Responsive dialog component that automatically switches between a full
dialog and bottom sheet based on screen size.
</p>
<h2>Demos</h2>
<div class="buttons">
<ha-button @click=${this._handleOpenDialog("basic")}
>Basic adaptive dialog</ha-button
>
<ha-button @click=${this._handleOpenDialog("basic-subtitle-below")}
>Adaptive dialog with subtitle below</ha-button
>
<ha-button @click=${this._handleOpenDialog("basic-subtitle-above")}
>Adaptive dialog with subtitle above</ha-button
>
<ha-button @click=${this._handleOpenDialog("small")}
>Small width adaptive dialog</ha-button
>
<ha-button @click=${this._handleOpenDialog("large")}
>Large width adaptive dialog</ha-button
>
<ha-button @click=${this._handleOpenDialog("form")}
>Adaptive dialog with form</ha-button
>
<ha-button @click=${this._handleOpenDialog("form-block-mode")}
>Adaptive dialog with form (block mode change)</ha-button
>
<ha-button @click=${this._handleOpenDialog("actions")}
>Adaptive dialog with actions</ha-button
>
</div>
<ha-card>
<div class="card-content">
<p>
<strong>Tip:</strong> Resize your browser window to see the
responsive behavior. The dialog automatically switches to a bottom
sheet on narrow screens (&lt;870px width) or short screens
(&lt;500px height).
</p>
</div>
</ha-card>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "basic"}
header-title="Basic adaptive dialog"
@closed=${this._handleClosed}
>
<div>Adaptive dialog content</div>
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "basic-subtitle-below"}
header-title="Adaptive dialog with subtitle"
header-subtitle="This is an adaptive dialog with a subtitle below"
@closed=${this._handleClosed}
>
<div>Adaptive dialog content</div>
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "basic-subtitle-above"}
header-title="Adaptive dialog with subtitle above"
header-subtitle="This is an adaptive dialog with a subtitle above"
header-subtitle-position="above"
@closed=${this._handleClosed}
>
<div>Adaptive dialog content</div>
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "small"}
width="small"
header-title="Small adaptive dialog"
@closed=${this._handleClosed}
>
<div>This dialog uses the small width preset (320px).</div>
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "large"}
width="large"
header-title="Large adaptive dialog"
@closed=${this._handleClosed}
>
<div>This dialog uses the large width preset (1024px).</div>
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "form"}
header-title="Adaptive dialog with form"
header-subtitle="This is an adaptive dialog with a form"
@closed=${this._handleClosed}
>
<ha-form autofocus .schema=${SCHEMA}></ha-form>
<ha-dialog-footer slot="footer">
<ha-button
@click=${this._handleClosed}
slot="secondaryAction"
variant="plain"
>Cancel</ha-button
>
<ha-button
@click=${this._handleClosed}
slot="primaryAction"
variant="accent"
>Submit</ha-button
>
</ha-dialog-footer>
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "form-block-mode"}
header-title="Adaptive dialog with form (block mode change)"
header-subtitle="This form will not reset when the viewport size changes"
block-mode-change
@closed=${this._handleClosed}
>
<ha-form autofocus .schema=${SCHEMA}></ha-form>
<ha-dialog-footer slot="footer">
<ha-button
@click=${this._handleClosed}
slot="secondaryAction"
variant="plain"
>Cancel</ha-button
>
<ha-button
@click=${this._handleClosed}
slot="primaryAction"
variant="accent"
>Submit</ha-button
>
</ha-dialog-footer>
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "actions"}
header-title="Adaptive dialog with actions"
header-subtitle="This is an adaptive dialog with header actions"
@closed=${this._handleClosed}
>
<div slot="headerActionItems">
<ha-icon-button label="Settings" path=${mdiCog}></ha-icon-button>
<ha-icon-button label="Help" path=${mdiHelp}></ha-icon-button>
</div>
<div>Adaptive dialog content</div>
</ha-adaptive-dialog>
<h2>Design</h2>
<h3>Responsive behavior</h3>
<p>
The <code>ha-adaptive-dialog</code> component automatically switches
between two modes based on screen size:
</p>
<ul>
<li>
<strong>Dialog mode:</strong> Used on larger screens (width &gt;
870px and height &gt; 500px). Renders as a centered dialog using
<code>ha-wa-dialog</code>.
</li>
<li>
<strong>Bottom sheet mode:</strong> Used on mobile devices and
smaller screens (width ≤ 870px or height ≤ 500px). Renders as a
drawer from the bottom using <code>ha-bottom-sheet</code>.
</li>
</ul>
<p>
The mode is determined automatically and updates when the window is
resized. To prevent mode changes after the initial mount (useful for
preventing form resets), use the <code>block-mode-change</code>
attribute.
</p>
<h3>Width</h3>
<p>
In dialog mode, there are multiple width presets available. These are
ignored in bottom sheet mode.
</p>
<table>
<thead>
<tr>
<th>Name</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>small</code></td>
<td><code>min(320px, var(--full-width))</code></td>
</tr>
<tr>
<td><code>medium</code></td>
<td><code>min(580px, var(--full-width))</code></td>
</tr>
<tr>
<td><code>large</code></td>
<td><code>min(1024px, var(--full-width))</code></td>
</tr>
<tr>
<td><code>full</code></td>
<td><code>var(--full-width)</code></td>
</tr>
</tbody>
</table>
<p>Adaptive dialogs have a default width of <code>medium</code>.</p>
<h3>Header</h3>
<p>
The header contains a navigation icon, title, subtitle, and action
items.
</p>
<table>
<thead>
<tr>
<th>Slot</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>headerNavigationIcon</code></td>
<td>
Leading header action (e.g., close/back button). In bottom sheet
mode, defaults to a close button if not provided.
</td>
</tr>
<tr>
<td><code>headerTitle</code></td>
<td>The header title content.</td>
</tr>
<tr>
<td><code>headerSubtitle</code></td>
<td>The header subtitle content.</td>
</tr>
<tr>
<td><code>headerActionItems</code></td>
<td>Trailing header actions (e.g., icon buttons, menus).</td>
</tr>
</tbody>
</table>
<h4>Header title</h4>
<p>
The header title can be set using the <code>header-title</code>
attribute or by providing custom content in the
<code>headerTitle</code> slot.
</p>
<h4>Header subtitle</h4>
<p>
The header subtitle can be set using the
<code>header-subtitle</code> attribute or by providing custom content
in the <code>headerSubtitle</code> slot. The subtitle position
relative to the title can be controlled with the
<code>header-subtitle-position</code> attribute.
</p>
<h4>Header navigation icon</h4>
<p>
In bottom sheet mode, a close button is automatically provided if no
custom navigation icon is specified. In dialog mode, the dialog can be
closed via the standard dialog close button.
</p>
<h4>Header action items</h4>
<p>
The header action items usually contain icon buttons and/or menu
buttons.
</p>
<h3>Body</h3>
<p>The body is the content of the adaptive dialog.</p>
<h3>Footer</h3>
<p>The footer is the footer of the adaptive dialog.</p>
<p>
It is recommended to use the <code>ha-dialog-footer</code> component
for the footer and to style the buttons inside the footer as follows:
</p>
<table>
<thead>
<tr>
<th>Slot</th>
<th>Description</th>
<th>Variant to use</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>secondaryAction</code></td>
<td>The secondary action button(s).</td>
<td><code>plain</code></td>
</tr>
<tr>
<td><code>primaryAction</code></td>
<td>The primary action button(s).</td>
<td><code>accent</code></td>
</tr>
</tbody>
</table>
<h2>Implementation</h2>
<h3>When to use</h3>
<p>
Use <code>ha-adaptive-dialog</code> when you need a dialog that should
adapt to different screen sizes automatically. This is particularly
useful for:
</p>
<ul>
<li>Forms and data entry that need to work well on mobile devices</li>
<li>
Content that benefits from full-screen presentation on small devices
</li>
<li>
Interfaces that need consistent behavior across desktop and mobile
</li>
</ul>
<p>
If you don't need responsive behavior, use
<code>ha-wa-dialog</code> directly for desktop-only dialogs or
<code>ha-bottom-sheet</code> for mobile-only sheets.
</p>
<p>
Use the <code>block-mode-change</code> attribute when you want to
prevent the dialog from switching modes after it's opened. This is
especially useful for forms, as it prevents form data from being lost
when users resize their browser window.
</p>
<h3>Example usage</h3>
<pre><code>&lt;ha-adaptive-dialog
.hass=\${this.hass}
open
width="medium"
header-title="Dialog title"
header-subtitle="Dialog subtitle"
&gt;
&lt;div slot="headerActionItems"&gt;
&lt;ha-icon-button label="Settings" path="mdiCog"&gt;&lt;/ha-icon-button&gt;
&lt;ha-icon-button label="Help" path="mdiHelp"&gt;&lt;/ha-icon-button&gt;
&lt;/div&gt;
&lt;div&gt;Dialog content&lt;/div&gt;
&lt;ha-dialog-footer slot="footer"&gt;
&lt;ha-button slot="secondaryAction" variant="plain"
&gt;Cancel&lt;/ha-button
&gt;
&lt;ha-button slot="primaryAction" variant="accent"&gt;Submit&lt;/ha-button&gt;
&lt;/ha-dialog-footer&gt;
&lt;/ha-adaptive-dialog&gt;</code></pre>
<p>Example with <code>block-mode-change</code> for forms:</p>
<pre><code>&lt;ha-adaptive-dialog
.hass=\${this.hass}
open
header-title="Edit configuration"
block-mode-change
&gt;
&lt;ha-form .schema=\${schema} .data=\${data}&gt;&lt;/ha-form&gt;
&lt;ha-dialog-footer slot="footer"&gt;
&lt;ha-button slot="secondaryAction" variant="plain"
&gt;Cancel&lt;/ha-button
&gt;
&lt;ha-button slot="primaryAction" variant="accent"&gt;Save&lt;/ha-button&gt;
&lt;/ha-dialog-footer&gt;
&lt;/ha-adaptive-dialog&gt;</code></pre>
<h3>API</h3>
<p>
This component combines <code>ha-wa-dialog</code> and
<code>ha-bottom-sheet</code> with automatic mode switching based on
screen size.
</p>
<h4>Attributes</h4>
<table>
<thead>
<tr>
<th>Attribute</th>
<th>Description</th>
<th>Default</th>
<th>Options</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>open</code></td>
<td>Controls the adaptive dialog open state.</td>
<td><code>false</code></td>
<td><code>false</code>, <code>true</code></td>
</tr>
<tr>
<td><code>width</code></td>
<td>
Preferred dialog width preset (dialog mode only, ignored in
bottom sheet mode).
</td>
<td><code>medium</code></td>
<td>
<code>small</code>, <code>medium</code>, <code>large</code>,
<code>full</code>
</td>
</tr>
<tr>
<td><code>header-title</code></td>
<td>Header title text when no custom title slot is provided.</td>
<td></td>
<td></td>
</tr>
<tr>
<td><code>header-subtitle</code></td>
<td>
Header subtitle text when no custom subtitle slot is provided.
</td>
<td></td>
<td></td>
</tr>
<tr>
<td><code>header-subtitle-position</code></td>
<td>Position of the subtitle relative to the title.</td>
<td><code>below</code></td>
<td><code>above</code>, <code>below</code></td>
</tr>
<tr>
<td><code>aria-labelledby</code></td>
<td>
The ID of the element that labels the dialog (for
accessibility).
</td>
<td></td>
<td></td>
</tr>
<tr>
<td><code>aria-describedby</code></td>
<td>
The ID of the element that describes the dialog (for
accessibility).
</td>
<td></td>
<td></td>
</tr>
<tr>
<td><code>block-mode-change</code></td>
<td>
When set, the mode is determined at mount time based on the
current screen size, but subsequent mode changes are blocked.
Useful for preventing forms from resetting when the viewport
size changes.
</td>
<td><code>false</code></td>
<td><code>false</code>, <code>true</code></td>
</tr>
</tbody>
</table>
<h4>CSS custom properties</h4>
<table>
<thead>
<tr>
<th>CSS Property</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>--ha-dialog-surface-background</code></td>
<td>Dialog/sheet background color.</td>
</tr>
<tr>
<td><code>--ha-dialog-border-radius</code></td>
<td>Border radius of the dialog surface (dialog mode only).</td>
</tr>
<tr>
<td><code>--ha-dialog-show-duration</code></td>
<td>Show animation duration (dialog mode only).</td>
</tr>
<tr>
<td><code>--ha-dialog-hide-duration</code></td>
<td>Hide animation duration (dialog mode only).</td>
</tr>
</tbody>
</table>
<h4>Events</h4>
<table>
<thead>
<tr>
<th>Event</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>opened</code></td>
<td>
Fired when the adaptive dialog is shown (dialog mode only).
</td>
</tr>
<tr>
<td><code>closed</code></td>
<td>
Fired after the adaptive dialog is hidden (dialog mode only).
</td>
</tr>
<tr>
<td><code>after-show</code></td>
<td>Fired after show animation completes (dialog mode only).</td>
</tr>
</tbody>
</table>
<h3>Focus management</h3>
<p>
To automatically focus an element when the adaptive dialog opens, add
the
<code>autofocus</code> attribute to it. Components with
<code>delegatesFocus: true</code> (like <code>ha-form</code>) will
forward focus to their first focusable child.
</p>
<p>Example:</p>
<pre><code>&lt;ha-adaptive-dialog .hass=\${this.hass} open&gt;
&lt;ha-form autofocus .schema=\${schema}&gt;&lt;/ha-form&gt;
&lt;/ha-adaptive-dialog&gt;</code></pre>
</div>
`;
}
private _handleOpenDialog = (dialog: DialogType) => () => {
this._openDialog = dialog;
};
private _handleClosed = () => {
this._openDialog = false;
};
static styles = [
css`
:host {
display: block;
padding: var(--ha-space-4);
}
.content {
max-width: 1000px;
margin: 0 auto;
}
h1 {
margin-top: 0;
margin-bottom: var(--ha-space-2);
}
h2 {
margin-top: var(--ha-space-6);
margin-bottom: var(--ha-space-3);
}
h3,
h4 {
margin-top: var(--ha-space-4);
margin-bottom: var(--ha-space-2);
}
p {
margin: var(--ha-space-2) 0;
line-height: 1.6;
}
ul {
margin: var(--ha-space-2) 0;
padding-left: var(--ha-space-5);
}
li {
margin: var(--ha-space-1) 0;
line-height: 1.6;
}
.subtitle {
color: var(--secondary-text-color);
font-size: 1.1em;
margin-bottom: var(--ha-space-4);
}
table {
width: 100%;
border-collapse: collapse;
margin: var(--ha-space-3) 0;
}
th,
td {
text-align: left;
padding: var(--ha-space-2);
border-bottom: 1px solid var(--divider-color);
}
th {
font-weight: 500;
}
code {
background-color: var(--secondary-background-color);
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
font-size: 0.9em;
}
pre {
background-color: var(--secondary-background-color);
padding: var(--ha-space-3);
border-radius: 8px;
overflow-x: auto;
margin: var(--ha-space-3) 0;
}
pre code {
background-color: transparent;
padding: 0;
}
.buttons {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--ha-space-2);
margin: var(--ha-space-4) 0;
}
.card-content {
padding: var(--ha-space-3);
}
a {
color: var(--primary-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-adaptive-dialog": DemoHaAdaptiveDialog;
}
}

View File

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

View File

@@ -13,9 +13,9 @@ import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row"; import "../../../../src/components/ha-settings-row";
import type { AreaRegistryEntry } from "../../../../src/data/area_registry"; import type { AreaRegistryEntry } from "../../../../src/data/area_registry";
import type { BlueprintInput } from "../../../../src/data/blueprint"; import type { BlueprintInput } from "../../../../src/data/blueprint";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry"; import type { DeviceRegistryEntry } from "../../../../src/data/device_registry";
import type { FloorRegistryEntry } from "../../../../src/data/floor_registry"; import type { FloorRegistryEntry } from "../../../../src/data/floor_registry";
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry"; import type { LabelRegistryEntry } from "../../../../src/data/label_registry";
import { showDialog } from "../../../../src/dialogs/make-dialog-manager"; import { showDialog } from "../../../../src/dialogs/make-dialog-manager";
import { getEntity } from "../../../../src/fake_data/entity"; import { getEntity } from "../../../../src/fake_data/entity";
import { provideHass } from "../../../../src/fake_data/provide_hass"; import { provideHass } from "../../../../src/fake_data/provide_hass";

View File

@@ -139,7 +139,7 @@ export class DemoHaWaDialog extends LitElement {
</tr> </tr>
<tr> <tr>
<td><code>large</code></td> <td><code>large</code></td>
<td><code>min(1024px, var(--full-width))</code></td> <td><code>min(720px, var(--full-width))</code></td>
</tr> </tr>
<tr> <tr>
<td><code>full</code></td> <td><code>full</code></td>

View File

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

View File

@@ -88,8 +88,8 @@ class HassioRegistriesDialog extends LitElement {
<ha-button <ha-button
?disabled=${Boolean( ?disabled=${Boolean(
!this._input.registry || !this._input.registry ||
!this._input.username || !this._input.username ||
!this._input.password !this._input.password
)} )}
@click=${this._addNewRegistry} @click=${this._addNewRegistry}
appearance="filled" appearance="filled"

View File

@@ -34,7 +34,7 @@
"@codemirror/legacy-modes": "6.5.2", "@codemirror/legacy-modes": "6.5.2",
"@codemirror/search": "6.5.11", "@codemirror/search": "6.5.11",
"@codemirror/state": "6.5.2", "@codemirror/state": "6.5.2",
"@codemirror/view": "6.39.4", "@codemirror/view": "6.38.8",
"@date-fns/tz": "1.4.1", "@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17", "@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "6.18.2", "@formatjs/intl-datetimeformat": "6.18.2",
@@ -52,7 +52,7 @@
"@fullcalendar/list": "6.1.19", "@fullcalendar/list": "6.1.19",
"@fullcalendar/luxon3": "6.1.19", "@fullcalendar/luxon3": "6.1.19",
"@fullcalendar/timegrid": "6.1.19", "@fullcalendar/timegrid": "6.1.19",
"@home-assistant/webawesome": "3.0.0-ha.2", "@home-assistant/webawesome": "3.0.0-ha.0",
"@lezer/highlight": "1.2.3", "@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.0.9", "@lit-labs/motion": "1.0.9",
"@lit-labs/observers": "2.0.6", "@lit-labs/observers": "2.0.6",
@@ -89,8 +89,8 @@
"@thomasloven/round-slider": "0.6.0", "@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "3.9.1", "@tsparticles/engine": "3.9.1",
"@tsparticles/preset-links": "3.2.0", "@tsparticles/preset-links": "3.2.0",
"@vaadin/combo-box": "24.9.6", "@vaadin/combo-box": "24.9.5",
"@vaadin/vaadin-themable-mixin": "24.9.6", "@vaadin/vaadin-themable-mixin": "24.9.5",
"@vibrant/color": "4.0.0", "@vibrant/color": "4.0.0",
"@vue/web-component-wrapper": "1.3.0", "@vue/web-component-wrapper": "1.3.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10", "@webcomponents/scoped-custom-element-registry": "0.0.10",
@@ -135,7 +135,7 @@
"stacktrace-js": "2.0.2", "stacktrace-js": "2.0.2",
"superstruct": "2.0.2", "superstruct": "2.0.2",
"tinykeys": "3.0.0", "tinykeys": "3.0.0",
"ua-parser-js": "2.0.7", "ua-parser-js": "2.0.6",
"vue": "2.7.16", "vue": "2.7.16",
"vue2-daterange-picker": "0.6.8", "vue2-daterange-picker": "0.6.8",
"weekstart": "2.0.0", "weekstart": "2.0.0",
@@ -152,13 +152,13 @@
"@babel/helper-define-polyfill-provider": "0.6.5", "@babel/helper-define-polyfill-provider": "0.6.5",
"@babel/plugin-transform-runtime": "7.28.5", "@babel/plugin-transform-runtime": "7.28.5",
"@babel/preset-env": "7.28.5", "@babel/preset-env": "7.28.5",
"@bundle-stats/plugin-webpack-filter": "4.21.7", "@bundle-stats/plugin-webpack-filter": "4.21.6",
"@lokalise/node-api": "15.4.0", "@lokalise/node-api": "15.4.0",
"@octokit/auth-oauth-device": "8.0.3", "@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.0.3", "@octokit/plugin-retry": "8.0.3",
"@octokit/rest": "22.0.1", "@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.3.15", "@rsdoctor/rspack-plugin": "1.3.11",
"@rspack/core": "1.6.7", "@rspack/core": "1.6.4",
"@rspack/dev-server": "1.1.4", "@rspack/dev-server": "1.1.4",
"@types/babel__plugin-transform-runtime": "7.9.5", "@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.22", "@types/chromecast-caf-receiver": "6.0.22",
@@ -178,12 +178,12 @@
"@types/tar": "6.1.13", "@types/tar": "6.1.13",
"@types/ua-parser-js": "0.7.39", "@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29", "@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.0.15", "@vitest/coverage-v8": "4.0.13",
"babel-loader": "10.0.0", "babel-loader": "10.0.0",
"babel-plugin-template-html-minifier": "4.1.0", "babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3", "browserslist-useragent-regexp": "4.1.3",
"del": "8.0.1", "del": "8.0.1",
"eslint": "9.39.2", "eslint": "9.39.1",
"eslint-config-airbnb-base": "15.0.0", "eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.8", "eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.10", "eslint-import-resolver-webpack": "0.13.10",
@@ -194,14 +194,14 @@
"eslint-plugin-wc": "3.0.2", "eslint-plugin-wc": "3.0.2",
"fancy-log": "2.0.0", "fancy-log": "2.0.0",
"fs-extra": "11.3.2", "fs-extra": "11.3.2",
"glob": "13.0.0", "glob": "12.0.0",
"gulp": "5.0.1", "gulp": "5.0.1",
"gulp-brotli": "3.0.0", "gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0", "gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0", "gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0", "html-minifier-terser": "7.2.0",
"husky": "9.1.7", "husky": "9.1.7",
"jsdom": "27.3.0", "jsdom": "27.2.0",
"jszip": "3.10.1", "jszip": "3.10.1",
"lint-staged": "16.2.7", "lint-staged": "16.2.7",
"lit-analyzer": "2.0.3", "lit-analyzer": "2.0.3",
@@ -209,17 +209,17 @@
"lodash.template": "4.5.0", "lodash.template": "4.5.0",
"map-stream": "0.0.7", "map-stream": "0.0.7",
"pinst": "3.0.0", "pinst": "3.0.0",
"prettier": "3.7.4", "prettier": "3.6.2",
"rspack-manifest-plugin": "5.2.0", "rspack-manifest-plugin": "5.2.0",
"serve": "14.2.5", "serve": "14.2.5",
"sinon": "21.0.0", "sinon": "21.0.0",
"tar": "7.5.2", "tar": "7.5.2",
"terser-webpack-plugin": "5.3.16", "terser-webpack-plugin": "5.3.14",
"ts-lit-plugin": "2.0.2", "ts-lit-plugin": "2.0.2",
"typescript": "5.9.3", "typescript": "5.9.3",
"typescript-eslint": "8.49.0", "typescript-eslint": "8.47.0",
"vite-tsconfig-paths": "6.0.1", "vite-tsconfig-paths": "5.1.4",
"vitest": "4.0.15", "vitest": "4.0.13",
"webpack-stats-plugin": "1.1.3", "webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0", "webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch" "workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
@@ -238,6 +238,6 @@
}, },
"packageManager": "yarn@4.12.0", "packageManager": "yarn@4.12.0",
"volta": { "volta": {
"node": "24.12.0" "node": "22.21.1"
} }
} }

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "home-assistant-frontend" name = "home-assistant-frontend"
version = "20251203.0" version = "20251203.3"
license = "Apache-2.0" license = "Apache-2.0"
license-files = ["LICENSE*"] license-files = ["LICENSE*"]
description = "The Home Assistant frontend" description = "The Home Assistant frontend"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -45,8 +45,9 @@ export const computeFormatFunctions = async (
formatEntityAttributeName: FormatEntityAttributeNameFunc; formatEntityAttributeName: FormatEntityAttributeNameFunc;
formatEntityName: FormatEntityNameFunc; formatEntityName: FormatEntityNameFunc;
}> => { }> => {
const { computeStateDisplay } = const { computeStateDisplay } = await import(
await import("../entity/compute_state_display"); "../entity/compute_state_display"
);
const { computeAttributeValueDisplay, computeAttributeNameDisplay } = const { computeAttributeValueDisplay, computeAttributeNameDisplay } =
await import("../entity/compute_attribute_display"); await import("../entity/compute_attribute_display");

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import type { PropertyValues } from "lit"; import type { PropertyValues } from "lit";
import { html, LitElement } from "lit"; import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators"; import { property, state } from "lit/decorators";
import type { VisualMapComponentOption } from "echarts/components"; import type { VisualMapComponentOption } from "echarts/components";
import type { LineSeriesOption } from "echarts/charts"; import type { LineSeriesOption } from "echarts/charts";
import type { YAXisOption } from "echarts/types/dist/shared"; import type { YAXisOption } from "echarts/types/dist/shared";
@@ -27,7 +27,6 @@ const safeParseFloat = (value) => {
return isFinite(parsed) ? parsed : null; return isFinite(parsed) ? parsed : null;
}; };
@customElement("state-history-chart-line")
export class StateHistoryChartLine extends LitElement { export class StateHistoryChartLine extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@@ -796,6 +795,7 @@ export class StateHistoryChartLine extends LitElement {
return Math.abs(value) < 1 ? value : roundingFn(value); return Math.abs(value) < 1 ? value : roundingFn(value);
} }
} }
customElements.define("state-history-chart-line", StateHistoryChartLine);
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {

View File

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

View File

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

View File

@@ -2,17 +2,15 @@ import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat"; import { repeat } from "lit/directives/repeat";
import type { LabelRegistryEntry } from "../../data/label_registry";
import { computeCssColor } from "../../common/color/compute-color"; import { computeCssColor } from "../../common/color/compute-color";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { stringCompare } from "../../common/string/compare";
import type { LabelRegistryEntry } from "../../data/label/label_registry";
import "../chips/ha-chip-set";
import "../ha-dropdown";
import "../ha-dropdown-item";
import type { HaDropdownItem } from "../ha-dropdown-item";
import "../ha-icon";
import "../ha-label"; import "../ha-label";
import { stringCompare } from "../../common/string/compare";
import "../chips/ha-chip-set";
import "../ha-button-menu";
import "../ha-icon";
import "../ha-list-item";
@customElement("ha-data-table-labels") @customElement("ha-data-table-labels")
class HaDataTableLabels extends LitElement { class HaDataTableLabels extends LitElement {
@@ -28,11 +26,12 @@ class HaDataTableLabels extends LitElement {
(label) => this._renderLabel(label, true) (label) => this._renderLabel(label, true)
)} )}
${labels.length > 2 ${labels.length > 2
? html`<ha-dropdown ? html`<ha-button-menu
absolute
role="button" role="button"
tabindex="0" tabindex="0"
@click=${stopPropagation} @click=${this._handleIconOverflowMenuOpened}
@wa-select=${this._handleDropdownSelect} @closed=${this._handleIconOverflowMenuClosed}
> >
<ha-label slot="trigger" class="plus" dense> <ha-label slot="trigger" class="plus" dense>
+${labels.length - 2} +${labels.length - 2}
@@ -41,12 +40,12 @@ class HaDataTableLabels extends LitElement {
labels.slice(2), labels.slice(2),
(label) => label.label_id, (label) => label.label_id,
(label) => html` (label) => html`
<ha-dropdown-item .value=${label.label_id} .item=${label}> <ha-list-item @click=${this._labelClicked} .item=${label}>
${this._renderLabel(label, false)} ${this._renderLabel(label, false)}
</ha-dropdown-item> </ha-list-item>
` `
)} )}
</ha-dropdown>` </ha-button-menu>`
: nothing} : nothing}
</ha-chip-set> </ha-chip-set>
`; `;
@@ -82,12 +81,21 @@ class HaDataTableLabels extends LitElement {
fireEvent(this, "label-clicked", { label }); fireEvent(this, "label-clicked", { label });
} }
private _handleDropdownSelect( protected _handleIconOverflowMenuOpened(e) {
ev: CustomEvent<{ item: HaDropdownItem & { item?: LabelRegistryEntry } }> e.stopPropagation();
) { // If this component is used inside a data table, the z-index of the row
const label = ev.detail?.item?.item; // needs to be increased. Otherwise the ha-button-menu would be displayed
if (label) { // underneath the next row in the table.
fireEvent(this, "label-clicked", { label }); const row = this.closest(".mdc-data-table__row") as HTMLDivElement | null;
if (row) {
row.style.zIndex = "1";
}
}
protected _handleIconOverflowMenuClosed() {
const row = this.closest(".mdc-data-table__row") as HTMLDivElement | null;
if (row) {
row.style.zIndex = "";
} }
} }
@@ -106,6 +114,9 @@ class HaDataTableLabels extends LitElement {
--ha-label-background-color: var(--color, var(--grey-color)); --ha-label-background-color: var(--color, var(--grey-color));
--ha-label-background-opacity: 0.5; --ha-label-background-opacity: 0.5;
} }
ha-button-menu {
border-radius: 10px;
}
.plus { .plus {
--ha-label-background-color: transparent; --ha-label-background-color: transparent;
border: 1px solid var(--divider-color); border: 1px solid var(--divider-color);

View File

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

View File

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

View File

@@ -101,10 +101,6 @@ const Component = Vue.extend({
type: String, type: String,
default: "en", default: "en",
}, },
opensVertical: {
type: String,
default: undefined,
},
}, },
render(createElement) { render(createElement) {
// @ts-expect-error // @ts-expect-error
@@ -133,11 +129,6 @@ const Component = Vue.extend({
}, },
expression: "dateRange", expression: "dateRange",
}, },
on: {
toggle: (open: boolean) => {
fireEvent(this.$el as HTMLElement, "toggle", { open });
},
},
scopedSlots: { scopedSlots: {
input() { input() {
return createElement("slot", { return createElement("slot", {
@@ -318,10 +309,6 @@ class DateRangePickerElement extends WrappedElement {
min-width: unset !important; min-width: unset !important;
display: block !important; display: block !important;
} }
:host([opens-vertical="up"]) .daterangepicker {
bottom: 100%;
top: auto !important;
}
`; `;
if (mainWindow.document.dir === "rtl") { if (mainWindow.document.dir === "rtl") {
style.innerHTML += ` style.innerHTML += `
@@ -353,7 +340,4 @@ declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"date-range-picker": DateRangePickerElement; "date-range-picker": DateRangePickerElement;
} }
interface HASSDomEvents {
toggle: { open: boolean };
}
} }

View File

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

View File

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

View File

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

View File

@@ -9,11 +9,10 @@ import { computeDeviceName } from "../../common/entity/compute_device_name";
import { getDeviceContext } from "../../common/entity/context/get_device_context"; import { getDeviceContext } from "../../common/entity/context/get_device_context";
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries"; import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
import { import {
deviceComboBoxKeys,
getDevices, getDevices,
type DevicePickerItem, type DevicePickerItem,
} from "../../data/device/device_picker"; type DeviceRegistryEntry,
import type { DeviceRegistryEntry } from "../../data/device/device_registry"; } from "../../data/device_registry";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url"; import { brandsUrl } from "../../util/brands-url";
import "../ha-generic-picker"; import "../ha-generic-picker";
@@ -205,8 +204,6 @@ export class HaDevicePicker extends LitElement {
<ha-generic-picker <ha-generic-picker
.hass=${this.hass} .hass=${this.hass}
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
.disabled=${this.disabled}
.helper=${this.helper}
.label=${this.label} .label=${this.label}
.searchLabel=${this.searchLabel} .searchLabel=${this.searchLabel}
.notFoundLabel=${this._notFoundLabel} .notFoundLabel=${this._notFoundLabel}
@@ -219,10 +216,6 @@ export class HaDevicePicker extends LitElement {
.getItems=${this._getItems} .getItems=${this._getItems}
.hideClearIcon=${this.hideClearIcon} .hideClearIcon=${this.hideClearIcon}
.valueRenderer=${valueRenderer} .valueRenderer=${valueRenderer}
.searchKeys=${deviceComboBoxKeys}
.unknownItemText=${this.hass.localize(
"ui.components.device-picker.unknown"
)}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
> >
</ha-generic-picker> </ha-generic-picker>

View File

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

View File

@@ -61,6 +61,7 @@ class HaDevicesPicker extends LitElement {
(entityId) => html` (entityId) => html`
<div> <div>
<ha-device-picker <ha-device-picker
allow-custom-entity
.curValue=${entityId} .curValue=${entityId}
.hass=${this.hass} .hass=${this.hass}
.deviceFilter=${this.deviceFilter} .deviceFilter=${this.deviceFilter}
@@ -78,6 +79,7 @@ class HaDevicesPicker extends LitElement {
)} )}
<div> <div>
<ha-device-picker <ha-device-picker
allow-custom-entity
.hass=${this.hass} .hass=${this.hass}
.helper=${this.helper} .helper=${this.helper}
.deviceFilter=${this.deviceFilter} .deviceFilter=${this.deviceFilter}

View File

@@ -4,7 +4,7 @@ import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { isValidEntityId } from "../../common/entity/valid_entity_id"; import { isValidEntityId } from "../../common/entity/valid_entity_id";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity"; import type { HaEntityPickerEntityFilterFunc } from "../../data/entity";
import type { HomeAssistant, ValueChangedEvent } from "../../types"; import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-sortable"; import "../ha-sortable";
import "./ha-entity-picker"; import "./ha-entity-picker";
@@ -99,6 +99,7 @@ class HaEntitiesPicker extends LitElement {
(entityId) => html` (entityId) => html`
<div class="entity"> <div class="entity">
<ha-entity-picker <ha-entity-picker
allow-custom-entity
.curValue=${entityId} .curValue=${entityId}
.hass=${this.hass} .hass=${this.hass}
.includeDomains=${this.includeDomains} .includeDomains=${this.includeDomains}
@@ -128,6 +129,7 @@ class HaEntitiesPicker extends LitElement {
</ha-sortable> </ha-sortable>
<div> <div>
<ha-entity-picker <ha-entity-picker
allow-custom-entity
.hass=${this.hass} .hass=${this.hass}
.includeDomains=${this.includeDomains} .includeDomains=${this.includeDomains}
.excludeDomains=${this.excludeDomains} .excludeDomains=${this.excludeDomains}

View File

@@ -7,12 +7,11 @@ import { fireEvent } from "../../common/dom/fire_event";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display"; import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { isValidEntityId } from "../../common/entity/valid_entity_id"; import { isValidEntityId } from "../../common/entity/valid_entity_id";
import { computeRTL } from "../../common/util/compute_rtl"; import { computeRTL } from "../../common/util/compute_rtl";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity"; import type { HaEntityPickerEntityFilterFunc } from "../../data/entity";
import { import {
entityComboBoxKeys,
getEntities, getEntities,
type EntityComboBoxItem, type EntityComboBoxItem,
} from "../../data/entity/entity_picker"; } from "../../data/entity_registry";
import { domainToName } from "../../data/integration"; import { domainToName } from "../../data/integration";
import { import {
isHelperDomain, isHelperDomain,
@@ -228,7 +227,7 @@ export class HaEntityPicker extends LitElement {
if (!createDomains?.length) { if (!createDomains?.length) {
return []; return [];
} }
this.hass.loadFragmentTranslation("config");
return createDomains.map((domain) => { return createDomains.map((domain) => {
const primary = localize( const primary = localize(
"ui.components.entity.entity-picker.create_helper", "ui.components.entity.entity-picker.create_helper",
@@ -236,7 +235,7 @@ export class HaEntityPicker extends LitElement {
domain: isHelperDomain(domain) domain: isHelperDomain(domain)
? localize( ? localize(
`ui.panel.config.helpers.types.${domain as HelperDomain}` `ui.panel.config.helpers.types.${domain as HelperDomain}`
) || domain )
: domainToName(localize, domain), : domainToName(localize, domain),
} }
); );
@@ -277,28 +276,22 @@ export class HaEntityPicker extends LitElement {
.disabled=${this.disabled} .disabled=${this.disabled}
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomEntity} .allowCustomValue=${this.allowCustomEntity}
.required=${this.required}
.label=${this.label} .label=${this.label}
.placeholder=${placeholder}
.helper=${this.helper} .helper=${this.helper}
.value=${this.addButton ? undefined : this.value}
.searchLabel=${this.searchLabel} .searchLabel=${this.searchLabel}
.notFoundLabel=${this._notFoundLabel} .notFoundLabel=${this._notFoundLabel}
.placeholder=${placeholder}
.value=${this.addButton ? undefined : this.value}
.rowRenderer=${this._rowRenderer} .rowRenderer=${this._rowRenderer}
.getItems=${this._getItems} .getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems} .getAdditionalItems=${this._getAdditionalItems}
.hideClearIcon=${this.hideClearIcon} .hideClearIcon=${this.hideClearIcon}
.searchFn=${this._searchFn} .searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer} .valueRenderer=${this._valueRenderer}
.searchKeys=${entityComboBoxKeys} @value-changed=${this._valueChanged}
use-top-label
.addButtonLabel=${this.addButton .addButtonLabel=${this.addButton
? this.hass.localize("ui.components.entity.entity-picker.add") ? this.hass.localize("ui.components.entity.entity-picker.add")
: undefined} : undefined}
.unknownItemText=${this.hass.localize(
"ui.components.entity.entity-picker.unknown"
)}
@value-changed=${this._valueChanged}
> >
</ha-generic-picker> </ha-generic-picker>
`; `;

View File

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

View File

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

View File

@@ -38,21 +38,9 @@ type StatisticItemType = "entity" | "external" | "no_state";
interface StatisticComboBoxItem extends PickerComboBoxItem { interface StatisticComboBoxItem extends PickerComboBoxItem {
statistic_id?: string; statistic_id?: string;
stateObj?: HassEntity; stateObj?: HassEntity;
domainName?: string;
type?: StatisticItemType; type?: StatisticItemType;
} }
const SEARCH_KEYS = [
{ name: "label", weight: 10 },
{ name: "search_labels.entityName", weight: 10 },
{ name: "search_labels.friendlyName", weight: 9 },
{ name: "search_labels.deviceName", weight: 8 },
{ name: "search_labels.areaName", weight: 6 },
{ name: "search_labels.domainName", weight: 4 },
{ name: "statisticId", weight: 3 },
{ name: "id", weight: 2 },
];
@customElement("ha-statistic-picker") @customElement("ha-statistic-picker")
export class HaStatisticPicker extends LitElement { export class HaStatisticPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@@ -245,6 +233,7 @@ export class HaStatisticPicker extends LitElement {
), ),
type, type,
sorting_label: [sortingPrefix, label].join("_"), sorting_label: [sortingPrefix, label].join("_"),
search_labels: [label, id],
icon_path: mdiShape, icon_path: mdiShape,
}); });
} else if (type === "external") { } else if (type === "external") {
@@ -257,7 +246,7 @@ export class HaStatisticPicker extends LitElement {
secondary: domainName, secondary: domainName,
type, type,
sorting_label: [sortingPrefix, label].join("_"), sorting_label: [sortingPrefix, label].join("_"),
search_labels: { label, domainName }, search_labels: [label, domainName, id],
icon_path: mdiChartLine, icon_path: mdiChartLine,
}); });
} }
@@ -291,12 +280,13 @@ export class HaStatisticPicker extends LitElement {
stateObj: stateObj, stateObj: stateObj,
type: "entity", type: "entity",
sorting_label: [sortingPrefix, deviceName, entityName].join("_"), sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
search_labels: { search_labels: [
entityName: entityName || null, entityName,
deviceName: deviceName || null, deviceName,
areaName: areaName || null, areaName,
friendlyName, friendlyName,
}, id,
].filter(Boolean) as string[],
}); });
}); });
@@ -371,13 +361,13 @@ export class HaStatisticPicker extends LitElement {
stateObj: stateObj, stateObj: stateObj,
type: "entity", type: "entity",
sorting_label: [sortingPrefix, deviceName, entityName].join("_"), sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
search_labels: { search_labels: [
entityName: entityName || null, entityName,
deviceName: deviceName || null, deviceName,
areaName: areaName || null, areaName,
friendlyName, friendlyName,
statisticId, statisticId,
}, ].filter(Boolean) as string[],
}; };
} }
@@ -404,7 +394,7 @@ export class HaStatisticPicker extends LitElement {
secondary: domainName, secondary: domainName,
type: "external", type: "external",
sorting_label: [sortingPrefix, label].join("_"), sorting_label: [sortingPrefix, label].join("_"),
search_labels: { label, domainName, statisticId }, search_labels: [label, domainName, statisticId],
icon_path: mdiChartLine, icon_path: mdiChartLine,
}; };
} }
@@ -419,7 +409,7 @@ export class HaStatisticPicker extends LitElement {
secondary: this.hass.localize("ui.components.statistic-picker.no_state"), secondary: this.hass.localize("ui.components.statistic-picker.no_state"),
type: "no_state", type: "no_state",
sorting_label: [sortingPrefix, label].join("_"), sorting_label: [sortingPrefix, label].join("_"),
search_labels: { label, statisticId }, search_labels: [label, statisticId],
icon_path: mdiShape, icon_path: mdiShape,
}; };
} }
@@ -471,14 +461,13 @@ export class HaStatisticPicker extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomEntity} .allowCustomValue=${this.allowCustomEntity}
.disabled=${this.disabled}
.label=${this.label} .label=${this.label}
.placeholder=${placeholder}
.value=${this.value}
.notFoundLabel=${this._notFoundLabel} .notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass.localize( .emptyLabel=${this.hass.localize(
"ui.components.statistic-picker.no_statistics" "ui.components.statistic-picker.no_statistics"
)} )}
.placeholder=${placeholder}
.value=${this.value}
.rowRenderer=${this._rowRenderer} .rowRenderer=${this._rowRenderer}
.getItems=${this._getItems} .getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems} .getAdditionalItems=${this._getAdditionalItems}
@@ -486,10 +475,6 @@ export class HaStatisticPicker extends LitElement {
.searchFn=${this._searchFn} .searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer} .valueRenderer=${this._valueRenderer}
.helper=${this.helper} .helper=${this.helper}
.searchKeys=${SEARCH_KEYS}
.unknownItemText=${this.hass.localize(
"ui.components.statistic-picker.unknown"
)}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
> >
</ha-generic-picker> </ha-generic-picker>

View File

@@ -2,7 +2,7 @@ import { mdiAlert } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket"; import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit"; import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit"; import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined"; import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map"; import { styleMap } from "lit/directives/style-map";
import { computeDomain } from "../../common/entity/compute_domain"; import { computeDomain } from "../../common/entity/compute_domain";
@@ -17,7 +17,6 @@ import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import "../ha-state-icon"; import "../ha-state-icon";
@customElement("state-badge")
export class StateBadge extends LitElement { export class StateBadge extends LitElement {
public hass?: HomeAssistant; public hass?: HomeAssistant;
@@ -266,3 +265,5 @@ declare global {
"state-badge": StateBadge; "state-badge": StateBadge;
} }
} }
customElements.define("state-badge", StateBadge);

View File

@@ -1,188 +0,0 @@
import { mdiClose } from "@mdi/js";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { HomeAssistant } from "../types";
import { listenMediaQuery } from "../common/dom/media_query";
import "./ha-bottom-sheet";
import "./ha-dialog-header";
import "./ha-icon-button";
import "./ha-wa-dialog";
import type { DialogWidth } from "./ha-wa-dialog";
type DialogSheetMode = "dialog" | "bottom-sheet";
/**
* Home Assistant adaptive dialog component
*
* @element ha-adaptive-dialog
* @extends {LitElement}
*
* @summary
* A responsive dialog component that automatically switches between a full dialog (ha-wa-dialog)
* and a bottom sheet (ha-bottom-sheet) based on screen size. Uses dialog mode on larger screens
* (>870px width and >500px height) and bottom sheet mode on smaller screens or mobile devices.
*
* @slot headerNavigationIcon - Leading header action (e.g. close/back button).
* @slot headerTitle - Custom title content (used when header-title is not set).
* @slot headerSubtitle - Custom subtitle content (used when header-subtitle is not set).
* @slot headerActionItems - Trailing header actions (e.g. buttons, menus).
* @slot - Dialog/sheet content body.
* @slot footer - Dialog/sheet footer content.
*
* @cssprop --ha-dialog-surface-background - Dialog/sheet background color.
* @cssprop --ha-dialog-border-radius - Border radius of the dialog surface (dialog mode only).
* @cssprop --ha-dialog-show-duration - Show animation duration (dialog mode only).
* @cssprop --ha-dialog-hide-duration - Hide animation duration (dialog mode only).
*
* @attr {boolean} open - Controls the dialog/sheet open state.
* @attr {("small"|"medium"|"large"|"full")} width - Preferred dialog width preset (dialog mode only). Defaults to "medium".
* @attr {string} header-title - Header title text. If not set, the headerTitle slot is used.
* @attr {string} header-subtitle - Header subtitle text. If not set, the headerSubtitle slot is used.
* @attr {("above"|"below")} header-subtitle-position - Position of the subtitle relative to the title. Defaults to "below".
* @attr {boolean} block-mode-change - When set, the mode is determined at mount time based on the current screen size, but subsequent mode changes are blocked. Useful for preventing forms from resetting when the viewport size changes.
*
* @event opened - Fired when the dialog/sheet is shown (dialog mode only).
* @event closed - Fired after the dialog/sheet is hidden.
* @event after-show - Fired after show animation completes (dialog mode only).
*
* @remarks
* **Responsive Behavior:**
* The component automatically switches between dialog and bottom sheet modes based on viewport size.
* Dialog mode is used for screens wider than 870px and taller than 500px.
* Bottom sheet mode is used for mobile devices and smaller screens.
*
* When `block-mode-change` is set, the mode is determined once at mount time based on the initial
* screen size. Subsequent viewport size changes will not trigger mode switches, which is useful
* for preventing form resets or other state loss when users resize their browser window.
*
* **Focus Management:**
* To automatically focus an element when opened, add the `autofocus` attribute to it.
* Components with `delegatesFocus: true` (like `ha-form`) will forward focus to their first focusable child.
* Example: `<ha-form autofocus .schema=${schema}></ha-form>`
*/
@customElement("ha-adaptive-dialog")
export class HaAdaptiveDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "aria-labelledby" })
public ariaLabelledBy?: string;
@property({ attribute: "aria-describedby" })
public ariaDescribedBy?: string;
@property({ type: Boolean, reflect: true })
public open = false;
@property({ type: String, reflect: true, attribute: "width" })
public width: DialogWidth = "medium";
@property({ attribute: "header-title" })
public headerTitle?: string;
@property({ attribute: "header-subtitle" })
public headerSubtitle?: string;
@property({ type: String, attribute: "header-subtitle-position" })
public headerSubtitlePosition: "above" | "below" = "below";
@property({ type: Boolean, attribute: "block-mode-change" })
public blockModeChange = false;
@state() private _mode: DialogSheetMode = "dialog";
private _unsubMediaQuery?: () => void;
private _modeSet = false;
connectedCallback() {
super.connectedCallback();
this._unsubMediaQuery = listenMediaQuery(
"(max-width: 870px), (max-height: 500px)",
(matches) => {
if (!this._modeSet || !this.blockModeChange) {
this._mode = matches ? "bottom-sheet" : "dialog";
this._modeSet = true;
}
}
);
}
disconnectedCallback() {
super.disconnectedCallback();
this._unsubMediaQuery?.();
this._unsubMediaQuery = undefined;
this._modeSet = false;
}
render() {
if (this._mode === "bottom-sheet") {
return html`
<ha-bottom-sheet .open=${this.open} flexcontent>
<ha-dialog-header
slot="header"
.subtitlePosition=${this.headerSubtitlePosition}
>
<slot name="headerNavigationIcon" slot="navigationIcon">
<ha-icon-button
data-drawer="close"
.label=${this.hass?.localize("ui.common.close") ?? "Close"}
.path=${mdiClose}
></ha-icon-button>
</slot>
${this.headerTitle !== undefined
? html`<span slot="title" class="title" id="ha-wa-dialog-title">
${this.headerTitle}
</span>`
: html`<slot name="headerTitle" slot="title"></slot>`}
${this.headerSubtitle !== undefined
? html`<span slot="subtitle">${this.headerSubtitle}</span>`
: html`<slot name="headerSubtitle" slot="subtitle"></slot>`}
<slot name="headerActionItems" slot="actionItems"></slot>
</ha-dialog-header>
<slot></slot>
<slot name="footer" slot="footer"></slot>
</ha-bottom-sheet>
`;
}
return html`
<ha-wa-dialog
.hass=${this.hass}
.open=${this.open}
.width=${this.width}
.ariaLabelledBy=${this.ariaLabelledBy}
.ariaDescribedBy=${this.ariaDescribedBy}
.headerTitle=${this.headerTitle}
.headerSubtitle=${this.headerSubtitle}
.headerSubtitlePosition=${this.headerSubtitlePosition}
flexcontent
>
<slot name="headerNavigationIcon" slot="headerNavigationIcon"></slot>
<slot name="headerTitle" slot="headerTitle"></slot>
<slot name="headerSubtitle" slot="headerSubtitle"></slot>
<slot name="headerActionItems" slot="headerActionItems"></slot>
<slot></slot>
<slot name="footer" slot="footer"></slot>
</ha-wa-dialog>
`;
}
static get styles() {
return [
css`
ha-bottom-sheet {
--ha-bottom-sheet-surface-background: var(
--ha-dialog-surface-background,
var(--card-background-color, var(--ha-color-surface-default))
);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-adaptive-dialog": HaAdaptiveDialog;
}
}

View File

@@ -1,29 +1,29 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize"; import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import { html, LitElement, nothing } from "lit"; import { html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../common/config/is_component_loaded"; import { isComponentLoaded } from "../common/config/is_component_loaded";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
import type { HassioAddonInfo } from "../data/hassio/addon";
import { fetchHassioAddonsInfo } from "../data/hassio/addon"; import { fetchHassioAddonsInfo } from "../data/hassio/addon";
import type { HomeAssistant, ValueChangedEvent } from "../types"; import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./ha-alert"; import "./ha-alert";
import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box";
import "./ha-combo-box-item"; import "./ha-combo-box-item";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
const SEARCH_KEYS = [ const rowRenderer: ComboBoxLitRenderer<HassioAddonInfo> = (item) => html`
{ name: "primary", weight: 10 },
{ name: "secondary", weight: 8 },
{ name: "search_labels.description", weight: 6 },
{ name: "search_labels.repository", weight: 5 },
];
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
<ha-combo-box-item type="button"> <ha-combo-box-item type="button">
<span slot="headline">${item.primary}</span> <span slot="headline">${item.name}</span>
<span slot="supporting-text">${item.secondary}</span> <span slot="supporting-text">${item.slug}</span>
${item.icon ${item.icon
? html` <img alt="" slot="start" .src=${item.icon} /> ` ? html`
<img
alt=""
slot="start"
.src="/api/hassio/addons/${item.slug}/icon"
/>
`
: nothing} : nothing}
</ha-combo-box-item> </ha-combo-box-item>
`; `;
@@ -38,22 +38,22 @@ class HaAddonPicker extends LitElement {
@property() public helper?: string; @property() public helper?: string;
@state() private _addons?: PickerComboBoxItem[]; @state() private _addons?: HassioAddonInfo[];
@property({ type: Boolean }) public disabled = false; @property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false; @property({ type: Boolean }) public required = false;
@query("ha-generic-picker") private _genericPicker!: HaGenericPicker; @query("ha-combo-box") private _comboBox!: HaComboBox;
@state() private _error?: string; @state() private _error?: string;
public open() { public open() {
this._genericPicker?.open(); this._comboBox?.open();
} }
public focus() { public focus() {
this._genericPicker?.focus(); this._comboBox?.focus();
} }
protected firstUpdated() { protected firstUpdated() {
@@ -61,34 +61,29 @@ class HaAddonPicker extends LitElement {
} }
protected render() { protected render() {
const label =
this.label === undefined && this.hass
? this.hass.localize("ui.components.addon-picker.addon")
: this.label;
if (this._error) { if (this._error) {
return html`<ha-alert alert-type="error">${this._error}</ha-alert>`; return html`<ha-alert alert-type="error">${this._error}</ha-alert>`;
} }
if (!this._addons) { if (!this._addons) {
return nothing; return nothing;
} }
return html` return html`
<ha-generic-picker <ha-combo-box
.hass=${this.hass} .hass=${this.hass}
.autofocus=${this.autofocus} .label=${this.label === undefined && this.hass
.label=${label} ? this.hass.localize("ui.components.addon-picker.addon")
.valueRenderer=${this._valueRenderer} : this.label}
.helper=${this.helper} .value=${this._value}
.disabled=${this.disabled}
.required=${this.required} .required=${this.required}
.value=${this.value} .disabled=${this.disabled}
.getItems=${this._getItems} .helper=${this.helper}
.searchKeys=${SEARCH_KEYS} .renderer=${rowRenderer}
.rowRenderer=${rowRenderer} .items=${this._addons}
item-value-path="slug"
item-id-path="slug"
item-label-path="name"
@value-changed=${this._addonChanged} @value-changed=${this._addonChanged}
> ></ha-combo-box>
</ha-generic-picker>
`; `;
} }
@@ -98,19 +93,9 @@ class HaAddonPicker extends LitElement {
const addonsInfo = await fetchHassioAddonsInfo(this.hass); const addonsInfo = await fetchHassioAddonsInfo(this.hass);
this._addons = addonsInfo.addons this._addons = addonsInfo.addons
.filter((addon) => addon.version) .filter((addon) => addon.version)
.map((addon) => ({ .sort((a, b) =>
id: addon.slug, stringCompare(a.name, b.name, this.hass.locale.language)
primary: addon.name, );
secondary: addon.slug,
icon: addon.icon
? `/api/hassio/addons/${addon.slug}/icon`
: undefined,
search_labels: {
description: addon.description || null,
repository: addon.repository || null,
},
sorting_label: [addon.name, addon.slug].filter(Boolean).join("_"),
}));
} else { } else {
this._error = this.hass.localize( this._error = this.hass.localize(
"ui.components.addon-picker.error.no_supervisor" "ui.components.addon-picker.error.no_supervisor"
@@ -123,8 +108,6 @@ class HaAddonPicker extends LitElement {
} }
} }
private _getItems = () => this._addons!;
private get _value() { private get _value() {
return this.value || ""; return this.value || "";
} }
@@ -145,17 +128,6 @@ class HaAddonPicker extends LitElement {
fireEvent(this, "change"); fireEvent(this, "change");
}, 0); }, 0);
} }
private _valueRenderer = (itemId: string) => {
const item = this._addons!.find((addon) => addon.id === itemId);
return html`${item?.icon
? html`<img
slot="start"
alt=${item.primary ?? "Unknown"}
.src=${item.icon}
/>`
: nothing}<span slot="headline">${item?.primary || "Unknown"}</span>`;
};
} }
declare global { declare global {

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,13 +2,12 @@ import "@home-assistant/webawesome/dist/components/drawer/drawer";
import { css, html, LitElement, type PropertyValues } from "lit"; import { css, html, LitElement, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import { SwipeGestureRecognizer } from "../common/util/swipe-gesture-recognizer"; import { SwipeGestureRecognizer } from "../common/util/swipe-gesture-recognizer";
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
import { haStyleScrollbar } from "../resources/styles"; import { haStyleScrollbar } from "../resources/styles";
export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300; export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300;
@customElement("ha-bottom-sheet") @customElement("ha-bottom-sheet")
export class HaBottomSheet extends ScrollableFadeMixin(LitElement) { export class HaBottomSheet extends LitElement {
@property({ type: Boolean }) public open = false; @property({ type: Boolean }) public open = false;
@property({ type: Boolean, reflect: true, attribute: "flexcontent" }) @property({ type: Boolean, reflect: true, attribute: "flexcontent" })
@@ -18,12 +17,6 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
@query("#drawer") private _drawer!: HTMLElement; @query("#drawer") private _drawer!: HTMLElement;
@query("#body") private _bodyElement!: HTMLDivElement;
protected get scrollableElement(): HTMLElement | null {
return this._bodyElement;
}
private _gestureRecognizer = new SwipeGestureRecognizer(); private _gestureRecognizer = new SwipeGestureRecognizer();
private _isDragging = false; private _isDragging = false;
@@ -56,13 +49,9 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
@touchstart=${this._handleTouchStart} @touchstart=${this._handleTouchStart}
> >
<slot name="header"></slot> <slot name="header"></slot>
<div class="content-wrapper"> <div id="body" class="body ha-scrollbar">
<div id="body" class="body ha-scrollbar"> <slot></slot>
<slot></slot>
</div>
${this.renderScrollableFades()}
</div> </div>
<slot name="footer"></slot>
</wa-drawer> </wa-drawer>
`; `;
} }
@@ -178,87 +167,60 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
this._isDragging = false; this._isDragging = false;
} }
static get styles() { static styles = [
return [ haStyleScrollbar,
...super.styles, css`
haStyleScrollbar, wa-drawer {
css` --wa-color-surface-raised: transparent;
wa-drawer { --spacing: 0;
--wa-color-surface-raised: transparent; --size: var(--ha-bottom-sheet-height, auto);
--spacing: 0; --show-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
--size: var(--ha-bottom-sheet-height, auto); --hide-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
--show-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms; }
--hide-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms; wa-drawer::part(dialog) {
} max-height: var(--ha-bottom-sheet-max-height, 90vh);
wa-drawer::part(dialog) { align-items: center;
max-height: var(--ha-bottom-sheet-max-height, 90vh); transform: var(--dialog-transform);
align-items: center; transition: var(--dialog-transition);
transform: var(--dialog-transform); }
transition: var(--dialog-transition); wa-drawer::part(body) {
} max-width: var(--ha-bottom-sheet-max-width);
wa-drawer::part(body) { width: 100%;
max-width: var(--ha-bottom-sheet-max-width); border-top-left-radius: var(
width: 100%; --ha-bottom-sheet-border-radius,
border-top-left-radius: var( var(--ha-dialog-border-radius, var(--ha-border-radius-2xl))
--ha-bottom-sheet-border-radius, );
var(--ha-dialog-border-radius, var(--ha-border-radius-2xl)) border-top-right-radius: var(
); --ha-bottom-sheet-border-radius,
border-top-right-radius: var( var(--ha-dialog-border-radius, var(--ha-border-radius-2xl))
--ha-bottom-sheet-border-radius, );
var(--ha-dialog-border-radius, var(--ha-border-radius-2xl)) background-color: var(
); --ha-bottom-sheet-surface-background,
background-color: var( var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)),
--ha-bottom-sheet-surface-background, );
var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)), padding: var(
); --ha-bottom-sheet-padding,
padding: var( 0 var(--safe-area-inset-right) var(--safe-area-inset-bottom)
--ha-bottom-sheet-padding, var(--safe-area-inset-left)
0 var(--safe-area-inset-right) var(--safe-area-inset-bottom) );
var(--safe-area-inset-left) }
); :host([flexcontent]) wa-drawer::part(body) {
} display: flex;
:host([flexcontent]) wa-drawer::part(body) { flex-direction: column;
display: flex; }
flex-direction: column; :host([flexcontent]) .body {
} flex: 1;
.content-wrapper { max-width: 100%;
position: relative; display: flex;
flex: 1; flex-direction: column;
display: flex; padding: var(
flex-direction: column; --ha-bottom-sheet-padding,
min-height: 0; 0 var(--safe-area-inset-right) var(--safe-area-inset-bottom)
} var(--safe-area-inset-left)
:host([flexcontent]) .body { );
flex: 1; }
max-width: 100%; `,
display: flex; ];
flex-direction: column;
padding: var(
--ha-bottom-sheet-padding,
0 var(--safe-area-inset-right) var(--safe-area-inset-bottom)
var(--safe-area-inset-left)
);
}
slot[name="footer"] {
display: block;
padding: var(--ha-space-0);
}
::slotted([slot="footer"]) {
display: flex;
padding: var(--ha-space-3) var(--ha-space-4) var(--ha-space-4)
var(--ha-space-4);
gap: var(--ha-space-3);
justify-content: flex-end;
align-items: center;
width: 100%;
box-sizing: border-box;
}
:host([flexcontent]) slot[name="footer"] {
flex-shrink: 0;
}
`,
];
}
} }
declare global { declare global {

View File

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

View File

@@ -1,23 +1,25 @@
import { mdiInvertColorsOff, mdiPalette } from "@mdi/js"; import { mdiInvertColorsOff, mdiPalette } from "@mdi/js";
import { html, LitElement } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property, query } from "lit/decorators";
import { styleMap } from "lit/directives/style-map"; import { styleMap } from "lit/directives/style-map";
import { computeCssColor, THEME_COLORS } from "../common/color/compute-color"; import { computeCssColor, THEME_COLORS } from "../common/color/compute-color";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import type { LocalizeKeys } from "../common/translations/localize"; import type { LocalizeKeys } from "../common/translations/localize";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import "./ha-generic-picker"; import "./ha-list-item";
import type { PickerComboBoxItem } from "./ha-picker-combo-box"; import "./ha-md-divider";
import type { PickerValueRenderer } from "./ha-picker-field"; import "./ha-select";
import type { HaSelect } from "./ha-select";
@customElement("ha-color-picker") @customElement("ha-color-picker")
export class HaColorPicker extends LitElement { export class HaColorPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string; @property() public label?: string;
@property() public helper?: string; @property() public helper?: string;
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public value?: string; @property() public value?: string;
@property({ type: String, attribute: "default_color" }) @property({ type: String, attribute: "default_color" })
@@ -31,178 +33,137 @@ export class HaColorPicker extends LitElement {
@property({ type: Boolean }) public disabled = false; @property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false; @query("ha-select") private _select?: HaSelect;
render() { connectedCallback(): void {
const effectiveValue = this.value ?? this.defaultColor ?? ""; super.connectedCallback();
// Refresh layout options when the field is connected to the DOM to ensure current value displayed
return html` this._select?.layoutOptions();
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.required=${this.required}
.hideClearIcon=${!this.value && !!this.defaultColor}
.label=${this.label}
.helper=${this.helper}
.value=${effectiveValue}
.getItems=${this._getItems}
.rowRenderer=${this._rowRenderer}
.valueRenderer=${this._valueRenderer}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
`;
} }
private _getItems = () => private _valueSelected(ev) {
this._getColors( ev.stopPropagation();
this.includeNone, if (!this.isConnected) return;
this.includeState, const value = ev.target.value;
this.defaultColor, this.value = value === this.defaultColor ? undefined : value;
this.value fireEvent(this, "value-changed", {
); value: this.value,
private _getColors = (
includeNone: boolean,
includeState: boolean,
defaultColor: string | undefined,
currentValue: string | undefined
): PickerComboBoxItem[] => {
const items: PickerComboBoxItem[] = [];
const defaultSuffix = this.hass.localize(
"ui.components.color-picker.default"
);
const addDefaultSuffix = (label: string, isDefault: boolean) =>
isDefault && defaultSuffix ? `${label} (${defaultSuffix})` : label;
if (includeNone) {
const noneLabel =
this.hass.localize("ui.components.color-picker.none") || "None";
items.push({
id: "none",
primary: addDefaultSuffix(noneLabel, defaultColor === "none"),
icon_path: mdiInvertColorsOff,
sorting_label: noneLabel,
});
}
if (includeState) {
const stateLabel =
this.hass.localize("ui.components.color-picker.state") || "State";
items.push({
id: "state",
primary: addDefaultSuffix(stateLabel, defaultColor === "state"),
icon_path: mdiPalette,
sorting_label: stateLabel,
});
}
Array.from(THEME_COLORS).forEach((color) => {
const themeLabel =
this.hass.localize(
`ui.components.color-picker.colors.${color}` as LocalizeKeys
) || color;
items.push({
id: color,
primary: addDefaultSuffix(themeLabel, defaultColor === color),
sorting_label: themeLabel,
});
}); });
}
const isSpecial = render() {
currentValue === "none" || const value = this.value || this.defaultColor || "";
currentValue === "state" ||
THEME_COLORS.has(currentValue || "");
const hasValue = currentValue && currentValue.length > 0; const isCustom = !(
THEME_COLORS.has(value) ||
if (hasValue && !isSpecial) { value === "none" ||
items.push({ value === "state"
id: currentValue!, );
primary: currentValue!,
sorting_label: currentValue!,
});
}
return items;
};
private _rowRenderer: (
item: PickerComboBoxItem,
index?: number
) => ReturnType<typeof html> = (item) => html`
<ha-combo-box-item type="button" compact>
${item.id === "none"
? html`<ha-svg-icon
slot="start"
.path=${mdiInvertColorsOff}
></ha-svg-icon>`
: item.id === "state"
? html`<ha-svg-icon slot="start" .path=${mdiPalette}></ha-svg-icon>`
: html`<span slot="start">
${this._renderColorCircle(item.id)}
</span>`}
<span slot="headline">${item.primary}</span>
</ha-combo-box-item>
`;
private _valueRenderer: PickerValueRenderer = (value: string) => {
if (value === "none") {
return html`
<ha-svg-icon slot="start" .path=${mdiInvertColorsOff}></ha-svg-icon>
<span slot="headline">
${this.hass.localize("ui.components.color-picker.none")}
</span>
`;
}
if (value === "state") {
return html`
<ha-svg-icon slot="start" .path=${mdiPalette}></ha-svg-icon>
<span slot="headline">
${this.hass.localize("ui.components.color-picker.state")}
</span>
`;
}
return html` return html`
<span slot="start">${this._renderColorCircle(value)}</span> <ha-select
<span slot="headline"> .icon=${Boolean(value)}
${this.hass.localize( .label=${this.label}
`ui.components.color-picker.colors.${value}` as LocalizeKeys .value=${value}
) || value} .helper=${this.helper}
</span> .disabled=${this.disabled}
@closed=${stopPropagation}
@selected=${this._valueSelected}
fixedMenuPosition
naturalMenuWidth
.clearable=${!this.defaultColor}
>
${value
? html`
<span slot="icon">
${value === "none"
? html`
<ha-svg-icon path=${mdiInvertColorsOff}></ha-svg-icon>
`
: value === "state"
? html`<ha-svg-icon path=${mdiPalette}></ha-svg-icon>`
: this._renderColorCircle(value || "grey")}
</span>
`
: nothing}
${this.includeNone
? html`
<ha-list-item value="none" graphic="icon">
${this.hass.localize("ui.components.color-picker.none")}
${this.defaultColor === "none"
? ` (${this.hass.localize("ui.components.color-picker.default")})`
: nothing}
<ha-svg-icon
slot="graphic"
path=${mdiInvertColorsOff}
></ha-svg-icon>
</ha-list-item>
`
: nothing}
${this.includeState
? html`
<ha-list-item value="state" graphic="icon">
${this.hass.localize("ui.components.color-picker.state")}
${this.defaultColor === "state"
? ` (${this.hass.localize("ui.components.color-picker.default")})`
: nothing}
<ha-svg-icon slot="graphic" path=${mdiPalette}></ha-svg-icon>
</ha-list-item>
`
: nothing}
${this.includeState || this.includeNone
? html`<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>`
: nothing}
${Array.from(THEME_COLORS).map(
(color) => html`
<ha-list-item .value=${color} graphic="icon">
${this.hass.localize(
`ui.components.color-picker.colors.${color}` as LocalizeKeys
) || color}
${this.defaultColor === color
? ` (${this.hass.localize("ui.components.color-picker.default")})`
: nothing}
<span slot="graphic">${this._renderColorCircle(color)}</span>
</ha-list-item>
`
)}
${isCustom
? html`
<ha-list-item .value=${value} graphic="icon">
${value}
<span slot="graphic">${this._renderColorCircle(value)}</span>
</ha-list-item>
`
: nothing}
</ha-select>
`; `;
}; }
private _renderColorCircle(color: string) { private _renderColorCircle(color: string) {
return html` return html`
<span <span
class="circle-color"
style=${styleMap({ style=${styleMap({
"--circle-color": computeCssColor(color), "--circle-color": computeCssColor(color),
display: "block",
"background-color": "var(--circle-color, var(--divider-color))",
border: "1px solid var(--outline-color)",
"border-radius": "var(--ha-border-radius-pill)",
width: "20px",
height: "20px",
"box-sizing": "border-box",
})} })}
></span> ></span>
`; `;
} }
private _valueChanged(ev: CustomEvent<{ value?: string }>) { static styles = css`
ev.stopPropagation(); .circle-color {
const selected = ev.detail.value; display: block;
const normalized = background-color: var(--circle-color, var(--divider-color));
selected && selected === this.defaultColor border: 1px solid var(--outline-color);
? undefined border-radius: var(--ha-border-radius-pill);
: (selected ?? undefined); width: 20px;
this.value = normalized; height: 20px;
fireEvent(this, "value-changed", { value: this.value }); box-sizing: border-box;
} }
ha-select {
width: 100%;
}
`;
} }
declare global { declare global {

View File

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

View File

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

View File

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

View File

@@ -74,9 +74,6 @@ export class HaDateRangePicker extends LitElement {
@property({ attribute: "extended-presets", type: Boolean }) @property({ attribute: "extended-presets", type: Boolean })
public extendedPresets = false; public extendedPresets = false;
@property({ attribute: "vertical-opening-direction" })
public verticalOpeningDirection?: "up" | "down";
@property({ attribute: false }) public openingDirection?: @property({ attribute: false }) public openingDirection?:
| "right" | "right"
| "left" | "left"
@@ -130,7 +127,6 @@ export class HaDateRangePicker extends LitElement {
opening-direction=${ifDefined( opening-direction=${ifDefined(
this.openingDirection || this._calcedOpeningDirection this.openingDirection || this._calcedOpeningDirection
)} )}
opens-vertical=${ifDefined(this.verticalOpeningDirection)}
first-day=${firstWeekdayIndex(this.hass.locale)} first-day=${firstWeekdayIndex(this.hass.locale)}
language=${this.hass.locale.language} language=${this.hass.locale.language}
@change=${this._handleChange} @change=${this._handleChange}

View File

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

View File

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

View File

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

View File

@@ -12,9 +12,9 @@ import { updateAreaRegistryEntry } from "../data/area_registry";
import type { import type {
DeviceEntityDisplayLookup, DeviceEntityDisplayLookup,
DeviceRegistryEntry, DeviceRegistryEntry,
} from "../data/device/device_registry"; } from "../data/device_registry";
import { getDeviceEntityDisplayLookup } from "../data/device/device_registry"; import { getDeviceEntityDisplayLookup } from "../data/device_registry";
import type { EntityRegistryDisplayEntry } from "../data/entity/entity_registry"; import type { EntityRegistryDisplayEntry } from "../data/entity_registry";
import { import {
createFloorRegistryEntry, createFloorRegistryEntry,
getFloorAreaLookup, getFloorAreaLookup,
@@ -35,12 +35,6 @@ import "./ha-svg-icon";
const ADD_NEW_ID = "___ADD_NEW___"; const ADD_NEW_ID = "___ADD_NEW___";
const SEARCH_KEYS = [
{ name: "search_labels.floorName", weight: 10 },
{ name: "search_labels.aliases", weight: 8 },
{ name: "search_labels.floor_id", weight: 3 },
];
interface FloorComboBoxItem extends PickerComboBoxItem { interface FloorComboBoxItem extends PickerComboBoxItem {
floor?: FloorRegistryEntry; floor?: FloorRegistryEntry;
} }
@@ -291,11 +285,10 @@ export class HaFloorPicker extends LitElement {
id: floor.floor_id, id: floor.floor_id,
primary: floorName, primary: floorName,
floor: floor, floor: floor,
search_labels: { sorting_label: floor.level?.toString() || "zzzzz",
floorName, search_labels: [floorName, floor.floor_id, ...floor.aliases].filter(
floor_id: floor.floor_id, (v): v is string => Boolean(v)
aliases: floor.aliases.join(" "), ),
},
}; };
}); });
@@ -389,23 +382,17 @@ export class HaFloorPicker extends LitElement {
<ha-generic-picker <ha-generic-picker
.hass=${this.hass} .hass=${this.hass}
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
.disabled=${this.disabled}
.label=${this.label} .label=${this.label}
.helper=${this.helper}
.placeholder=${placeholder}
.notFoundLabel=${this._notFoundLabel} .notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass.localize( .emptyLabel=${this.hass.localize(
"ui.components.floor-picker.no_floors" "ui.components.floor-picker.no_floors"
)} )}
.placeholder=${placeholder}
.value=${this.value} .value=${this.value}
.getItems=${this._getItems} .getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems} .getAdditionalItems=${this._getAdditionalItems}
.valueRenderer=${valueRenderer} .valueRenderer=${valueRenderer}
.rowRenderer=${this._rowRenderer} .rowRenderer=${this._rowRenderer}
.searchKeys=${SEARCH_KEYS}
.unknownItemText=${this.hass.localize(
"ui.components.floor-picker.unknown"
)}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
> >
</ha-generic-picker> </ha-generic-picker>

View File

@@ -4,13 +4,9 @@ import { mdiPlaylistPlus } from "@mdi/js";
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit"; import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined"; import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import { tinykeys } from "tinykeys"; import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { PickerMixin } from "../mixins/picker-mixin";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import { isIosApp } from "../util/is_ios";
import "./ha-bottom-sheet"; import "./ha-bottom-sheet";
import "./ha-button"; import "./ha-button";
import "./ha-combo-box-item"; import "./ha-combo-box-item";
@@ -22,21 +18,36 @@ import type {
PickerComboBoxSearchFn, PickerComboBoxSearchFn,
} from "./ha-picker-combo-box"; } from "./ha-picker-combo-box";
import "./ha-picker-field"; import "./ha-picker-field";
import type { PickerValueRenderer } from "./ha-picker-field";
import "./ha-svg-icon"; import "./ha-svg-icon";
@customElement("ha-generic-picker") @customElement("ha-generic-picker")
export class HaGenericPicker extends PickerMixin(LitElement) { export class HaGenericPicker extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public hass?: HomeAssistant;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@property({ type: Boolean, attribute: "allow-custom-value" }) @property({ type: Boolean, attribute: "allow-custom-value" })
public allowCustomValue; public allowCustomValue;
@property() public label?: string;
@property() public value?: string;
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: String, attribute: "search-label" }) @property({ type: String, attribute: "search-label" })
public searchLabel?: string; public searchLabel?: string;
/** To prevent lags, getItems needs to be memoized */ @property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@property({ attribute: false }) @property({ attribute: false })
public getItems!: ( public getItems?: (
searchString?: string, searchString?: string,
section?: string section?: string
) => (PickerComboBoxItem | string)[]; ) => (PickerComboBoxItem | string)[];
@@ -48,10 +59,10 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
public rowRenderer?: RenderItemFunction<PickerComboBoxItem>; public rowRenderer?: RenderItemFunction<PickerComboBoxItem>;
@property({ attribute: false }) @property({ attribute: false })
public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>; public valueRenderer?: PickerValueRenderer;
@property({ attribute: false }) @property({ attribute: false })
public searchKeys?: FuseWeightedKey[]; public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
@property({ attribute: false }) @property({ attribute: false })
public notFoundLabel?: string | ((search: string) => string); public notFoundLabel?: string | ((search: string) => string);
@@ -96,12 +107,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@property({ attribute: "selected-section" }) public selectedSection?: string; @property({ attribute: "selected-section" }) public selectedSection?: string;
@property({ type: Boolean, attribute: "use-top-label" })
public useTopLabel = false;
@property({ attribute: "custom-value-label" })
public customValueLabel?: string;
@query(".container") private _containerElement?: HTMLDivElement; @query(".container") private _containerElement?: HTMLDivElement;
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox; @query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@@ -124,20 +129,14 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
// helper to set new value after closing picker, to avoid flicker // helper to set new value after closing picker, to avoid flicker
private _newValue?: string; private _newValue?: string;
@property({ attribute: "error-message" }) public errorMessage?: string;
@property({ type: Boolean, reflect: true }) public invalid = false;
private _unsubscribeTinyKeys?: () => void; private _unsubscribeTinyKeys?: () => void;
protected render() { protected render() {
// Only show label if it's not a top label and there is a value. return html`
const label = this.useTopLabel && this.value ? undefined : this.label; ${this.label
? html`<label ?disabled=${this.disabled}>${this.label}</label>`
return html`<div class="container"> : nothing}
${this.useTopLabel && this.label <div class="container">
? html`<label ?disabled=${this.disabled}>${this.label}</label>`
: nothing}
<div id="picker"> <div id="picker">
<slot name="field"> <slot name="field">
${this.addButtonLabel && !this.value ${this.addButtonLabel && !this.value
@@ -157,28 +156,16 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
type="button" type="button"
class=${this._opened ? "opened" : ""} class=${this._opened ? "opened" : ""}
compact compact
.unknown=${this._unknownValue(
this.allowCustomValue,
this.value,
this.getItems()
)}
.unknownItemText=${this.unknownItemText}
aria-label=${ifDefined(this.label)} aria-label=${ifDefined(this.label)}
@click=${this.open} @click=${this.open}
@clear=${this._clear} @clear=${this._clear}
.icon=${this.icon}
.image=${this.image}
.label=${label}
.placeholder=${this.placeholder} .placeholder=${this.placeholder}
.helper=${this.helper}
.value=${this.value} .value=${this.value}
.valueRenderer=${this.valueRenderer}
.required=${this.required} .required=${this.required}
.disabled=${this.disabled} .disabled=${this.disabled}
.invalid=${this.invalid}
.hideClearIcon=${this.hideClearIcon} .hideClearIcon=${this.hideClearIcon}
.valueRenderer=${this.valueRenderer}
> >
<slot name="start"></slot>
</ha-picker-field>`} </ha-picker-field>`}
</slot> </slot>
</div> </div>
@@ -217,7 +204,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
</ha-bottom-sheet>` </ha-bottom-sheet>`
: nothing} : nothing}
</div> </div>
${this._renderHelper()}`; ${this._renderHelper()}
`;
} }
private _renderComboBox(dialogMode = false) { private _renderComboBox(dialogMode = false) {
@@ -226,7 +214,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
} }
return html` return html`
<ha-picker-combo-box <ha-picker-combo-box
id="combo-box"
.hass=${this.hass} .hass=${this.hass}
.allowCustomValue=${this.allowCustomValue} .allowCustomValue=${this.allowCustomValue}
.label=${this.searchLabel} .label=${this.searchLabel}
@@ -242,59 +229,21 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
.sections=${this.sections} .sections=${this.sections}
.sectionTitleFunction=${this.sectionTitleFunction} .sectionTitleFunction=${this.sectionTitleFunction}
.selectedSection=${this.selectedSection} .selectedSection=${this.selectedSection}
.searchKeys=${this.searchKeys}
.customValueLabel=${this.customValueLabel}
></ha-picker-combo-box> ></ha-picker-combo-box>
`; `;
} }
private _unknownValue = memoizeOne(
(
allowCustomValue: boolean,
value?: string,
items?: (PickerComboBoxItem | string)[]
) => {
if (
allowCustomValue ||
value === undefined ||
value === null ||
value === "" ||
!items
) {
return false;
}
return !items.some(
(item) => typeof item !== "string" && item.id === value
);
}
);
private _renderHelper() { private _renderHelper() {
const showError = this.invalid && this.errorMessage; return this.helper
const showHelper = !showError && this.helper; ? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
if (!showError && !showHelper) { >`
return nothing; : nothing;
}
return html`<ha-input-helper-text .disabled=${this.disabled}>
${showError ? this.errorMessage : this.helper}
</ha-input-helper-text>`;
} }
private _dialogOpened = () => { private _dialogOpened = () => {
this._opened = true; this._opened = true;
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (this.hass && isIosApp(this.hass)) {
this.hass.auth.external!.fireMessage({
type: "focus_element",
payload: {
element_id: "combo-box",
},
});
return;
}
this._comboBox?.focus(); this._comboBox?.focus();
}); });
}; };
@@ -321,7 +270,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
this._newValue = value; this._newValue = value;
} }
private _clear(e: CustomEvent) { private _clear(e) {
e.stopPropagation(); e.stopPropagation();
this._setValue(undefined); this._setValue(undefined);
} }
@@ -388,9 +337,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
display: block; display: block;
margin: var(--ha-space-2) 0 0; margin: var(--ha-space-2) 0 0;
} }
:host([invalid]) ha-input-helper-text {
color: var(--mdc-theme-error, var(--error-color, #b00020));
}
wa-popover { wa-popover {
--wa-space-l: var(--ha-space-0); --wa-space-l: var(--ha-space-0);
@@ -413,6 +359,12 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
} }
} }
@media (max-height: 1000px) {
wa-popover::part(body) {
max-height: 400px;
}
}
ha-bottom-sheet { ha-bottom-sheet {
--ha-bottom-sheet-height: 90vh; --ha-bottom-sheet-height: 90vh;
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12)); --ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));

View File

@@ -32,12 +32,6 @@ export class HaGridSizeEditor extends LitElement {
@property({ attribute: false }) public step = 1; @property({ attribute: false }) public step = 1;
@property({ type: Boolean, attribute: "rows-disabled" })
public rowsDisabled?: boolean;
@property({ type: Boolean, attribute: "columns-disabled" })
public columnsDisabled?: boolean;
@state() public _localValue?: CardGridSize = { rows: 1, columns: 1 }; @state() public _localValue?: CardGridSize = { rows: 1, columns: 1 };
protected willUpdate(changedProperties) { protected willUpdate(changedProperties) {
@@ -48,11 +42,9 @@ export class HaGridSizeEditor extends LitElement {
protected render() { protected render() {
const disabledColumns = const disabledColumns =
this.columnsDisabled || this.columnMin !== undefined && this.columnMin === this.columnMax;
(this.columnMin !== undefined && this.columnMin === this.columnMax);
const disabledRows = const disabledRows =
this.rowsDisabled || this.rowMin !== undefined && this.rowMin === this.rowMax;
(this.rowMin !== undefined && this.rowMin === this.rowMax);
const autoHeight = this._localValue?.rows === "auto"; const autoHeight = this._localValue?.rows === "auto";
const fullWidth = this._localValue?.columns === "full"; const fullWidth = this._localValue?.columns === "full";
@@ -80,7 +72,7 @@ export class HaGridSizeEditor extends LitElement {
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
@slider-moved=${this._sliderMoved} @slider-moved=${this._sliderMoved}
.disabled=${disabledColumns} .disabled=${disabledColumns}
tooltip-mode=${disabledColumns ? "never" : "always"} tooltip-mode="always"
></ha-grid-layout-slider> ></ha-grid-layout-slider>
<ha-grid-layout-slider <ha-grid-layout-slider
@@ -96,7 +88,7 @@ export class HaGridSizeEditor extends LitElement {
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
@slider-moved=${this._sliderMoved} @slider-moved=${this._sliderMoved}
.disabled=${disabledRows} .disabled=${disabledRows}
tooltip-mode=${disabledRows ? "never" : "always"} tooltip-mode="always"
></ha-grid-layout-slider> ></ha-grid-layout-slider>
${!this.isDefault ${!this.isDefault
? html` ? html`

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,12 +40,14 @@ export const getLanguageOptions = (
return { return {
id: lang, id: lang,
primary, primary,
search_labels: [primary],
}; };
}); });
} else if (locale) { } else if (locale) {
options = languages.map((lang) => ({ options = languages.map((lang) => ({
id: lang, id: lang,
primary: formatLanguageCode(lang, locale), primary: formatLanguageCode(lang, locale),
search_labels: [formatLanguageCode(lang, locale)],
})); }));
} }
@@ -116,11 +118,6 @@ export class HaLanguagePicker extends LitElement {
> `; > `;
protected render() { protected render() {
const label =
this.label ??
(this.hass?.localize("ui.components.language-picker.language") ||
"Language");
const value = const value =
this.value ?? this.value ??
(this.required && !this.disabled ? this._getItems()[0].id : this.value); (this.required && !this.disabled ? this._getItems()[0].id : this.value);
@@ -134,7 +131,9 @@ export class HaLanguagePicker extends LitElement {
.emptyLabel=${this.hass?.localize( .emptyLabel=${this.hass?.localize(
"ui.components.language-picker.no_languages" "ui.components.language-picker.no_languages"
) || "No languages available"} ) || "No languages available"}
.label=${label} .placeholder=${this.label ??
(this.hass?.localize("ui.components.language-picker.language") ||
"Language")}
.value=${value} .value=${value}
.valueRenderer=${this._valueRenderer} .valueRenderer=${this._valueRenderer}
.disabled=${this.disabled} .disabled=${this.disabled}

View File

@@ -1,17 +1,55 @@
import { html, LitElement, nothing } from "lit"; import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import { customElement, property, state } from "lit/decorators"; import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { titleCase } from "../common/string/title-case"; import { titleCase } from "../common/string/title-case";
import { fetchConfig } from "../data/lovelace/config/types"; import { fetchConfig } from "../data/lovelace/config/types";
import type { LovelaceViewRawConfig } from "../data/lovelace/config/view";
import { getPanelIcon, getPanelTitle } from "../data/panel"; import { getPanelIcon, getPanelTitle } from "../data/panel";
import type { HomeAssistant, ValueChangedEvent } from "../types"; import type { HomeAssistant, PanelInfo, ValueChangedEvent } from "../types";
import "./ha-generic-picker"; import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box";
import "./ha-combo-box-item";
import "./ha-icon"; import "./ha-icon";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
interface NavigationItem {
path: string;
icon: string;
title: string;
}
const DEFAULT_ITEMS: NavigationItem[] = [];
const rowRenderer: ComboBoxLitRenderer<NavigationItem> = (item) => html`
<ha-combo-box-item type="button">
<ha-icon .icon=${item.icon} slot="start"></ha-icon>
<span slot="headline">${item.title || item.path}</span>
${item.title
? html`<span slot="supporting-text">${item.path}</span>`
: nothing}
</ha-combo-box-item>
`;
const createViewNavigationItem = (
prefix: string,
view: LovelaceViewRawConfig,
index: number
) => ({
path: `/${prefix}/${view.path ?? index}`,
icon: view.icon ?? "mdi:view-compact",
title: view.title ?? (view.path ? titleCase(view.path) : `${index}`),
});
const createPanelNavigationItem = (hass: HomeAssistant, panel: PanelInfo) => ({
path: `/${panel.url_path}`,
icon: getPanelIcon(panel) || "mdi:view-dashboard",
title: getPanelTitle(hass, panel) || "",
});
@customElement("ha-navigation-picker") @customElement("ha-navigation-picker")
export class HaNavigationPicker extends LitElement { export class HaNavigationPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass?: HomeAssistant;
@property() public label?: string; @property() public label?: string;
@@ -23,51 +61,46 @@ export class HaNavigationPicker extends LitElement {
@property({ type: Boolean }) public required = false; @property({ type: Boolean }) public required = false;
@state() private _loading = true; @state() private _opened = false;
protected firstUpdated() { private navigationItemsLoaded = false;
this._loadNavigationItems();
}
private _navigationItems: PickerComboBoxItem[] = []; private navigationItems: NavigationItem[] = DEFAULT_ITEMS;
protected render() { @query("ha-combo-box", true) private comboBox!: HaComboBox;
protected render(): TemplateResult {
return html` return html`
<ha-generic-picker <ha-combo-box
.hass=${this.hass} .hass=${this.hass}
.value=${this._loading ? undefined : this.value} item-value-path="path"
item-label-path="path"
.value=${this._value}
allow-custom-value allow-custom-value
.placeholder=${this.label} .filteredItems=${this.navigationItems}
.label=${this.label}
.helper=${this.helper} .helper=${this.helper}
.disabled=${this._loading || this.disabled} .disabled=${this.disabled}
.required=${this.required} .required=${this.required}
.getItems=${this._getItems} .renderer=${rowRenderer}
.valueRenderer=${this._valueRenderer} @opened-changed=${this._openedChanged}
.customValueLabel=${this.hass.localize(
"ui.components.navigation-picker.add_custom_path"
)}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
@filter-changed=${this._filterChanged}
> >
</ha-generic-picker> </ha-combo-box>
`; `;
} }
private _valueRenderer = (itemId: string) => { private async _openedChanged(ev: ValueChangedEvent<boolean>) {
const item = this._navigationItems.find((navItem) => navItem.id === itemId); this._opened = ev.detail.value;
return html` if (this._opened && !this.navigationItemsLoaded) {
${item?.icon this._loadNavigationItems();
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>` }
: nothing} }
<span slot="headline">${item?.primary || itemId}</span>
${item?.primary
? html`<span slot="supporting-text">${itemId}</span>`
: nothing}
`;
};
private _getItems = () => this._navigationItems;
private async _loadNavigationItems() { private async _loadNavigationItems() {
this.navigationItemsLoaded = true;
const panels = Object.entries(this.hass!.panels).map(([id, panel]) => ({ const panels = Object.entries(this.hass!.panels).map(([id, panel]) => ({
id, id,
...panel, ...panel,
@@ -91,47 +124,27 @@ export class HaNavigationPicker extends LitElement {
const panelViewConfig = new Map(viewConfigs); const panelViewConfig = new Map(viewConfigs);
this._navigationItems = []; this.navigationItems = [];
for (const panel of panels) { for (const panel of panels) {
const path = `/${panel.url_path}`; this.navigationItems.push(createPanelNavigationItem(this.hass!, panel));
const panelTitle = getPanelTitle(this.hass, panel);
const primary = panelTitle || path;
this._navigationItems.push({
id: path,
primary,
secondary: panelTitle ? path : undefined,
icon: getPanelIcon(panel) || "mdi:view-dashboard",
sorting_label: [
primary.startsWith("/") ? `zzz${primary}` : primary,
path,
]
.filter(Boolean)
.join("_"),
});
const config = panelViewConfig.get(panel.id); const config = panelViewConfig.get(panel.id);
if (!config || !("views" in config)) continue; if (!config || !("views" in config)) continue;
config.views.forEach((view, index) => { config.views.forEach((view, index) =>
const viewPath = `/${panel.url_path}/${view.path ?? index}`; this.navigationItems.push(
const viewPrimary = createViewNavigationItem(panel.url_path, view, index)
view.title ?? (view.path ? titleCase(view.path) : `${index}`); )
this._navigationItems.push({ );
id: viewPath,
secondary: viewPath,
icon: view.icon ?? "mdi:view-compact",
primary: viewPrimary,
sorting_label: [
viewPrimary.startsWith("/") ? `zzz${viewPrimary}` : viewPrimary,
viewPath,
].join("_"),
});
});
} }
this._loading = false; this.comboBox.filteredItems = this.navigationItems;
}
protected shouldUpdate(changedProps: PropertyValues) {
return !this._opened || changedProps.has("_opened");
} }
private _valueChanged(ev: ValueChangedEvent<string>) { private _valueChanged(ev: ValueChangedEvent<string>) {
@@ -139,18 +152,61 @@ export class HaNavigationPicker extends LitElement {
this._setValue(ev.detail.value); this._setValue(ev.detail.value);
} }
private _setValue(value = "") { private _setValue(value: string) {
this.value = value; this.value = value;
fireEvent( fireEvent(
this, this,
"value-changed", "value-changed",
{ value: this.value }, { value: this._value },
{ {
bubbles: false, bubbles: false,
composed: false, composed: false,
} }
); );
} }
private _filterChanged(ev: CustomEvent): void {
const filterString = ev.detail.value.toLowerCase();
const characterCount = filterString.length;
if (characterCount >= 2) {
const filteredItems: NavigationItem[] = [];
this.navigationItems.forEach((item) => {
if (
item.path.toLowerCase().includes(filterString) ||
item.title.toLowerCase().includes(filterString)
) {
filteredItems.push(item);
}
});
if (filteredItems.length > 0) {
this.comboBox.filteredItems = filteredItems;
} else {
this.comboBox.filteredItems = [];
}
} else {
this.comboBox.filteredItems = this.navigationItems;
}
}
private get _value() {
return this.value || "";
}
static styles = css`
ha-icon,
ha-svg-icon {
color: var(--primary-text-color);
position: relative;
bottom: 0px;
}
*[slot="prefix"] {
margin-right: 8px;
margin-inline-end: 8px;
margin-inline-start: initial;
}
`;
} }
declare global { declare global {

View File

@@ -1,6 +1,6 @@
import type { LitVirtualizer } from "@lit-labs/virtualizer"; import type { LitVirtualizer } from "@lit-labs/virtualizer";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize"; import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiMagnify, mdiMinusBoxOutline, mdiPlus } from "@mdi/js"; import { mdiMagnify, mdiMinusBoxOutline } from "@mdi/js";
import Fuse from "fuse.js"; import Fuse from "fuse.js";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { import {
@@ -14,11 +14,7 @@ import memoizeOne from "memoize-one";
import { tinykeys } from "tinykeys"; import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare"; import { caseInsensitiveStringCompare } from "../common/string/compare";
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin"; import { HaFuse } from "../resources/fuse";
import {
multiTermSortedSearch,
type FuseWeightedKey,
} from "../resources/fuseMultiTerm";
import { haStyleScrollbar } from "../resources/styles"; import { haStyleScrollbar } from "../resources/styles";
import { loadVirtualizer } from "../resources/virtualizer"; import { loadVirtualizer } from "../resources/virtualizer";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
@@ -29,26 +25,11 @@ import "./ha-icon";
import "./ha-textfield"; import "./ha-textfield";
import type { HaTextField } from "./ha-textfield"; import type { HaTextField } from "./ha-textfield";
export const DEFAULT_SEARCH_KEYS: FuseWeightedKey[] = [
{
name: "primary",
weight: 10,
},
{
name: "secondary",
weight: 7,
},
{
name: "id",
weight: 3,
},
];
export interface PickerComboBoxItem { export interface PickerComboBoxItem {
id: string; id: string;
primary: string; primary: string;
secondary?: string; secondary?: string;
search_labels?: Record<string, string | null>; search_labels?: string[];
sorting_label?: string; sorting_label?: string;
icon_path?: string; icon_path?: string;
icon?: string; icon?: string;
@@ -78,7 +59,7 @@ export type PickerComboBoxSearchFn<T extends PickerComboBoxItem> = (
) => T[]; ) => T[];
@customElement("ha-picker-combo-box") @customElement("ha-picker-combo-box")
export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) { export class HaPickerComboBox extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public hass?: HomeAssistant;
// eslint-disable-next-line lit/no-native-attributes // eslint-disable-next-line lit/no-native-attributes
@@ -91,20 +72,14 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@property({ type: Boolean, attribute: "allow-custom-value" }) @property({ type: Boolean, attribute: "allow-custom-value" })
public allowCustomValue; public allowCustomValue;
@property({ attribute: "custom-value-label" })
public customValueLabel?: string;
@property() public label?: string; @property() public label?: string;
@property() public value?: string; @property() public value?: string;
@property({ attribute: false })
public searchKeys?: FuseWeightedKey[];
@state() private _listScrolled = false; @state() private _listScrolled = false;
@property({ attribute: false }) @property({ attribute: false })
public getItems!: ( public getItems?: (
searchString?: string, searchString?: string,
section?: string section?: string
) => (PickerComboBoxItem | string)[]; ) => (PickerComboBoxItem | string)[];
@@ -151,14 +126,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@state() private _items: (PickerComboBoxItem | string)[] = []; @state() private _items: (PickerComboBoxItem | string)[] = [];
protected get scrollableElement(): HTMLElement | null {
return this._virtualizerElement as HTMLElement | null;
}
@state() private _sectionTitle?: string; @state() private _sectionTitle?: string;
@state() private _valuePinned = true;
private _allItems: (PickerComboBoxItem | string)[] = []; private _allItems: (PickerComboBoxItem | string)[] = [];
private _selectedItemIndex = -1; private _selectedItemIndex = -1;
@@ -190,15 +159,10 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
} }
protected render() { protected render() {
const searchLabel =
this.label ??
(this.allowCustomValue
? (this.hass?.localize("ui.components.combo-box.search_or_custom") ??
"Search | Add custom value")
: (this.hass?.localize("ui.common.search") ?? "Search"));
return html`<ha-textfield return html`<ha-textfield
.label=${searchLabel} .label=${this.label ??
this.hass?.localize("ui.common.search") ??
"Search"}
@input=${this._filterChanged} @input=${this._filterChanged}
></ha-textfield> ></ha-textfield>
${this._renderSectionButtons()} ${this._renderSectionButtons()}
@@ -216,31 +180,19 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
</div> </div>
` `
: nothing} : nothing}
<div class="virtualizer-wrapper"> <lit-virtualizer
<lit-virtualizer .keyFunction=${this._keyFunction}
.keyFunction=${this._keyFunction} tabindex="0"
tabindex="0" scroller
scroller .items=${this._items}
.items=${this._items} .renderItem=${this._renderItem}
.renderItem=${this._renderItem} style="min-height: 36px;"
style="min-height: 36px;" class=${this._listScrolled ? "scrolled" : ""}
class=${this._listScrolled ? "scrolled" : ""} @scroll=${this._onScrollList}
.layout=${this.value && this._valuePinned @focus=${this._focusList}
? { @visibilityChanged=${this._visibilityChanged}
pin: { >
index: this._getInitialSelectedIndex(), </lit-virtualizer>`;
block: "center",
},
}
: undefined}
@unpinned=${this._handleUnpinned}
@scroll=${this._onScrollList}
@focus=${this._focusList}
@visibilityChanged=${this._visibilityChanged}
>
</lit-virtualizer>
${this.renderScrollableFades()}
</div>`;
} }
private _renderSectionButtons() { private _renderSectionButtons() {
@@ -284,42 +236,24 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
} }
} }
@eventOptions({ passive: true })
private _handleUnpinned() {
this._valuePinned = false;
}
private _getAdditionalItems = (searchString?: string) => private _getAdditionalItems = (searchString?: string) =>
this.getAdditionalItems?.(searchString) || []; this.getAdditionalItems?.(searchString) || [];
private _getItems = () => { private _getItems = () => {
let items = [...this.getItems(this._search, this.selectedSection)]; let items = [
...(this.getItems
? this.getItems(this._search, this.selectedSection)
: []),
];
if (!this.sections?.length) { if (!this.sections?.length) {
items = items.sort((entityA, entityB) => { items = items.sort((entityA, entityB) =>
const sortLabelA = caseInsensitiveStringCompare(
typeof entityA === "string" ? entityA : entityA.sorting_label; (entityA as PickerComboBoxItem).sorting_label!,
const sortLabelB = (entityB as PickerComboBoxItem).sorting_label!,
typeof entityB === "string" ? entityB : entityB.sorting_label;
if (!sortLabelA || !sortLabelB) {
return 0;
}
if (!sortLabelB) {
return -1;
}
if (!sortLabelA) {
return 1;
}
return caseInsensitiveStringCompare(
sortLabelA,
sortLabelB,
this.hass?.locale.language ?? navigator.language this.hass?.locale.language ?? navigator.language
); )
}); );
} }
if (!items.length) { if (!items.length) {
@@ -337,9 +271,6 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
}; };
private _renderItem = (item: PickerComboBoxItem | string, index: number) => { private _renderItem = (item: PickerComboBoxItem | string, index: number) => {
if (!item) {
return nothing;
}
if (item === "padding") { if (item === "padding") {
return html`<div class="bottom-padding"></div>`; return html`<div class="bottom-padding"></div>`;
} }
@@ -400,9 +331,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
fireEvent(this, "value-changed", { value: newValue }); fireEvent(this, "value-changed", { value: newValue });
}; };
private _fuseIndex = memoizeOne( private _fuseIndex = memoizeOne((states: PickerComboBoxItem[]) =>
(states: PickerComboBoxItem[], searchKeys?: FuseWeightedKey[]) => Fuse.createIndex(["search_labels"], states)
Fuse.createIndex(searchKeys || DEFAULT_SEARCH_KEYS, states)
); );
private _filterChanged = (ev: Event) => { private _filterChanged = (ev: Event) => {
@@ -418,26 +348,34 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
return; return;
} }
const index = this._fuseIndex( const index = this._fuseIndex(this._allItems as PickerComboBoxItem[]);
const fuse = new HaFuse(
this._allItems as PickerComboBoxItem[], this._allItems as PickerComboBoxItem[],
this.searchKeys {
shouldSort: false,
minMatchCharLength: Math.min(searchString.length, 2),
},
index
); );
let filteredItems = multiTermSortedSearch<PickerComboBoxItem>( const results = fuse.multiTermsSearch(searchString);
this._allItems as PickerComboBoxItem[], let filteredItems = [...this._allItems];
searchString,
this.searchKeys || DEFAULT_SEARCH_KEYS,
(item) => item.id,
index
) as (PickerComboBoxItem | string)[];
if (!filteredItems.length) { if (results) {
filteredItems.push(NO_ITEMS_AVAILABLE_ID); const items: (PickerComboBoxItem | string)[] = results.map(
(result) => result.item
);
if (!items.length) {
filteredItems.push(NO_ITEMS_AVAILABLE_ID);
}
const additionalItems = this._getAdditionalItems();
items.push(...additionalItems);
filteredItems = items;
} }
const additionalItems = this._getAdditionalItems();
filteredItems.push(...additionalItems);
if (this.searchFn) { if (this.searchFn) {
filteredItems = this.searchFn( filteredItems = this.searchFn(
searchString, searchString,
@@ -446,23 +384,13 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
); );
} }
if (this.allowCustomValue && searchString) {
filteredItems.push({
id: searchString,
primary:
this.customValueLabel ??
this.hass?.localize("ui.components.combo-box.add_custom_item") ??
"Add custom item",
secondary: `"${searchString}"`,
icon_path: mdiPlus,
});
}
this._items = filteredItems as PickerComboBoxItem[]; this._items = filteredItems as PickerComboBoxItem[];
} }
this._selectedItemIndex = -1; this._selectedItemIndex = -1;
this._valuePinned = true; if (this._virtualizerElement) {
this._virtualizerElement.scrollTo(0, 0);
}
}; };
private _toggleSection(ev: Event) { private _toggleSection(ev: Event) {
@@ -654,187 +582,158 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
} }
private _keyFunction = (item: PickerComboBoxItem | string) => private _keyFunction = (item: PickerComboBoxItem | string) =>
typeof item === "string" ? item : item?.id; typeof item === "string" ? item : item.id;
private _getInitialSelectedIndex() { static styles = [
if (!this._virtualizerElement || this._search || !this.value) { haStyleScrollbar,
return 0; css`
} :host {
display: flex;
flex-direction: column;
padding-top: var(--ha-space-3);
flex: 1;
}
const index = this._virtualizerElement.items.findIndex( ha-textfield {
(item) => padding: 0 var(--ha-space-3);
typeof item !== "string" && margin-bottom: var(--ha-space-3);
(item as PickerComboBoxItem).id === this.value }
);
if (index === -1) { :host([mode="dialog"]) ha-textfield {
return 0; padding: 0 var(--ha-space-4);
} }
return index; ha-combo-box-item {
} width: 100%;
}
static get styles() { ha-combo-box-item.selected {
return [ background-color: var(--ha-color-fill-neutral-quiet-hover);
...super.styles, }
haStyleScrollbar,
css`
:host {
display: flex;
flex-direction: column;
padding-top: var(--ha-space-3);
flex: 1;
}
ha-textfield {
padding: 0 var(--ha-space-3);
margin-bottom: var(--ha-space-3);
}
:host([mode="dialog"]) ha-textfield {
padding: 0 var(--ha-space-4);
}
ha-combo-box-item {
width: 100%;
}
@media (prefers-color-scheme: dark) {
ha-combo-box-item.selected { ha-combo-box-item.selected {
background-color: var(--ha-color-fill-neutral-quiet-hover); background-color: var(--ha-color-fill-neutral-normal-hover);
} }
}
@media (prefers-color-scheme: dark) { lit-virtualizer {
ha-combo-box-item.selected { flex: 1;
background-color: var(--ha-color-fill-neutral-normal-hover); }
}
}
.virtualizer-wrapper { lit-virtualizer:focus-visible {
position: relative; outline: none;
flex: 1; }
display: flex;
flex-direction: column;
min-height: 0;
}
lit-virtualizer { lit-virtualizer.scrolled {
flex: 1; border-top: 1px solid var(--ha-color-border-neutral-quiet);
} }
lit-virtualizer:focus-visible { .bottom-padding {
outline: none; height: max(var(--safe-area-inset-bottom, 0px), var(--ha-space-8));
} width: 100%;
}
lit-virtualizer.scrolled { .empty {
border-top: 1px solid var(--ha-color-border-neutral-quiet); text-align: center;
} }
.bottom-padding { .combo-box-row {
height: max(var(--safe-area-inset-bottom, 0px), var(--ha-space-8)); display: flex;
width: 100%; width: 100%;
} align-items: center;
box-sizing: border-box;
min-height: 36px;
}
.combo-box-row.current-value {
background-color: var(--ha-color-fill-primary-quiet-resting);
}
.empty { .combo-box-row.selected {
text-align: center; background-color: var(--ha-color-fill-neutral-quiet-hover);
} }
.combo-box-row {
display: flex;
width: 100%;
align-items: center;
box-sizing: border-box;
min-height: 36px;
}
.combo-box-row.current-value {
background-color: var(--ha-color-fill-primary-quiet-resting);
}
@media (prefers-color-scheme: dark) {
.combo-box-row.selected { .combo-box-row.selected {
background-color: var(--ha-color-fill-neutral-quiet-hover); background-color: var(--ha-color-fill-neutral-normal-hover);
} }
}
@media (prefers-color-scheme: dark) { .sections {
.combo-box-row.selected { display: flex;
background-color: var(--ha-color-fill-neutral-normal-hover); flex-wrap: nowrap;
} gap: var(--ha-space-2);
} padding: var(--ha-space-3) var(--ha-space-3);
overflow: auto;
}
.sections { :host([mode="dialog"]) .sections {
display: flex; padding: var(--ha-space-3) var(--ha-space-4);
flex-wrap: nowrap; }
gap: var(--ha-space-2);
padding: var(--ha-space-3) var(--ha-space-3);
overflow: auto;
}
:host([mode="dialog"]) .sections { .sections ha-filter-chip {
padding: var(--ha-space-3) var(--ha-space-4); flex-shrink: 0;
} --md-filter-chip-selected-container-color: var(
--ha-color-fill-primary-normal-hover
);
color: var(--primary-color);
}
.sections ha-filter-chip { .sections .separator {
flex-shrink: 0; height: var(--ha-space-8);
--md-filter-chip-selected-container-color: var( width: 0;
--ha-color-fill-primary-normal-hover border: 1px solid var(--ha-color-border-neutral-quiet);
); }
color: var(--primary-color);
}
.sections .separator { .section-title,
height: var(--ha-space-8); .title {
width: 0; background-color: var(--ha-color-fill-neutral-quiet-resting);
border: 1px solid var(--ha-color-border-neutral-quiet); padding: var(--ha-space-1) var(--ha-space-2);
} font-weight: var(--ha-font-weight-bold);
color: var(--secondary-text-color);
min-height: var(--ha-space-6);
display: flex;
align-items: center;
}
.section-title, .title {
.title { width: 100%;
background-color: var(--ha-color-fill-neutral-quiet-resting); }
padding: var(--ha-space-1) var(--ha-space-2);
font-weight: var(--ha-font-weight-bold);
color: var(--secondary-text-color);
min-height: var(--ha-space-6);
display: flex;
align-items: center;
}
.title { :host([mode="dialog"]) .title {
width: 100%; padding: var(--ha-space-1) var(--ha-space-4);
} }
:host([mode="dialog"]) .title { :host([mode="dialog"]) ha-textfield {
padding: var(--ha-space-1) var(--ha-space-4); padding: 0 var(--ha-space-4);
} }
:host([mode="dialog"]) ha-textfield { .section-title-wrapper {
padding: 0 var(--ha-space-4); height: 0;
} position: relative;
}
.section-title-wrapper { .section-title {
height: 0; opacity: 0;
position: relative; position: absolute;
} top: 1px;
width: calc(100% - var(--ha-space-8));
}
.section-title { .section-title.show {
opacity: 0; opacity: 1;
position: absolute; z-index: 1;
top: 1px; }
width: calc(100% - var(--ha-space-8));
}
.section-title.show { .empty-search {
opacity: 1; display: flex;
z-index: 1; width: 100%;
} flex-direction: column;
align-items: center;
.empty-search { padding: var(--ha-space-3);
display: flex; }
width: 100%; `,
flex-direction: column; ];
align-items: center;
padding: var(--ha-space-3);
}
`,
];
}
} }
declare global { declare global {

View File

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

View File

@@ -9,13 +9,13 @@ import { customElement, property, query, state } from "lit/decorators";
import { prepareZXingModule } from "barcode-detector"; import { prepareZXingModule } from "barcode-detector";
import type QrScanner from "qr-scanner"; import type QrScanner from "qr-scanner";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { addExternalBarCodeListener } from "../external_app/external_app_entrypoint"; import { addExternalBarCodeListener } from "../external_app/external_app_entrypoint";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import "./ha-alert"; import "./ha-alert";
import "./ha-button"; import "./ha-button";
import "./ha-dropdown"; import "./ha-button-menu";
import "./ha-dropdown-item"; import "./ha-list-item";
import type { HaDropdownItem } from "./ha-dropdown-item";
import "./ha-spinner"; import "./ha-spinner";
import "./ha-textfield"; import "./ha-textfield";
import type { HaTextField } from "./ha-textfield"; import type { HaTextField } from "./ha-textfield";
@@ -52,8 +52,6 @@ class HaQrScanner extends LitElement {
@state() private _warning?: string; @state() private _warning?: string;
@state() private _selectedCamera?: string;
private _qrScanner?: QrScanner; private _qrScanner?: QrScanner;
private _qrNotFoundCount = 0; private _qrNotFoundCount = 0;
@@ -123,7 +121,7 @@ class HaQrScanner extends LitElement {
!this._error && !this._error &&
this._cameras && this._cameras &&
this._cameras.length > 1 this._cameras.length > 1
? html`<ha-dropdown @wa-select=${this._handleDropdownSelect}> ? html`<ha-button-menu fixed @closed=${stopPropagation}>
<ha-icon-button <ha-icon-button
slot="trigger" slot="trigger"
.label=${this.hass.localize( .label=${this.hass.localize(
@@ -133,17 +131,15 @@ class HaQrScanner extends LitElement {
></ha-icon-button> ></ha-icon-button>
${this._cameras!.map( ${this._cameras!.map(
(camera) => html` (camera) => html`
<ha-dropdown-item <ha-list-item
.value=${camera.id} .value=${camera.id}
class=${this._selectedCamera === camera.id @click=${this._cameraChanged}
? "selected"
: ""}
> >
${camera.label} ${camera.label}
</ha-dropdown-item> </ha-list-item>
` `
)} )}
</ha-dropdown>` </ha-button-menu>`
: nothing} : nothing}
</div>` </div>`
: html`<ha-alert alert-type="warning"> : html`<ha-alert alert-type="warning">
@@ -209,9 +205,6 @@ class HaQrScanner extends LitElement {
private async _listCameras(qrScanner: typeof QrScanner): Promise<void> { private async _listCameras(qrScanner: typeof QrScanner): Promise<void> {
this._cameras = await qrScanner.listCameras(true); this._cameras = await qrScanner.listCameras(true);
if (this._cameras.length > 0) {
this._selectedCamera = this._cameras[0].id;
}
} }
private _qrCodeError = (err: any) => { private _qrCodeError = (err: any) => {
@@ -259,12 +252,8 @@ class HaQrScanner extends LitElement {
this._qrCodeScanned(this._manualInput!.value); this._qrCodeScanned(this._manualInput!.value);
} }
private _handleDropdownSelect(ev: CustomEvent<{ item: HaDropdownItem }>) { private _cameraChanged(ev: CustomEvent): void {
const cameraId = ev.detail?.item?.value; this._qrScanner?.setCamera((ev.target as any).value);
if (cameraId) {
this._selectedCamera = cameraId;
this._qrScanner?.setCamera(cameraId);
}
} }
private _openExternalScanner() { private _openExternalScanner() {
@@ -370,7 +359,7 @@ class HaQrScanner extends LitElement {
#canvas-container { #canvas-container {
position: relative; position: relative;
} }
ha-icon-button { ha-button-menu {
position: absolute; position: absolute;
bottom: 8px; bottom: 8px;
right: 8px; right: 8px;
@@ -380,9 +369,6 @@ class HaQrScanner extends LitElement {
color: white; color: white;
border-radius: var(--ha-border-radius-circle); border-radius: var(--ha-border-radius-circle);
} }
ha-dropdown-item.selected {
font-weight: var(--ha-font-weight-bold);
}
.row { .row {
display: flex; display: flex;
align-items: center; align-items: center;

View File

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

View File

@@ -28,6 +28,7 @@ export class HaAddonSelector extends LitElement {
.helper=${this.helper} .helper=${this.helper}
.disabled=${this.disabled} .disabled=${this.disabled}
.required=${this.required} .required=${this.required}
allow-custom-entity
></ha-addon-picker>`; ></ha-addon-picker>`;
} }

View File

@@ -4,14 +4,14 @@ import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array"; import { ensureArray } from "../../common/array/ensure-array";
import type { DeviceRegistryEntry } from "../../data/device_registry";
import { getDeviceIntegrationLookup } from "../../data/device_registry";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import type { EntitySources } from "../../data/entity_sources";
import { fetchEntitySourcesWithCache } from "../../data/entity_sources";
import type { AreaSelector } from "../../data/selector";
import type { ConfigEntry } from "../../data/config_entries"; import type { ConfigEntry } from "../../data/config_entries";
import { getConfigEntries } from "../../data/config_entries"; import { getConfigEntries } from "../../data/config_entries";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import { getDeviceIntegrationLookup } from "../../data/device/device_registry";
import type { EntitySources } from "../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources";
import type { AreaSelector } from "../../data/selector";
import { import {
filterSelectorDevices, filterSelectorDevices,
filterSelectorEntities, filterSelectorEntities,

View File

@@ -29,6 +29,7 @@ export class HaConfigEntrySelector extends LitElement {
.disabled=${this.disabled} .disabled=${this.disabled}
.required=${this.required} .required=${this.required}
.integration=${this.selector.config_entry?.integration} .integration=${this.selector.config_entry?.integration}
allow-custom-entity
></ha-config-entry-picker>`; ></ha-config-entry-picker>`;
} }

View File

@@ -5,13 +5,13 @@ import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array"; import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import type { DeviceRegistryEntry } from "../../data/device_registry";
import { getDeviceIntegrationLookup } from "../../data/device_registry";
import type { EntitySources } from "../../data/entity_sources";
import { fetchEntitySourcesWithCache } from "../../data/entity_sources";
import type { DeviceSelector } from "../../data/selector";
import type { ConfigEntry } from "../../data/config_entries"; import type { ConfigEntry } from "../../data/config_entries";
import { getConfigEntries } from "../../data/config_entries"; import { getConfigEntries } from "../../data/config_entries";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import { getDeviceIntegrationLookup } from "../../data/device/device_registry";
import type { EntitySources } from "../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources";
import type { DeviceSelector } from "../../data/selector";
import { import {
filterSelectorDevices, filterSelectorDevices,
filterSelectorEntities, filterSelectorEntities,
@@ -107,6 +107,7 @@ export class HaDeviceSelector extends LitElement {
.placeholder=${this.placeholder} .placeholder=${this.placeholder}
.disabled=${this.disabled} .disabled=${this.disabled}
.required=${this.required} .required=${this.required}
allow-custom-entity
></ha-device-picker> ></ha-device-picker>
`; `;
} }

View File

@@ -4,12 +4,12 @@ import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { ensureArray } from "../../common/array/ensure-array"; import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import type { EntitySources } from "../../data/entity/entity_sources"; import type { EntitySources } from "../../data/entity_sources";
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources"; import { fetchEntitySourcesWithCache } from "../../data/entity_sources";
import type { EntitySelector } from "../../data/selector"; import type { EntitySelector } from "../../data/selector";
import { import {
computeCreateDomains,
filterSelectorEntities, filterSelectorEntities,
computeCreateDomains,
} from "../../data/selector"; } from "../../data/selector";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import "../entity/ha-entities-picker"; import "../entity/ha-entities-picker";
@@ -66,14 +66,15 @@ export class HaEntitySelector extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.value=${this.value} .value=${this.value}
.label=${this.label} .label=${this.label}
.placeholder=${this.placeholder}
.helper=${this.helper} .helper=${this.helper}
.includeEntities=${this.selector.entity?.include_entities} .includeEntities=${this.selector.entity?.include_entities}
.excludeEntities=${this.selector.entity?.exclude_entities} .excludeEntities=${this.selector.entity?.exclude_entities}
.entityFilter=${this._filterEntities} .entityFilter=${this._filterEntities}
.createDomains=${this._createDomains} .createDomains=${this._createDomains}
.placeholder=${this.placeholder}
.disabled=${this.disabled} .disabled=${this.disabled}
.required=${this.required} .required=${this.required}
allow-custom-entity
></ha-entity-picker>`; ></ha-entity-picker>`;
} }
@@ -82,13 +83,13 @@ export class HaEntitySelector extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.value=${this.value} .value=${this.value}
.label=${this.label} .label=${this.label}
.placeholder=${this.placeholder}
.helper=${this.helper} .helper=${this.helper}
.includeEntities=${this.selector.entity.include_entities} .includeEntities=${this.selector.entity.include_entities}
.excludeEntities=${this.selector.entity.exclude_entities} .excludeEntities=${this.selector.entity.exclude_entities}
.reorder=${this.selector.entity.reorder ?? false} .reorder=${this.selector.entity.reorder ?? false}
.entityFilter=${this._filterEntities} .entityFilter=${this._filterEntities}
.createDomains=${this._createDomains} .createDomains=${this._createDomains}
.placeholder=${this.placeholder}
.disabled=${this.disabled} .disabled=${this.disabled}
.required=${this.required} .required=${this.required}
></ha-entities-picker> ></ha-entities-picker>

View File

@@ -4,14 +4,14 @@ import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array"; import { ensureArray } from "../../common/array/ensure-array";
import type { DeviceRegistryEntry } from "../../data/device_registry";
import { getDeviceIntegrationLookup } from "../../data/device_registry";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import type { EntitySources } from "../../data/entity_sources";
import { fetchEntitySourcesWithCache } from "../../data/entity_sources";
import type { FloorSelector } from "../../data/selector";
import type { ConfigEntry } from "../../data/config_entries"; import type { ConfigEntry } from "../../data/config_entries";
import { getConfigEntries } from "../../data/config_entries"; import { getConfigEntries } from "../../data/config_entries";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import { getDeviceIntegrationLookup } from "../../data/device/device_registry";
import type { EntitySources } from "../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources";
import type { FloorSelector } from "../../data/selector";
import { import {
filterSelectorDevices, filterSelectorDevices,
filterSelectorEntities, filterSelectorEntities,

View File

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

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