Compare commits

...

161 Commits

Author SHA1 Message Date
Wendelin
9b720c8a9f Refactor ha-auth-form-string and ha-form-string to use ha-input component; remove ha-auth-textfield 2026-02-27 13:29:59 +01:00
Wendelin
643942f350 Update ha-input and supervisor-network styles for improved layout 2026-02-27 11:36:54 +01:00
Wendelin
544a0c2971 Refactor ha-textfield to integrate ha-input component 2026-02-27 11:25:49 +01:00
Wendelin
1b367e85da Migrate ha-md-textfield 2026-02-27 10:57:25 +01:00
Wendelin
820c8d7975 Remove ha-password-field and migrate to ha-input 2026-02-27 10:15:51 +01:00
Wendelin
ab966d039a Add ha-input component and update ha-timer-form to use it 2026-02-26 13:25:13 +01:00
Wendelin
d06321ed43 Fix protocols dashboards fab padding (#29847) 2026-02-26 10:31:50 +02:00
dependabot[bot]
3c3d8d9974 Bump rollup from 2.79.2 to 2.80.0 (#29841)
Bumps [rollup](https://github.com/rollup/rollup) from 2.79.2 to 2.80.0.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/v2.80.0/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v2.79.2...v2.80.0)

---
updated-dependencies:
- dependency-name: rollup
  dependency-version: 2.80.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-26 08:18:15 +02:00
Paul Bottein
4f39fa482d Only ask to refresh dashboard in edit mode or yaml mode (#29826) 2026-02-26 08:16:21 +02:00
renovate[bot]
5d0fe3236c Update dependency @swc/helpers to v0.5.19 (#29836)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-26 07:07:37 +01:00
renovate[bot]
b86142ae50 Update Node.js to v24.14.0 (#29831)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 19:25:42 +00:00
renovate[bot]
5d2f3ee5e8 Update dependency tar to v7.5.9 (#29832)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 19:24:58 +00:00
AlCalzone
e3f7c631a7 Rename "Z-Wave JS" to "Z-Wave" when not referring to the project/org (#29830) 2026-02-25 19:15:16 +00:00
renovate[bot]
49f9d95853 Update dependency vite-tsconfig-paths to v6.1.1 (#29829)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 18:53:12 +01:00
renovate[bot]
db3d7701b5 Update dependency typescript-eslint to v8.56.0 (#29828)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 17:35:36 +00:00
renovate[bot]
3e55acf531 Update dependency @home-assistant/webawesome to v3.2.1-ha.3 (#29810)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 18:26:47 +01:00
renovate[bot]
f102618d9d Update dependency eslint-plugin-wc to v3.1.0 (#29824)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 18:25:06 +01:00
renovate[bot]
a3c02b511d Update dependency jsdom to v28.1.0 (#29825)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 18:24:38 +01:00
Bram Kragten
74111d248e Fix css minifying (#29827) 2026-02-25 17:53:50 +01:00
Bram Kragten
f8161b3505 Merge branch 'rc' into dev 2026-02-25 17:13:44 +01:00
Franck Nijhof
6070c1907a Adjust brands assets to proxy brand images through local API (#29799)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2026-02-25 17:10:38 +01:00
renovate[bot]
ce5991582c Update dependency @html-eslint/eslint-plugin to v0.56.0 (#29818)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 17:37:34 +02:00
Paul Bottein
d17217fc90 Use show in sidebar property instead of checking title (#29815) 2026-02-25 16:37:25 +01:00
renovate[bot]
86b4bd0013 Update dependency eslint-plugin-lit to v2.2.1 (#29821)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 17:36:42 +02:00
renovate[bot]
108ba3abd6 Update dependency eslint-plugin-unused-imports to v4.4.1 (#29822)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 17:36:12 +02:00
Matthias Alphart
d38a2894c4 Remove unused properties in ha-data-table and hass-tabs-subpage-data-table (#29808) 2026-02-25 16:31:39 +01:00
Aidan Timson
4c70376a62 Cleanup old comments (#29823) 2026-02-25 15:24:00 +00:00
Wendelin
8d69bd1401 Fix button active also for icon-buttons (#29820) 2026-02-25 16:21:31 +01:00
renovate[bot]
5dfecd3693 Update dependency @octokit/plugin-retry to v8.1.0 (#29819)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 15:09:19 +00:00
Aidan Timson
efd51d2234 Rename more info "Attributes" to "Details", add raw state and all available attributes (#29811) 2026-02-25 15:57:27 +01:00
renovate[bot]
668299c16a Update dependency marked to v17.0.3 (#29817)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 16:00:51 +02:00
renovate[bot]
5e155a4030 Update dependency glob to v13.0.6 (#29816)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 16:00:25 +02:00
gpoitch
809fa10135 Add day of week to energy chart tooltips (#29803)
* Add day of week to energy chart tooltips

* New localization helpers
2026-02-25 13:31:00 +00:00
Petar Petrov
1cbc38f231 Water flow rate sankey chart in Now view (#29804) 2026-02-25 14:18:48 +01:00
renovate[bot]
9ed39bb523 Update dependency @rspack/core to v1.7.6 (#29812)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 15:10:38 +02:00
renovate[bot]
4e3d66cf40 Update dependency eslint to v9.39.3 (#29813)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 15:10:03 +02:00
renovate[bot]
2eaad79d1c Update dependency @codemirror/view to v6.39.15 (#29807)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 12:47:06 +02:00
renovate[bot]
afef7a2c0f Update dependency @bundle-stats/plugin-webpack-filter to v4.21.10 (#29806)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 12:46:41 +02:00
renovate[bot]
18d5224002 Update dependency @formatjs/intl-datetimeformat to v7.2.2 (#29809)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-25 12:46:08 +02:00
Johan Henkens
dbffdfeaca Add cover of device type window to the security dashboard (#29797) 2026-02-25 11:11:13 +01:00
Artur Pragacz
0a4b7917ab Update vacuum segment mapping description (#29802) 2026-02-25 08:38:37 +01:00
Simon Lamon
e1524358d9 Remove duplicated buttons (#29798) 2026-02-25 08:22:40 +01:00
Artur Pragacz
8774f9c3fc Add vacuum mapping not configured issue (#29800) 2026-02-25 08:14:38 +01:00
Wendelin
f9a9aeacab Fix app panel narrow header safe area top (#29792)
* Enhance narrow property to reflect changes and adjust header padding for safe area

* Remove safe-area-inset-top for narrow iframe

* handle kiosk mode
2026-02-24 20:26:06 +01:00
ildar170975
b798fee116 Data tables: keep "Actions" as the last column (#29364)
* Data tables: keep "Actions" as the last column

* last_fixed -> lastFixed

* last_fixed -> lastFixed

* last_fixed -> lastFixed

* last_fixed -> lastFixed

* last_fixed -> lastFixed

* last_fixed -> lastFixed

* last_fixed -> lastFixed

* last_fixed -> lastFixed

* last_fixed -> lastFixed

* last_fixed -> lastFixed

* simplify

* Update dialog-data-table-settings.ts

* narrow down a column

* blank line added

* narrow dow Assistants a bit more

* remove moveable/hideable for "actions"

* remove moveable/hideable for "actions"

* remove moveable/hideable for "actions"

* remove moveable/hideable for "actions"

* remove moveable/hideable for "actions"

* remove moveable/hideable for "actions"

* remove moveable/hideable for "actions"

* remove moveable/hideable for "actions"
2026-02-24 20:24:53 +01:00
Norbert Rittel
b25f731f0f Simplify card descriptions using "This …" instead of repeating the name (#29795)
Simplify card description using "This …" instead of repeating the name
2026-02-24 20:17:05 +01:00
Paul Bottein
26a7372c5e Don't show label for toggle all lights and align individual lights (#29794) 2026-02-24 17:28:53 +01:00
Paul Bottein
70d3409d62 Don't use navigation history when using tabs (#29791) 2026-02-24 18:03:48 +02:00
Wendelin
0711ecddab Handle selector edge case for [] (#29790) 2026-02-24 15:30:29 +00:00
Petar Petrov
bcfaa67eba Add power, water and gas current flow rate tile cards (#29788) 2026-02-24 16:01:32 +01:00
Matthias de Baat
1b60e6e04e Reorganize Zigbee settings page (#29671)
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2026-02-24 15:11:36 +01:00
Petar Petrov
a1a634f6dc Add footer card support to sections view (#29620)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
Co-authored-by: Wendelin <w@pe8.at>
2026-02-24 11:00:50 +00:00
Petar Petrov
55f48fbb56 Add tabs to energy config page (#29689) 2026-02-24 09:43:02 +00:00
Norbert Rittel
ca4d66b94c Change second tab to "Electricity" in Energy dashboard (#29787) 2026-02-24 10:13:22 +01:00
Aidan Timson
51fd2eedd9 Update gallery with latest adaptive dialog changes (#29672)
* Update gallery with latest adaptive dialog changes

* Update gallery/src/pages/components/ha-adaptive-dialog.ts

Co-authored-by: Bram Kragten <mail@bramkragten.nl>

* Format

---------

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2026-02-24 06:57:31 +01:00
ildar170975
434a7c2e93 "Numeric state" trigger editor: add a "filter_entity" context to "attribute" selector (#29778)
* add a "filter_entity" context to "attribute" selector

* remove unused variable
2026-02-24 07:55:49 +02:00
Petar Petrov
b849fecf0b Add flow rate picker to gas, water, and water device energy dialogs (#29693)
* Add flow rate picker to gas, water, and water device energy dialogs

Add optional flow rate (stat_rate) picker to gas source, water source,
and water device configuration dialogs, matching the pattern used for
power tracking in grid/solar/battery sources and energy devices.

- Add stat_rate to GasSourceTypeEnergyPreference and WaterSourceTypeEnergyPreference
- Collect gas/water stat_rate in getReferencedStatisticIdsPower()
- Add flow rate ha-statistic-picker filtered to volume_flow_rate device class
- Move entity help text to picker helper props for cleaner layout

* Apply suggestions from code review

Co-authored-by: Norbert Rittel <norbert@rittel.de>

---------

Co-authored-by: Norbert Rittel <norbert@rittel.de>
2026-02-24 06:55:35 +01:00
ildar170975
3a48e1996f hui-entities-card: fix "buttons-header-footer" margin-bottom (#29783)
* fix margin-bottom for hui-buttons-header-footer

* typo
2026-02-24 07:54:50 +02:00
ildar170975
8299386737 ha-entity-attribute-picker: add valueRenderer (#29780)
add valueRenderer
2026-02-24 06:52:10 +01:00
Raphael Hehl
5e58ff476f Re-initialize camera stream when backend finishes starting (#29752) 2026-02-23 16:59:53 +01:00
Paul Bottein
758d955053 Add configuration to built-in panels (#29572)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-02-23 16:15:54 +01:00
Kevin Stillhammer
1efd5d26f0 Show allow_negative in DurationSelector options (#29775) 2026-02-23 16:02:33 +01:00
Aidan Timson
36979f10cc Fix types for dialog hide events (#29777) 2026-02-23 15:54:36 +01:00
Aidan Timson
812c59fcb4 Add missing back path for protocol config dashboards (#29770) 2026-02-23 15:36:50 +01:00
karwosts
0c34165bcf Disallow moving a section to non-sections view (#29756) 2026-02-23 10:54:11 +00:00
Matthias de Baat
8c2bfbe9ce Reorganize Matter settings (#29708)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2026-02-23 10:31:38 +00:00
Aidan Timson
8f721d74e2 Fix swipe action bubbling up to stacked bottom drawer/sheet (#29768) 2026-02-23 10:26:10 +01:00
dependabot[bot]
63782e6ef3 Bump lodash from 4.17.21 to 4.17.23 (#29767)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-23 10:22:31 +01:00
Marie-Alice Blete
eaad2295a9 Fix ZHA config dashboard button animation targeting (#29744)
Co-authored-by: Marie-Alice Blete <malywut@users.noreply.github.com>
2026-02-23 10:18:32 +01:00
Norbert Rittel
e74eee3d34 Make all picker strings of Frontend conditions consistent (#29742) 2026-02-23 09:54:14 +01:00
karwosts
cc39010839 Fix group more-info names for not-in-registry entities (#29758) 2026-02-23 09:50:55 +01:00
Kevin Stillhammer
7f97425214 Allow to disable seconds in DurationSelector (#29760) 2026-02-23 09:30:04 +01:00
dependabot[bot]
8fac6e63de Bump actions/stale from 10.1.1 to 10.2.0 (#29765)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-23 09:22:29 +01:00
dependabot[bot]
2ac8fe2b21 Bump github/codeql-action from 4.32.3 to 4.32.4 (#29766)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-23 09:22:18 +01:00
Aidan Timson
45ca1b2cdc Fix esc closing all dialogs or sheets (close one after another) (#29732) 2026-02-23 09:13:28 +01:00
Matthias de Baat
0667f1e789 Reorganize Bluetooth settings (#29723)
* Reorganize Bluetooth settings

* Additional changes

* Updates adapter page

* Update statuses

* Fix button icon

* Update en.json

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

* Update bluetooth-adapter-info-page.ts

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

* Update text

* Show GATT message and make row clickable

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-02-23 06:59:09 +01:00
Norbert Rittel
db49678ccb Make descriptions of Frontend actions consistent with Core (#29740) 2026-02-20 18:03:19 +01:00
Norbert Rittel
2ca7e9f71e Make building block descriptions consistent with new conditions (#29739)
Make building block descriptions consistent with conditions
2026-02-20 18:02:41 +01:00
karwosts
8d883450a8 Add year period to stat graph card editor (#29741)
Add year period to stat graph card
2026-02-20 18:02:07 +01:00
dependabot[bot]
2c136e00f5 Bump tar from 7.5.7 to 7.5.8 (#29735)
* Bump tar from 7.5.7 to 7.5.8

Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.7 to 7.5.8.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.7...v7.5.8)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.8
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

* dedupe

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-02-20 07:05:32 +00:00
RoboMagus
6f82478598 Fix header tab height (#29736)
Fix header tabs to header height
2026-02-20 08:48:43 +02:00
Aidan Timson
1093bd890f Add missing helper to ha-select, remove unused attr (#29729) 2026-02-19 18:54:29 +01:00
Aidan Timson
456c638750 Use ha-scrollbar in config dashboard (#29724)
* Use ha-scrollbar in config dashboard

* Remove padding

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Add padding to bottom

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-19 18:52:32 +01:00
Aidan Timson
60ca50deb4 Add a drag handle visual indicator to bottom sheet (#29707)
* Add drag handle to bottom sheet

* Remove locks

* Fix rounded corners

* Restore original functionality, keep visual indicator

* Add padding to combo box

* Apply suggestion from @wendevlin

* Fix prettier

* Shorter height

Co-authored-by: Marcin Bauer <marcinbauer85@gmail.com>

* Half width

Co-authored-by: Marcin Bauer <marcinbauer85@gmail.com>

* Restore after rebase

* Reduce space for picker

---------

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
Co-authored-by: Wendelin <w@pe8.at>
Co-authored-by: Marcin Bauer <marcinbauer85@gmail.com>
2026-02-19 14:24:09 +01:00
Matthias de Baat
2064ab4141 Reorganize Z-Wave settings page (#29697)
* Reorganize ZWave settings

* Next iteration

* Made more consistent with Zigbee settings page

* Update text

* Updates on the provisioned devices page

* Add identifier when you have multiple networks

* Update to force remove button

* Update button text

* Update rebuild text

* Update remove foreign device button text
2026-02-19 13:45:35 +02:00
karwosts
d34c42e587 Refine supported actions in button heading badge (#29718) 2026-02-19 12:49:30 +02:00
Joakim Sørensen
5da7bf6fba Add repository handling for missing addons in HaConfigAppDashboard (#29722)
* Add repository handling for missing addons in HaConfigAppDashboard

* Implement feedback

* More adjustments

* minor adjustment
2026-02-19 10:36:34 +00:00
Norbert Rittel
f05ff58d27 Replace "consumption" with "usage" for battery and grid energy (#29719)
Replace "consumption" with "usage" for battery and grid power
2026-02-19 10:59:47 +02:00
Aidan Timson
7b0a381d93 Use ha-scrollbar with history panel, fix overflow position (#29715)
Use ha-scrollbar with history panel
2026-02-18 18:04:46 +01:00
Aidan Timson
8b38e6d170 Switch dialog device registry detail to adaptive dialog (#29713) 2026-02-18 18:04:05 +01:00
Aidan Timson
6daf0eb469 Use ha-scrollbar with media browser (#29714) 2026-02-18 18:03:26 +01:00
Wendelin
6f8f849af3 Prevent bottom-sheet from closing from child elements (#29716)
Fix handling of after hide event in ha-bottom-sheet component
2026-02-18 16:19:43 +00:00
Aidan Timson
cafe0f62c6 Trigger add todo item dialog via search param (#29690)
* Fix scrim closure

* Trigger add todo item dialog via add_item=true search param

* Check supports before opening prompt

* Use in willUpdate

* Add subtitle as context using name of list
2026-02-18 16:28:20 +01:00
Wendelin
721cf46ce5 Migrate ha-icon-button to webawesome (#29622)
* Remove mwc-icon-button dependency and update ha-icon-button to use ha-button component

* --mdc-icon-button-size to --ha-icon-button-size

* Refactor ha-icon-button styles to improve encapsulation and remove redundant CSS rules

* add href functionality

* Migrate a wrapped ha-icon-button to ha-icon-button

* Update slot reference for ha-icon-button in hui-dialog-save-config

* fix overflow trigger

* Review

* fix sub icon buttons

* Fix attribute binding for href and target in ha-icon-button-next

* Fix binding for href and target properties in ha-icon-button

* Update src/layouts/hass-tabs-subpage.ts

Co-authored-by: Bram Kragten <mail@bramkragten.nl>

* Update src/panels/lovelace/editor/hui-dialog-save-config.ts

Co-authored-by: Bram Kragten <mail@bramkragten.nl>

* Update src/panels/lovelace/editor/badge-editor/hui-dialog-edit-badge.ts

Co-authored-by: Bram Kragten <mail@bramkragten.nl>

* Update src/panels/config/labs/ha-config-labs.ts

Co-authored-by: Bram Kragten <mail@bramkragten.nl>

* Fix icon-button slot

* Update src/components/ha-icon-button.ts

Co-authored-by: Bram Kragten <mail@bramkragten.nl>

---------

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2026-02-18 16:18:28 +01:00
Marcin Bauer
4e087760ab Fix chip order in automation save dialog to match field order (#29710) 2026-02-18 15:18:07 +00:00
Aidan Timson
8fcfd4be84 Move scrolling for dashboards inside view container (#29444)
* Move scrolling for dashboards inside view container

* Use scrollbar styles on host

* Cleanup

* Inline

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>

---------

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-02-18 16:17:13 +01:00
Wendelin
b03680a8ab ha-automation-action-condition use generic-picker (#29702)
Refactor ha-automation-action-condition to use ha-generic-picker and improve condition rendering
2026-02-18 15:14:25 +00:00
ildar170975
7ab0622bec cloud-tts-pref: fix for language picker (#29678)
* fix styles to prevent oveflow

* use a new variable to define min-width

* pass a "minWidth" property into ha-language-picker

* use a "minWidth" property for ha-generic-picker

* Update ha-language-picker.ts

* pass empty minWidth

* do not set min-width if empty

* add a style for ha-language-picker

* remove a style for ha-language-picker

* add a style for ha-language-picker

* remove min-width

* add a style for ha-language-picker

* Update src/panels/profile/ha-pick-language-row.ts

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

* add a gap

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-02-18 14:05:41 +00:00
Bram Kragten
c5aad44768 Add support for vacuum segment mapping to areas (#29343)
* Add support for vacuum segment mapping to areas

* simplify, use list item

* Update ha-more-info-view-vacuum-segment-mapping.ts

* review

* review

* Update dialog-vacuum-segment-mapping.ts
2026-02-18 14:11:17 +01:00
Icecovery
20ee7e5dc7 Split antimeridian-crossing paths in ha-map (#29694)
* Add option to split antimeridian-crossing path to ha-map

and map card with related editor options

* Remove split antimeridian-crossing option in ha-map

making it the default behavior, as suggested by @karwosts. And remove the option from the map card

* Fix longitudeDifference is zero edge case
2026-02-18 12:08:03 +00:00
Bram Kragten
d98e373f64 Bumped version to 20260128.6 2026-02-04 15:41:09 +01:00
Paul Bottein
649516c9fa Change default icon for blank area if not icon configured (#29394) 2026-02-04 15:40:36 +01:00
Paul Bottein
bbc4fb96b2 Load domain translation when integration page load (#29391) 2026-02-04 15:40:35 +01:00
Paul Bottein
0ae639aeb0 Remove old lovelace overview from pickers (#29390) 2026-02-04 15:40:34 +01:00
karwosts
0e7e41065e Don't shrink ha-dropdown checkboxes (#29387) 2026-02-04 15:40:33 +01:00
Paul Bottein
685843f112 Add translations for new overview dialog (#29382) 2026-02-04 15:40:32 +01:00
Paul Bottein
5e1a99d94a Use area icon for area empty state (#29371) 2026-02-04 15:40:31 +01:00
Bram Kragten
d843349865 Bumped version to 20260128.5 2026-02-03 16:58:01 +01:00
Paul Bottein
ec23164aa9 Improve other devices page in home dashboard (#29370) 2026-02-03 16:57:45 +01:00
Paul Bottein
e74ef11101 Hide edit and delete actions for YAML dashboards in config (#29368)
YAML dashboards are defined in configuration files and cannot be
modified or deleted through the UI. This change ensures the edit
and delete actions are only shown for storage-mode dashboards.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 16:57:43 +01:00
Paul Bottein
a222f6a736 Add missing danger variant in dropdown item (#29359) 2026-02-03 16:57:40 +01:00
Petar Petrov
ef3dd16d45 Move dialog scrim to pseudo-element (#29357) 2026-02-03 16:57:39 +01:00
karwosts
5d4e1d205e Fix missing imports in devtools-statistics (#29355) 2026-02-03 16:57:38 +01:00
Darryn Capes-Davis
1ee5ebbe75 Fix CSS minification issue for ha-card (#29354) 2026-02-03 16:57:37 +01:00
Bram Kragten
59d705aa3d Bumped version to 20260128.4 2026-02-02 17:17:24 +01:00
Paul Bottein
332e108dae Fix "Reload resources" menu for YAML resource mode (#29346) 2026-02-02 17:17:17 +01:00
karwosts
3c15b29d0a Entity diagnostic - handle entity not in the registry (#29344) 2026-02-02 17:17:16 +01:00
Wendelin
130c708e23 Fix dropdown width in datatables (#29340) 2026-02-02 17:17:15 +01:00
Paul Bottein
588a14a8a7 Fix type error for missing hass.themes race condition in themes mixin (#29338) 2026-02-02 17:17:14 +01:00
Petar Petrov
a1ef6ad266 Remove redundant dialog backdrop color (#29337) 2026-02-02 17:17:13 +01:00
Aidan Timson
a6c1f87730 Ensure template renderer overflows on overflow (#29335) 2026-02-02 17:17:12 +01:00
Wendelin
49252a3808 Fix missing ha-md-menu in config/labels (#29334) 2026-02-02 17:17:11 +01:00
Aidan Timson
c7877fe38f Show hint only if keyboard shortcuts is enabled (#29332)
Enabled by default, must be explicity disabled
2026-02-02 17:17:10 +01:00
Wendelin
e355a61d8f Revert "Fix automation sidebar ui supported check" (#29331) 2026-02-02 17:17:08 +01:00
Linus Rath
f2e19e51ce Update untracked consumption threshold to 1W (#29310) 2026-02-02 17:17:07 +01:00
karwosts
fd9ab8f561 Use ha-form for condition template (#29301) 2026-02-02 17:17:06 +01:00
Kristel
faa1b3c98f bugfix: add eventlistener for exposed-entities-changed to Entities page (#29299) 2026-02-02 17:17:06 +01:00
Aidan Timson
acc4a84fc9 Fix scrolling for labs page (#29287) 2026-02-02 17:17:05 +01:00
karwosts
4d723dac37 Fix areas cannot be deleted (#29285) 2026-02-02 17:17:03 +01:00
Aidan Timson
f1d4d0ef98 Fix type error for missing hass.config race condition in themes mixin (#29280) 2026-02-02 17:17:02 +01:00
Paul Bottein
88180a2708 Fix demo because of new default panel (#29279) 2026-02-02 17:17:01 +01:00
Aidan Timson
258d87e3d5 Add missing settings nav items for quick search (#29278)
* Add missing repairs quick search item

* Add voice assistants
2026-02-02 17:17:00 +01:00
Wendelin
55f22ba61a Implement fallback for dialog close event in Quick Search (#29260) 2026-02-02 17:16:59 +01:00
Aidan Timson
812f3ca8b9 Change default shortcut tip in Quick Search to mod+k, add tip to settings (#29253) 2026-02-02 17:16:58 +01:00
Marcin Bauer
7f880d11a0 Keep focus on search field when clicking filter chips (#29249)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 17:16:57 +01:00
Bram Kragten
6b2452c538 Update compress.js 2026-02-02 17:15:44 +01:00
Paul Bottein
c2cbf8bd21 Bumped version to 20260128.3 2026-01-30 10:31:26 +01:00
Wendelin
224bcece9c Fix multi select in quick search (#29272)
Add item selection state management to QuickBar component
2026-01-30 10:30:33 +01:00
Wendelin
dc84b7698f Fix --wa-color-text-normal (#29271)
Update normal text color variable in wa.globals.ts
2026-01-30 10:30:32 +01:00
Wendelin
bc22e6a9bd Fix device download diagnostic via overflow (#29269)
fix diagnostic download link handling to simplify URL signing
2026-01-30 10:30:31 +01:00
Simon Lamon
d44874783a Remove duplicated text (#29265) 2026-01-30 10:30:30 +01:00
Paul Bottein
8d1bb5c867 Fix default lovelace yaml loading (#29240) 2026-01-30 10:30:29 +01:00
Bram Kragten
da1b528eee Bumped version to 20260128.2 2026-01-29 18:04:42 +01:00
Paul Bottein
756138408a Remove default title for new dashboards (#29259) 2026-01-29 18:04:09 +01:00
Paul Bottein
3c8f112565 Prevent action in tile container (#29257) 2026-01-29 18:04:08 +01:00
Paul Bottein
2521f3dde4 Fix actions in dashboard overflow menu (#29256) 2026-01-29 18:04:07 +01:00
TheJulianJES
56390aa01a Fix Matter dashboard using disabled and ignored config entries (#29254) 2026-01-29 17:52:32 +01:00
Paul Bottein
9aac5b19da Stop click propagation when clicking item in icon overflow (#29252) 2026-01-29 17:52:31 +01:00
Wendelin
24afc3dc88 Prevent quick search to close from hot keys (#29251) 2026-01-29 17:52:30 +01:00
Paul Bottein
873c7b2947 Remove unused theme option in distribution card (#29250) 2026-01-29 17:52:29 +01:00
Aidan Timson
648db4276b Add protocols to quick search (#29248)
Add protocols to quick search, extract logic and translations
2026-01-29 17:52:28 +01:00
Aidan Timson
f86c3e7856 Remove unused "app" item from quick search (#29244) 2026-01-29 17:52:27 +01:00
Aidan Timson
1d0251cc28 Fixes for picker combo box scrolling and selection (#29242) 2026-01-29 17:52:26 +01:00
Wendelin
518cf87847 Fix quick search apps (#29238) 2026-01-29 17:52:25 +01:00
ildar170975
81a9216c44 computeGroupEntitiesState(): fix condition (#29234)
* fix condition

* fix condition

* prettier
2026-01-29 17:52:24 +01:00
Paul Bottein
f0e10e0058 Fix default yaml lovelace panel loading (#29230) 2026-01-29 17:52:23 +01:00
Paul Bottein
5df8ea4f07 Add welcome banner for new overview dashboard (#29223) 2026-01-29 17:52:22 +01:00
Aidan Timson
73f081f5cc Add meta+click/enter support to quick search (#29220)
* Allow meta+click event from combobox

* Handle new tab events for navigations

* Add mod+enter support for new tab

* Helper function
2026-01-29 17:52:21 +01:00
Petar Petrov
f0d1db1da6 Add non standard power sensor support (#28845)
* Add non standard power sensor support

* remove useless code

* GridPowerSourceInput type for grid power source saving
2026-01-29 17:52:20 +01:00
Bram Kragten
c658eb414b Bumped version to 20260128.1 2026-01-28 17:52:10 +01:00
Bram Kragten
bac493b72b dont include brotli compression 2026-01-28 17:50:19 +01:00
259 changed files with 11132 additions and 4656 deletions

View File

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

View File

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

2
.nvmrc
View File

@@ -1 +1 @@
24.13.1
24.14.0

View File

@@ -21,8 +21,8 @@ type DialogType =
| "basic"
| "basic-subtitle-below"
| "basic-subtitle-above"
| "allow-mode-change"
| "form"
| "form-block-mode"
| "actions"
| "large"
| "small";
@@ -69,8 +69,8 @@ export class DemoHaAdaptiveDialog extends LitElement {
<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("allow-mode-change")}
>Adaptive dialog with allow mode change</ha-button
>
<ha-button @click=${this._handleOpenDialog("actions")}
>Adaptive dialog with actions</ha-button
@@ -164,27 +164,15 @@ export class DemoHaAdaptiveDialog extends LitElement {
<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
.allowModeChange=${this._openDialog === "allow-mode-change"}
header-title="Adaptive dialog with allow mode change"
header-subtitle="Resize the window while this dialog is open"
@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>
<div>
This dialog can switch between dialog mode and bottom sheet mode
while open.
</div>
</ha-adaptive-dialog>
<ha-adaptive-dialog
@@ -225,10 +213,9 @@ export class DemoHaAdaptiveDialog extends LitElement {
</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.
By default, the mode is determined at mount time and then stays fixed
while the dialog is open. To allow switching modes while the viewport
changes, use the <code>allow-mode-change</code> attribute.
</p>
<h3>Width</h3>
@@ -399,10 +386,10 @@ export class DemoHaAdaptiveDialog extends LitElement {
</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.
Use the <code>allow-mode-change</code> attribute when you want the
dialog to switch between modes as the viewport changes after opening.
For forms, you can keep the default behavior to avoid resetting fields
on resize.
</p>
<h3>Example usage</h3>
@@ -426,23 +413,6 @@ export class DemoHaAdaptiveDialog extends LitElement {
&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>
@@ -520,12 +490,10 @@ export class DemoHaAdaptiveDialog extends LitElement {
<td></td>
</tr>
<tr>
<td><code>block-mode-change</code></td>
<td><code>allow-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.
When set, the dialog can switch between modes as the viewport
size changes while it is open.
</td>
<td><code>false</code></td>
<td><code>false</code>, <code>true</code></td>

View File

@@ -222,6 +222,9 @@ class HaLandingPage extends LandingPageBaseElement {
flex-direction: column;
gap: var(--ha-space-4);
}
ha-language-picker {
min-width: 200px;
}
ha-alert p {
text-align: unset;
}

View File

@@ -34,10 +34,10 @@
"@codemirror/legacy-modes": "6.5.2",
"@codemirror/search": "6.6.0",
"@codemirror/state": "6.5.4",
"@codemirror/view": "6.39.12",
"@codemirror/view": "6.39.15",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.2.1",
"@formatjs/intl-datetimeformat": "7.2.2",
"@formatjs/intl-displaynames": "7.2.1",
"@formatjs/intl-durationformat": "0.10.1",
"@formatjs/intl-getcanonicallocales": "3.2.1",
@@ -52,7 +52,7 @@
"@fullcalendar/list": "6.1.20",
"@fullcalendar/luxon3": "6.1.20",
"@fullcalendar/timegrid": "6.1.20",
"@home-assistant/webawesome": "3.2.1-ha.2",
"@home-assistant/webawesome": "3.2.1-ha.3",
"@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.1.0",
"@lit-labs/observers": "2.1.0",
@@ -68,7 +68,6 @@
"@material/mwc-fab": "0.27.0",
"@material/mwc-floating-label": "0.27.0",
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
"@material/mwc-icon-button": "0.27.0",
"@material/mwc-linear-progress": "0.27.0",
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"@material/mwc-radio": "0.27.0",
@@ -84,7 +83,7 @@
"@mdi/js": "7.4.47",
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.18",
"@swc/helpers": "0.5.19",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "3.9.1",
"@tsparticles/preset-links": "3.2.0",
@@ -119,7 +118,7 @@
"lit": "3.3.2",
"lit-html": "3.3.2",
"luxon": "3.7.2",
"marked": "17.0.1",
"marked": "17.0.3",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -149,14 +148,14 @@
"@babel/helper-define-polyfill-provider": "0.6.6",
"@babel/plugin-transform-runtime": "7.29.0",
"@babel/preset-env": "7.29.0",
"@bundle-stats/plugin-webpack-filter": "4.21.9",
"@html-eslint/eslint-plugin": "0.55.0",
"@bundle-stats/plugin-webpack-filter": "4.21.10",
"@html-eslint/eslint-plugin": "0.56.0",
"@lokalise/node-api": "15.6.1",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.2",
"@rspack/core": "1.7.5",
"@rspack/core": "1.7.6",
"@rspack/dev-server": "1.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.25",
@@ -173,7 +172,7 @@
"@types/mocha": "10.0.10",
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "6.1.13",
"@types/tar": "7.0.87",
"@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.0.18",
@@ -181,25 +180,25 @@
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
"del": "8.0.1",
"eslint": "9.39.2",
"eslint": "9.39.3",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.10",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-lit": "2.1.1",
"eslint-plugin-lit": "2.2.1",
"eslint-plugin-lit-a11y": "5.1.1",
"eslint-plugin-unused-imports": "4.3.0",
"eslint-plugin-wc": "3.0.2",
"eslint-plugin-unused-imports": "4.4.1",
"eslint-plugin-wc": "3.1.0",
"fancy-log": "2.0.0",
"fs-extra": "11.3.3",
"glob": "13.0.1",
"glob": "13.0.6",
"gulp": "5.0.1",
"gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "28.0.0",
"jsdom": "28.1.0",
"jszip": "3.10.1",
"lint-staged": "16.2.7",
"lit-analyzer": "2.0.3",
@@ -211,12 +210,12 @@
"rspack-manifest-plugin": "5.2.1",
"serve": "14.2.5",
"sinon": "21.0.1",
"tar": "7.5.7",
"tar": "7.5.9",
"terser-webpack-plugin": "5.3.16",
"ts-lit-plugin": "2.0.2",
"typescript": "5.9.3",
"typescript-eslint": "8.54.0",
"vite-tsconfig-paths": "6.0.5",
"typescript-eslint": "8.56.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.0.18",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
@@ -236,6 +235,6 @@
},
"packageManager": "yarn@4.12.0",
"volta": {
"node": "24.13.1"
"node": "24.14.0"
}
}

View File

@@ -1,10 +1,7 @@
/* eslint-disable lit/prefer-static-styles */
import type { TemplateResult } from "lit";
import { html } from "lit";
import { customElement } from "lit/decorators";
import { HaFormString } from "../components/ha-form/ha-form-string";
import "../components/ha-icon-button";
import "./ha-auth-textfield";
import "../components/ha-input";
@customElement("ha-auth-form-string")
export class HaAuthFormString extends HaFormString {
@@ -12,59 +9,9 @@ export class HaAuthFormString extends HaFormString {
return this;
}
protected render(): TemplateResult {
return html`
<style>
ha-auth-form-string {
display: block;
position: relative;
}
ha-auth-form-string[own-margin] {
margin-bottom: 5px;
}
ha-auth-form-string ha-auth-textfield {
display: block !important;
}
ha-auth-form-string ha-icon-button {
position: absolute;
top: 8px;
right: 8px;
inset-inline-start: initial;
inset-inline-end: 8px;
--mdc-icon-button-size: 40px;
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
direction: var(--direction);
}
</style>
<ha-auth-textfield
.type=${!this.isPassword
? this.stringType
: this.unmaskedPassword
? "text"
: "password"}
.label=${this.label}
.value=${this.data || ""}
.helper=${this.helper}
helperPersistent
.disabled=${this.disabled}
.required=${this.schema.required}
.autoValidate=${this.schema.required}
.name=${this.schema.name}
.autocomplete=${this.schema.autocomplete}
?autofocus=${this.schema.autofocus}
.suffix=${this.isPassword
? // reserve some space for the icon.
html`<div style="width: 24px"></div>`
: this.schema.description?.suffix}
.validationMessage=${this.schema.required
? this.localize?.("ui.panel.page-authorize.form.error_required")
: undefined}
@input=${this._valueChanged}
@change=${this._valueChanged}
></ha-auth-textfield>
${this.renderIcon()}
`;
public connectedCallback(): void {
super.connectedCallback();
this.style.position = "relative";
}
}

View File

@@ -1,264 +0,0 @@
/* eslint-disable lit/value-after-constraints */
/* eslint-disable lit/prefer-static-styles */
import { floatingLabel } from "@material/mwc-floating-label/mwc-floating-label-directive";
import type { TemplateResult } from "lit";
import { html } from "lit";
import { customElement } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { live } from "lit/directives/live";
import { HaTextField } from "../components/ha-textfield";
@customElement("ha-auth-textfield")
export class HaAuthTextField extends HaTextField {
protected renderLabel(): TemplateResult | string {
return !this.label
? ""
: html`
<span
.floatingLabelFoundation=${floatingLabel(
this.label
) as unknown as any}
.id=${this.name}
>${this.label}</span
>
`;
}
protected renderInput(shouldRenderHelperText: boolean): TemplateResult {
const minOrUndef = this.minLength === -1 ? undefined : this.minLength;
const maxOrUndef = this.maxLength === -1 ? undefined : this.maxLength;
const autocapitalizeOrUndef = this.autocapitalize
? (this.autocapitalize as
| "off"
| "none"
| "on"
| "sentences"
| "words"
| "characters")
: undefined;
const showValidationMessage = this.validationMessage && !this.isUiValid;
const ariaLabelledbyOrUndef = this.label ? this.name : undefined;
const ariaControlsOrUndef = shouldRenderHelperText
? "helper-text"
: undefined;
const ariaDescribedbyOrUndef =
this.focused || this.helperPersistent || showValidationMessage
? "helper-text"
: undefined;
// TODO: live() directive needs casting for lit-analyzer
// https://github.com/runem/lit-analyzer/pull/91/files
// TODO: lit-analyzer labels min/max as (number|string) instead of string
return html`<input
aria-labelledby=${ifDefined(ariaLabelledbyOrUndef)}
aria-controls=${ifDefined(ariaControlsOrUndef)}
aria-describedby=${ifDefined(ariaDescribedbyOrUndef)}
class="mdc-text-field__input"
type=${this.type}
.value=${live(this.value) as unknown as string}
?disabled=${this.disabled}
placeholder=${this.placeholder}
?required=${this.required}
?readonly=${this.readOnly}
minlength=${ifDefined(minOrUndef)}
maxlength=${ifDefined(maxOrUndef)}
pattern=${ifDefined(this.pattern ? this.pattern : undefined)}
min=${ifDefined(this.min === "" ? undefined : (this.min as number))}
max=${ifDefined(this.max === "" ? undefined : (this.max as number))}
step=${ifDefined(this.step === null ? undefined : (this.step as number))}
size=${ifDefined(this.size === null ? undefined : this.size)}
name=${ifDefined(this.name === "" ? undefined : this.name)}
inputmode=${ifDefined(this.inputMode)}
autocapitalize=${ifDefined(autocapitalizeOrUndef)}
?autofocus=${this.autofocus}
@input=${this.handleInputChange}
@focus=${this.onInputFocus}
@blur=${this.onInputBlur}
/>`;
}
public render() {
return html`
<style>
ha-auth-textfield {
display: inline-flex;
flex-direction: column;
outline: none;
}
ha-auth-textfield:not([disabled]):hover
:not(.mdc-text-field--invalid):not(.mdc-text-field--focused)
mwc-notched-outline {
--mdc-notched-outline-border-color: var(
--mdc-text-field-outlined-hover-border-color,
rgba(0, 0, 0, 0.87)
);
}
ha-auth-textfield:not([disabled])
.mdc-text-field:not(.mdc-text-field--outlined) {
background-color: var(--mdc-text-field-fill-color, whitesmoke);
}
ha-auth-textfield:not([disabled])
.mdc-text-field.mdc-text-field--invalid
mwc-notched-outline {
--mdc-notched-outline-border-color: var(
--mdc-text-field-error-color,
var(--mdc-theme-error, #b00020)
);
}
ha-auth-textfield:not([disabled])
.mdc-text-field.mdc-text-field--invalid
+ .mdc-text-field-helper-line
.mdc-text-field-character-counter,
ha-auth-textfield:not([disabled])
.mdc-text-field.mdc-text-field--invalid
.mdc-text-field__icon {
color: var(
--mdc-text-field-error-color,
var(--mdc-theme-error, #b00020)
);
}
ha-auth-textfield:not([disabled])
.mdc-text-field:not(.mdc-text-field--invalid):not(
.mdc-text-field--focused
)
.mdc-floating-label,
ha-auth-textfield:not([disabled])
.mdc-text-field:not(.mdc-text-field--invalid):not(
.mdc-text-field--focused
)
.mdc-floating-label::after {
color: var(--mdc-text-field-label-ink-color, rgba(0, 0, 0, 0.6));
}
ha-auth-textfield:not([disabled])
.mdc-text-field.mdc-text-field--focused
mwc-notched-outline {
--mdc-notched-outline-stroke-width: 2px;
}
ha-auth-textfield:not([disabled])
.mdc-text-field.mdc-text-field--focused:not(.mdc-text-field--invalid)
mwc-notched-outline {
--mdc-notched-outline-border-color: var(
--mdc-text-field-focused-label-color,
var(--mdc-theme-primary, rgba(98, 0, 238, 0.87))
);
}
ha-auth-textfield:not([disabled])
.mdc-text-field.mdc-text-field--focused:not(.mdc-text-field--invalid)
.mdc-floating-label {
color: #6200ee;
color: var(--mdc-theme-primary, #6200ee);
}
ha-auth-textfield:not([disabled])
.mdc-text-field
.mdc-text-field__input {
color: var(--mdc-text-field-ink-color, rgba(0, 0, 0, 0.87));
}
ha-auth-textfield:not([disabled])
.mdc-text-field
.mdc-text-field__input::placeholder {
color: var(--mdc-text-field-label-ink-color, rgba(0, 0, 0, 0.6));
}
ha-auth-textfield:not([disabled])
.mdc-text-field-helper-line
.mdc-text-field-helper-text:not(
.mdc-text-field-helper-text--validation-msg
),
ha-auth-textfield:not([disabled])
.mdc-text-field-helper-line:not(.mdc-text-field--invalid)
.mdc-text-field-character-counter {
color: var(--mdc-text-field-label-ink-color, rgba(0, 0, 0, 0.6));
}
ha-auth-textfield[disabled]
.mdc-text-field:not(.mdc-text-field--outlined) {
background-color: var(--mdc-text-field-disabled-fill-color, #fafafa);
}
ha-auth-textfield[disabled]
.mdc-text-field.mdc-text-field--outlined
mwc-notched-outline {
--mdc-notched-outline-border-color: var(
--mdc-text-field-outlined-disabled-border-color,
rgba(0, 0, 0, 0.06)
);
}
ha-auth-textfield[disabled]
.mdc-text-field:not(.mdc-text-field--invalid):not(
.mdc-text-field--focused
)
.mdc-floating-label,
ha-auth-textfield[disabled]
.mdc-text-field:not(.mdc-text-field--invalid):not(
.mdc-text-field--focused
)
.mdc-floating-label::after {
color: var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.38));
}
ha-auth-textfield[disabled] .mdc-text-field .mdc-text-field__input,
ha-auth-textfield[disabled]
.mdc-text-field
.mdc-text-field__input::placeholder {
color: var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.38));
}
ha-auth-textfield[disabled]
.mdc-text-field-helper-line
.mdc-text-field-helper-text,
ha-auth-textfield[disabled]
.mdc-text-field-helper-line
.mdc-text-field-character-counter {
color: var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.38));
}
ha-auth-textfield:not([disabled])
.mdc-text-field.mdc-text-field--focused:not(.mdc-text-field--invalid)
.mdc-floating-label {
color: var(--mdc-theme-primary, #6200ee);
}
ha-auth-textfield[no-spinner] input::-webkit-outer-spin-button,
ha-auth-textfield[no-spinner] input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
ha-auth-textfield[no-spinner] input[type="number"] {
-moz-appearance: textfield;
}
</style>
${super.render()}
`;
}
protected createRenderRoot() {
// add parent style to light dom
const style = document.createElement("style");
style.textContent = HaTextField.elementStyles as unknown as string;
this.append(style);
return this;
}
public firstUpdated() {
super.firstUpdated();
if (this.autofocus) {
this.focus();
}
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-auth-textfield": HaAuthTextField;
}
}

View File

@@ -210,3 +210,39 @@ const formatDateWeekdayShortMem = memoizeOne(
timeZone: resolveTimeZone(locale.time_zone, serverTimeZone),
})
);
// Mon, Aug 10
export const formatDateWeekdayVeryShortDate = (
dateObj: Date,
locale: FrontendLocaleData,
config: HassConfig
) =>
formatDateWeekdayVeryShortDateMem(locale, config.time_zone).format(dateObj);
const formatDateWeekdayVeryShortDateMem = memoizeOne(
(locale: FrontendLocaleData, serverTimeZone: string) =>
new Intl.DateTimeFormat(locale.language, {
weekday: "short",
month: "short",
day: "numeric",
timeZone: resolveTimeZone(locale.time_zone, serverTimeZone),
})
);
// Mon, Aug 10, 2021
export const formatDateWeekdayShortDate = (
dateObj: Date,
locale: FrontendLocaleData,
config: HassConfig
) => formatDateWeekdayShortDateMem(locale, config.time_zone).format(dateObj);
const formatDateWeekdayShortDateMem = memoizeOne(
(locale: FrontendLocaleData, serverTimeZone: string) =>
new Intl.DateTimeFormat(locale.language, {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
timeZone: resolveTimeZone(locale.time_zone, serverTimeZone),
})
);

View File

@@ -18,9 +18,11 @@ import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { ensureArray } from "../../common/array/ensure-array";
import { getAllGraphColors } from "../../common/color/colors";
import { fireEvent } from "../../common/dom/fire_event";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { listenMediaQuery } from "../../common/dom/media_query";
import { afterNextRender } from "../../common/util/render-status";
import { filterXSS } from "../../common/util/xss";
import { themesContext } from "../../data/context";
import type { Themes } from "../../data/ws-themes";
import type { ECOption } from "../../resources/echarts/echarts";
@@ -28,8 +30,6 @@ import type { HomeAssistant } from "../../types";
import { isMac } from "../../util/is_mac";
import "../chips/ha-assist-chip";
import "../ha-icon-button";
import { afterNextRender } from "../../common/util/render-status";
import { filterXSS } from "../../common/util/xss";
import { formatTimeLabel } from "./axis-label";
import { downSampleLineData } from "./down-sample";
@@ -1115,13 +1115,13 @@ export class HaChartBase extends LitElement {
.chart-controls ::slotted(ha-icon-button) {
background: var(--card-background-color);
border-radius: var(--ha-border-radius-sm);
--mdc-icon-button-size: 32px;
--ha-icon-button-size: 32px;
color: var(--primary-color);
border: 1px solid var(--divider-color);
}
.chart-controls.small ha-icon-button,
.chart-controls.small ::slotted(ha-icon-button) {
--mdc-icon-button-size: 22px;
--ha-icon-button-size: 22px;
--mdc-icon-size: 16px;
}
.chart-controls ha-icon-button.inactive,

View File

@@ -32,11 +32,13 @@ export class DialogDataTableSettings extends LitElement {
@state() private _hiddenColumns?: string[];
private _lastFixedKeys: string[] = [];
@state() private _open = false;
public showDialog(params: DataTableSettingsDialogParams) {
this._params = params;
this._columnOrder = params.columnOrder;
this._columnOrder = this._preserveLastFixed(params.columnOrder);
this._hiddenColumns = params.hiddenColumns;
this._open = true;
}
@@ -50,6 +52,29 @@ export class DialogDataTableSettings extends LitElement {
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private _lastFixedCount(): number {
const lastFixedKeys = Object.keys(this._params!.columns).filter(
(col) => this._params!.columns[col].lastFixed
);
if (lastFixedKeys.length) {
this._lastFixedKeys = lastFixedKeys;
}
return lastFixedKeys.length;
}
private _preserveLastFixed(columnOrder) {
let strippedColumnOrder;
const lastFixedCount = this._lastFixedCount();
if (lastFixedCount && columnOrder) {
strippedColumnOrder = [...columnOrder];
strippedColumnOrder.splice(
columnOrder.length - lastFixedCount,
lastFixedCount
);
}
return strippedColumnOrder;
}
private _sortedColumns = memoizeOne(
(
columns: DataTableColumnContainer,
@@ -57,7 +82,7 @@ export class DialogDataTableSettings extends LitElement {
hiddenColumns: string[] | undefined
) =>
Object.keys(columns)
.filter((col) => !columns[col].hidden)
.filter((col) => !columns[col].hidden && !columns[col].lastFixed)
.sort((a, b) => {
const orderA = columnOrder?.indexOf(a) ?? -1;
const orderB = columnOrder?.indexOf(b) ?? -1;
@@ -195,7 +220,8 @@ export class DialogDataTableSettings extends LitElement {
this._columnOrder = columnOrder;
this._params!.onUpdate(this._columnOrder, this._hiddenColumns);
const reportedOrder = columnOrder.concat(this._lastFixedKeys);
this._params!.onUpdate(reportedOrder, this._hiddenColumns);
}
private _toggle(ev) {
@@ -276,7 +302,8 @@ export class DialogDataTableSettings extends LitElement {
this._hiddenColumns = hidden;
this._params!.onUpdate(this._columnOrder, this._hiddenColumns);
const reportedOrder = this._columnOrder.concat(this._lastFixedKeys);
this._params!.onUpdate(reportedOrder, this._hiddenColumns);
}
private _reset() {

View File

@@ -86,6 +86,7 @@ export interface DataTableColumnData<T = any> extends DataTableSortColumnData {
flex?: number;
forceLTR?: boolean;
hidden?: boolean;
lastFixed?: boolean;
}
export type ClonedDataTableColumnData = Omit<DataTableColumnData, "title"> & {
@@ -135,9 +136,6 @@ export class HaDataTable extends LitElement {
@property({ attribute: false }) public searchLabel?: string;
@property({ type: Boolean, attribute: "no-label-float" })
public noLabelFloat? = false;
@property({ type: String }) public filter = "";
@property({ attribute: false }) public groupColumn?: string;
@@ -359,6 +357,11 @@ export class HaDataTable extends LitElement {
.sort((a, b) => {
const orderA = columnOrder!.indexOf(a);
const orderB = columnOrder!.indexOf(b);
const fixedA = Boolean(columns[a].lastFixed);
const fixedB = Boolean(columns[b].lastFixed);
if (fixedA !== fixedB) {
return fixedA ? 1 : -1;
}
if (orderA !== orderB) {
if (orderA === -1) {
return 1;
@@ -394,7 +397,6 @@ export class HaDataTable extends LitElement {
.hass=${this.hass}
@value-changed=${this._handleSearchChange}
.label=${this.searchLabel}
.noLabelFloat=${this.noLabelFloat}
></search-input>
</div>
`
@@ -428,9 +430,9 @@ export class HaDataTable extends LitElement {
<ha-checkbox
class="mdc-data-table__row-checkbox"
@change=${this._handleHeaderRowCheckboxClick}
.indeterminate=${this._checkedRows.length &&
.indeterminate=${!!this._checkedRows.length &&
this._checkedRows.length !== this._checkableRowsCount}
.checked=${this._checkedRows.length &&
.checked=${!!this._checkedRows.length &&
this._checkedRows.length === this._checkableRowsCount}
>
</ha-checkbox>

View File

@@ -6,6 +6,7 @@ import { fireEvent } from "../../common/dom/fire_event";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-generic-picker";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
import type { PickerValueRenderer } from "../ha-picker-field";
@customElement("ha-entity-attribute-picker")
class HaEntityAttributePicker extends LitElement {
@@ -94,12 +95,19 @@ class HaEntityAttributePicker extends LitElement {
.helper=${this.helper}
.allowCustomValue=${this.allowCustomValue}
.getItems=${this._getItems}
.valueRenderer=${this._valueRenderer}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
`;
}
private _valueRenderer: PickerValueRenderer = (value: string) => {
const items = this._getItems();
const item = items.find((option) => option.id === value);
return html`<span slot="headline">${item?.primary ?? value}</span>`;
};
private _valueChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const newValue = ev.detail.value;

View File

@@ -164,7 +164,7 @@ export class HaEntityToggle extends LitElement {
min-width: 38px;
}
ha-icon-button {
--mdc-icon-button-size: 40px;
--ha-icon-button-size: 40px;
color: var(--ha-icon-button-inactive-color, var(--primary-text-color));
transition: color 0.5s;
}

View File

@@ -15,6 +15,7 @@ import { iconColorCSS } from "../../common/style/icon_color_css";
import { cameraUrlWithWidthHeight } from "../../data/camera";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import type { HomeAssistant } from "../../types";
import { addBrandsAuth } from "../../util/brands-url";
import "../ha-state-icon";
@customElement("state-badge")
@@ -137,6 +138,7 @@ export class StateBadge extends LitElement {
let imageUrl =
stateObj.attributes.entity_picture_local ||
stateObj.attributes.entity_picture;
imageUrl = addBrandsAuth(imageUrl);
if (this.hass) {
imageUrl = this.hass.hassUrl(imageUrl);
}

View File

@@ -220,6 +220,7 @@ export class HaAdaptiveDialog extends LitElement {
return [
css`
ha-bottom-sheet {
--ha-bottom-sheet-border-radius: var(--ha-border-radius-2xl);
--ha-bottom-sheet-surface-background: var(
--ha-dialog-surface-background,
var(--card-background-color, var(--ha-color-surface-default))

View File

@@ -135,7 +135,7 @@ class HaAlert extends LitElement {
}
.action ha-icon-button {
--mdc-theme-primary: var(--primary-text-color);
--mdc-icon-button-size: 36px;
--ha-icon-button-size: 36px;
}
.issue-type.info > .icon {
color: var(--info-color);

View File

@@ -672,11 +672,11 @@ export class HaAssistChat extends LitElement {
--markdown-code-background-color: var(--primary-background-color);
--markdown-code-text-color: var(--primary-text-color);
--markdown-list-indent: 1.15em;
&:not(:has(ha-markdown-element)) {
min-height: 1lh;
min-width: 1lh;
flex-shrink: 0;
}
}
ha-markdown:not(:has(ha-markdown-element)) {
min-height: 1lh;
min-width: 1lh;
flex-shrink: 0;
}
.bouncer {
width: 48px;

View File

@@ -4,10 +4,10 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event";
import type { HaSelectSelectEvent } from "./ha-select";
import "./ha-icon-button";
import "./ha-input-helper-text";
import "./ha-select";
import type { HaSelectSelectEvent } from "./ha-select";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
@@ -368,7 +368,7 @@ export class HaBaseTimeInput extends LitElement {
}
ha-icon-button {
position: relative;
--mdc-icon-button-size: 36px;
--ha-icon-button-size: 36px;
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
direction: var(--direction);

View File

@@ -102,37 +102,47 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
this._sliderInteractionActive = false;
};
private _handleAfterHide = () => {
private _handleAfterHide = (ev: CustomEvent<{ source: Element }>) => {
if (this._sliderInteractionActive) {
this._drawerOpen = true;
this.open = true;
return;
}
this.open = false;
this._drawerOpen = false;
fireEvent(this, "closed");
if (ev.eventPhase === Event.AT_TARGET) {
this.open = false;
this._drawerOpen = false;
fireEvent(this, "closed");
}
};
private _handleHide = (ev: CustomEvent<{ source: Element }>) => {
// Ignore bubbled wa-hide events from nested drawers (e.g., picker bottom sheet)
if (ev.eventPhase !== Event.AT_TARGET) {
return;
}
const sourceIsDrawer = ev.detail.source === (ev.target as WaDrawer).drawer;
if (this._sliderInteractionActive) {
ev.preventDefault();
this._drawerOpen = true;
this.open = true;
this._escapePressed = false;
return;
}
if (
this.preventScrimClose &&
this._escapePressed &&
ev.detail.source === (ev.target as WaDrawer).drawer
) {
if (this.preventScrimClose && this._escapePressed && sourceIsDrawer) {
ev.preventDefault();
}
this._escapePressed = false;
};
private _handleKeyDown = (ev: KeyboardEvent) => {
if (ev.key === "Escape") {
this._escapePressed = true;
ev.stopPropagation();
(ev.currentTarget as WaDrawer).open = false;
}
};
@@ -194,6 +204,9 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
without-header
@touchstart=${this._handleTouchStart}
>
<div class="handle-wrapper" aria-hidden="true">
<div class="handle"></div>
</div>
<slot name="header"></slot>
<div class="content-wrapper">
<div id="body" class="body ha-scrollbar">
@@ -235,6 +248,9 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
}
}
// Stop propagation so parent bottom sheets don't also start tracking
// this gesture (same pattern as _handleKeyDown for Escape)
ev.stopPropagation();
this._startResizing(ev.touches[0].clientY);
};
@@ -369,6 +385,7 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
wa-drawer::part(body) {
max-width: var(--ha-bottom-sheet-max-width);
width: 100%;
position: relative;
border-top-left-radius: var(
--ha-bottom-sheet-border-radius,
var(--ha-dialog-border-radius, var(--ha-border-radius-2xl))
@@ -391,6 +408,35 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
display: flex;
flex-direction: column;
}
:host([prevent-scrim-close]) .handle-wrapper {
display: none;
}
.handle-wrapper {
position: absolute;
top: 0;
inset-inline-start: 0;
width: 100%;
padding-bottom: 2px;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
z-index: 1;
}
.handle-wrapper .handle {
height: 16px;
width: 200px;
display: flex;
justify-content: center;
align-items: center;
}
.handle-wrapper .handle::after {
content: "";
border-radius: var(--ha-border-radius-md);
height: 4px;
background: var(--ha-bottom-sheet-handle-color, var(--divider-color));
width: 40px;
}
.content-wrapper {
position: relative;
flex: 1;

View File

@@ -42,7 +42,7 @@ export class HaButton extends Button {
Button.styles,
css`
:host {
--wa-form-control-padding-inline: 16px;
--wa-form-control-padding-inline: var(--ha-space-4);
--wa-font-weight-action: var(--ha-font-weight-medium);
--wa-form-control-border-radius: var(
--ha-button-border-radius,
@@ -68,7 +68,7 @@ export class HaButton extends Button {
var(--button-height, 32px)
);
font-size: var(--wa-font-size-s, var(--ha-font-size-m));
--wa-form-control-padding-inline: 12px;
--wa-form-control-padding-inline: var(--ha-space-3);
}
:host([variant="brand"]) {
@@ -84,6 +84,9 @@ export class HaButton extends Button {
--button-color-fill-loud-hover: var(
--ha-color-fill-primary-loud-hover
);
--button-color-fill-quiet-active: var(
--ha-color-fill-primary-quiet-active
);
}
:host([variant="neutral"]) {
@@ -99,6 +102,9 @@ export class HaButton extends Button {
--button-color-fill-loud-hover: var(
--ha-color-fill-neutral-loud-hover
);
--button-color-fill-quiet-active: var(
--ha-color-fill-neutral-normal-active
);
}
:host([variant="success"]) {
@@ -114,6 +120,9 @@ export class HaButton extends Button {
--button-color-fill-loud-hover: var(
--ha-color-fill-success-loud-hover
);
--button-color-fill-quiet-active: var(
--ha-color-fill-success-quiet-active
);
}
:host([variant="warning"]) {
@@ -129,6 +138,9 @@ export class HaButton extends Button {
--button-color-fill-loud-hover: var(
--ha-color-fill-warning-loud-hover
);
--button-color-fill-quiet-active: var(
--ha-color-fill-warning-quiet-active
);
}
:host([variant="danger"]) {
@@ -144,6 +156,9 @@ export class HaButton extends Button {
--button-color-fill-loud-hover: var(
--ha-color-fill-danger-loud-hover
);
--button-color-fill-quiet-active: var(
--ha-color-fill-danger-quiet-active
);
}
:host([appearance~="plain"]) .button {
@@ -187,6 +202,10 @@ export class HaButton extends Button {
background-color: var(--ha-color-fill-disabled-normal-resting);
color: var(--ha-color-on-disabled-normal);
}
:host([appearance~="plain"])
.button:not(.disabled):not(.loading):active {
background-color: var(--button-color-fill-quiet-active);
}
:host([appearance~="accent"]) .button {
background-color: var(
@@ -212,17 +231,17 @@ export class HaButton extends Button {
}
slot[name="start"]::slotted(*) {
margin-inline-end: 4px;
margin-inline-end: var(--ha-space-1);
}
slot[name="end"]::slotted(*) {
margin-inline-start: 4px;
margin-inline-start: var(--ha-space-1);
}
.button.has-start {
padding-inline-start: 8px;
padding-inline-start: var(--ha-space-2);
}
.button.has-end {
padding-inline-end: 8px;
padding-inline-end: var(--ha-space-2);
}
.label {

View File

@@ -2,6 +2,7 @@ import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { styleMap } from "lit/directives/style-map";
import { STATE_RUNNING } from "home-assistant-js-websocket";
import memoizeOne from "memoize-one";
import { computeStateName } from "../common/entity/compute_state_name";
import { supportsFeature } from "../common/entity/supports-feature";
@@ -58,12 +59,22 @@ export class HaCameraStream extends LitElement {
@state() private _webRtcStreams?: { hasAudio: boolean; hasVideo: boolean };
public willUpdate(changedProps: PropertyValues): void {
if (
const entityChanged =
changedProps.has("stateObj") &&
this.stateObj &&
(changedProps.get("stateObj") as CameraEntity | undefined)?.entity_id !==
this.stateObj.entity_id
) {
this.stateObj.entity_id;
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
const backendStarted =
changedProps.has("hass") &&
this.hass &&
this.stateObj &&
oldHass &&
this.hass.config.state === STATE_RUNNING &&
oldHass.config?.state !== STATE_RUNNING;
if (entityChanged || backendStarted) {
this._getCapabilities();
this._getPosterUrl();
}

View File

@@ -95,7 +95,7 @@ export class HaCopyTextfield extends LitElement {
right: 8px;
inset-inline-start: initial;
inset-inline-end: 8px;
--mdc-icon-button-size: 40px;
--ha-icon-button-size: 40px;
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
direction: var(--direction);

View File

@@ -20,6 +20,8 @@ import "./ha-icon-button";
export type DialogWidth = "small" | "medium" | "large" | "full";
type DialogHideEvent = CustomEvent<{ source?: Element }>;
/**
* Home Assistant dialog component
*
@@ -217,7 +219,7 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
fireEvent(this, "after-show");
};
private _handleAfterHide = (ev: CustomEvent<{ source: Element }>) => {
private _handleAfterHide = (ev: DialogHideEvent) => {
if (ev.eventPhase === Event.AT_TARGET) {
this._open = false;
fireEvent(this, "closed");
@@ -237,17 +239,18 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
private _handleKeyDown(ev: KeyboardEvent) {
if (ev.key === "Escape") {
this._escapePressed = true;
ev.stopPropagation();
(ev.currentTarget as WaDialog).open = false;
}
}
private _handleHide(ev: CustomEvent<{ source: Element }>) {
if (
this.preventScrimClose &&
this._escapePressed &&
ev.detail.source === (ev.target as WaDialog).dialog
) {
private _handleHide(ev: DialogHideEvent) {
const sourceIsDialog = ev.detail?.source === (ev.target as WaDialog).dialog;
if (this.preventScrimClose && this._escapePressed && sourceIsDialog) {
ev.preventDefault();
}
this._escapePressed = false;
}
@@ -331,29 +334,29 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
@media all and (max-width: 450px), all and (max-height: 500px) {
:host([type="standard"]) {
--ha-dialog-border-radius: 0;
}
wa-dialog {
/* Make the container fill the whole screen width and not the safe width */
--full-width: var(--ha-dialog-width-full, 100vw);
--width: var(--full-width);
}
:host([type="standard"]) wa-dialog {
/* Make the container fill the whole screen width and not the safe width */
--full-width: var(--ha-dialog-width-full, 100vw);
--width: var(--full-width);
}
wa-dialog::part(dialog) {
/* Make the dialog fill the whole screen height and not the safe height */
min-height: var(--ha-dialog-min-height, 100vh);
min-height: var(--ha-dialog-min-height, 100dvh);
max-height: var(--ha-dialog-max-height, 100vh);
max-height: var(--ha-dialog-max-height, 100dvh);
margin-top: 0;
margin-bottom: 0;
/* Use safe area as padding instead of the container size */
padding-top: var(--safe-area-inset-top);
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
/* Reset the transform to center the dialog */
transform: none;
}
:host([type="standard"]) wa-dialog::part(dialog) {
/* Make the dialog fill the whole screen height and not the safe height */
min-height: var(--ha-dialog-min-height, 100vh);
min-height: var(--ha-dialog-min-height, 100dvh);
max-height: var(--ha-dialog-max-height, 100vh);
max-height: var(--ha-dialog-max-height, 100dvh);
margin-top: 0;
margin-bottom: 0;
/* Use safe area as padding instead of the container size */
padding-top: var(--safe-area-inset-top);
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
/* Reset the transform to center the dialog */
transform: none;
}
}

View File

@@ -1,8 +1,8 @@
import type WaButton from "@home-assistant/webawesome/dist/components/button/button";
import Dropdown from "@home-assistant/webawesome/dist/components/dropdown/dropdown";
import { css, type CSSResultGroup } from "lit";
import { customElement, property } from "lit/decorators";
import type { HaDropdownItem } from "./ha-dropdown-item";
import type { HaIconButton } from "./ha-icon-button";
/**
* Event type for the ha-dropdown component when an item is selected.
@@ -29,16 +29,25 @@ export class HaDropdown extends Dropdown {
@property({ attribute: false }) dropdownItemTag = "ha-dropdown-item";
public get anchorElement(): HTMLButtonElement | WaButton | undefined {
public get anchorElement(): HTMLButtonElement | HaIconButton | undefined {
// @ts-ignore Allow to set an anchor element on popup
return this.popup?.anchor as HTMLButtonElement | WaButton | undefined;
return this.popup?.anchor as HTMLButtonElement | HaIconButton | undefined;
}
public set anchorElement(element: HTMLButtonElement | WaButton | undefined) {
public set anchorElement(
element: HTMLButtonElement | HaIconButton | undefined
) {
// @ts-ignore Allow to get the current anchor element from popup
if (!this.popup) {
return;
}
// @ts-ignore
if (this.popup.anchor && this.popup.anchor.localName === "ha-icon-button") {
// @ts-ignore
(this.popup.anchor as HaIconButton).selected = false;
}
// @ts-ignore Allow to get the current anchor element from popup
this.popup.anchor = element;
}
@@ -46,7 +55,7 @@ export class HaDropdown extends Dropdown {
/** Get the slotted trigger button, a <wa-button> or <button> element */
// @ts-ignore Override parent method to be able to use alternative anchor
// eslint-disable-next-line @typescript-eslint/naming-convention
private override getTrigger(): HTMLButtonElement | WaButton | null {
private override getTrigger(): HTMLButtonElement | HaIconButton | null {
if (this.anchorElement) {
return this.anchorElement;
}
@@ -54,6 +63,28 @@ export class HaDropdown extends Dropdown {
return super.getTrigger();
}
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/naming-convention
private override async showMenu() {
// @ts-ignore
await super.showMenu();
const triggerElement = this.getTrigger();
if (triggerElement && triggerElement.localName === "ha-icon-button") {
(triggerElement as HaIconButton).selected = true;
}
}
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/naming-convention
private override async hideMenu() {
const triggerElement = this.getTrigger();
if (triggerElement && triggerElement.localName === "ha-icon-button") {
(triggerElement as HaIconButton).selected = false;
}
// @ts-ignore
await super.hideMenu();
}
static get styles(): CSSResultGroup {
return [
Dropdown.styles,

View File

@@ -37,6 +37,9 @@ class HaDurationInput extends LitElement {
@property({ attribute: "allow-negative", type: Boolean })
public allowNegative = false;
@property({ attribute: "enable-second", type: Boolean })
public enableSecond = true;
@property({ type: Boolean }) public disabled = false;
private _toggleNegative = false;
@@ -65,7 +68,7 @@ class HaDurationInput extends LitElement {
.autoValidate=${this.required}
.disabled=${this.disabled}
errorMessage="Required"
enable-second
.enableSecond=${this.enableSecond}
.enableMillisecond=${this.enableMillisecond}
.enableDay=${this.enableDay}
format="24"
@@ -162,9 +165,9 @@ class HaDurationInput extends LitElement {
if (value) {
value.hours ||= 0;
value.minutes ||= 0;
value.seconds ||= 0;
if ("days" in value) value.days ||= 0;
if ("seconds" in value) value.seconds ||= 0;
if ("milliseconds" in value) value.milliseconds ||= 0;
if (this.allowNegative) {
@@ -183,8 +186,11 @@ class HaDurationInput extends LitElement {
value.milliseconds %= 1000;
}
if (value.seconds > 59) {
value.minutes += Math.floor(value.seconds / 60);
if (!this.enableSecond && !value.seconds) {
// @ts-ignore
delete value.seconds;
} else if (this.enableSecond && value.seconds > 59) {
value.minutes = (value.minutes ?? 0) + Math.floor(value.seconds / 60);
value.seconds %= 60;
}

View File

@@ -4,14 +4,14 @@ import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ensureArray } from "../common/array/ensure-array";
import { fireEvent } from "../common/dom/fire_event";
import { blankBeforePercent } from "../common/translations/blank_before_percent";
import type { LocalizeFunc } from "../common/translations/localize";
import type { HomeAssistant } from "../types";
import { bytesToString } from "../util/bytes-to-string";
import "./ha-button";
import "./ha-icon-button";
import { blankBeforePercent } from "../common/translations/blank_before_percent";
import { ensureArray } from "../common/array/ensure-array";
import { bytesToString } from "../util/bytes-to-string";
import type { LocalizeFunc } from "../common/translations/localize";
declare global {
interface HASSDomEvents {
@@ -317,7 +317,7 @@ export class HaFileUpload extends LitElement {
}
ha-button {
--mdc-button-outline-color: var(--primary-color);
--mdc-icon-button-size: 24px;
--ha-icon-button-size: 24px;
}
mwc-linear-progress {
width: 100%;

View File

@@ -26,12 +26,12 @@ import { showCategoryRegistryDetailDialog } from "../panels/config/category/show
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-dropdown";
import type { HaDropdownSelectEvent } from "./ha-dropdown";
import "./ha-dropdown-item";
import "./ha-expansion-panel";
import "./ha-icon";
import "./ha-list";
import "./ha-list-item";
import type { HaDropdownSelectEvent } from "./ha-dropdown";
@customElement("ha-filter-categories")
export class HaFilterCategories extends SubscribeMixin(LitElement) {
@@ -317,7 +317,7 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
--mdc-list-item-meta-size: auto;
--mdc-list-side-padding-right: var(--ha-space-1);
--mdc-list-side-padding-left: var(--ha-space-4);
--mdc-icon-button-size: 36px;
--ha-icon-button-size: 36px;
}
ha-list-item {
--mdc-list-item-graphic-margin: var(--ha-space-4);

View File

@@ -1,20 +1,16 @@
import { mdiEye, mdiEyeOff } from "@mdi/js";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { LocalizeFunc } from "../../common/translations/localize";
import "../ha-icon-button";
import "../ha-textfield";
import type { HaTextField } from "../ha-textfield";
import "../ha-input";
import type { HaInput } from "../ha-input";
import type {
HaFormElement,
HaFormStringData,
HaFormStringSchema,
} from "./types";
import type {
LocalizeFunc,
LocalizeKeys,
} from "../../common/translations/localize";
const MASKED_FIELDS = ["password", "secret", "token"];
@@ -37,7 +33,7 @@ export class HaFormString extends LitElement implements HaFormElement {
@state() protected unmaskedPassword = false;
@query("ha-textfield") private _input?: HaTextField;
@query("ha-input") private _input?: HaInput;
public focus(): void {
if (this._input) {
@@ -47,48 +43,29 @@ export class HaFormString extends LitElement implements HaFormElement {
protected render(): TemplateResult {
return html`
<ha-textfield
.type=${!this.isPassword
? this.stringType
: this.unmaskedPassword
? "text"
: "password"}
<ha-input
.passwordToggle=${this.isPassword}
.passwordVisible=${this.unmaskedPassword}
.type=${!this.isPassword ? this.stringType : "password"}
.label=${this.label}
.value=${this.data || ""}
.helper=${this.helper}
helperPersistent
.hint=${this.helper}
.disabled=${this.disabled}
.required=${this.schema.required}
.autoValidate=${this.schema.required}
.required=${!!this.schema.required}
.autoValidate=${!!this.schema.required}
.name=${this.schema.name}
.autofocus=${this.schema.autofocus}
.autofocus=${!!this.schema.autofocus}
.autocomplete=${this.schema.autocomplete}
.suffix=${this.isPassword
? // reserve some space for the icon.
html`<div style="width: 24px"></div>`
: this.schema.description?.suffix}
.validationMessage=${this.schema.required
? this.localize?.("ui.common.error_required")
: undefined}
@input=${this._valueChanged}
@change=${this._valueChanged}
></ha-textfield>
${this.renderIcon()}
`;
}
protected renderIcon() {
if (!this.isPassword) return nothing;
return html`
<ha-icon-button
.label=${this.localize?.(
`${this.localizeBaseKey}.${
this.unmaskedPassword ? "hide_password" : "show_password"
}` as LocalizeKeys
)}
@click=${this.toggleUnmaskedPassword}
.path=${this.unmaskedPassword ? mdiEyeOff : mdiEye}
></ha-icon-button>
>
${this.schema.description?.suffix
? html`<span slot="end">${this.schema.description.suffix}</span>`
: nothing}
</ha-input>
`;
}
@@ -98,12 +75,8 @@ export class HaFormString extends LitElement implements HaFormElement {
}
}
protected toggleUnmaskedPassword(): void {
this.unmaskedPassword = !this.unmaskedPassword;
}
protected _valueChanged(ev: Event): void {
let value: string | undefined = (ev.target as HaTextField).value;
let value: string | undefined = (ev.target as HaInput).value;
if (this.data === value) {
return;
}
@@ -115,10 +88,10 @@ export class HaFormString extends LitElement implements HaFormElement {
});
}
protected get stringType(): string {
protected get stringType(): "email" | "url" | "text" {
if (this.schema.format) {
if (["email", "url"].includes(this.schema.format)) {
return this.schema.format;
return this.schema.format as "email" | "url";
}
if (this.schema.format === "fqdnurl") {
return "url";
@@ -139,20 +112,6 @@ export class HaFormString extends LitElement implements HaFormElement {
:host([own-margin]) {
margin-bottom: 5px;
}
ha-textfield {
display: block;
}
ha-icon-button {
position: absolute;
top: 8px;
right: 8px;
inset-inline-start: initial;
inset-inline-end: 8px;
--mdc-icon-button-size: 40px;
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
direction: var(--direction);
}
`;
}

View File

@@ -194,7 +194,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
.image=${this.image}
.label=${label}
.placeholder=${this.placeholder}
.helper=${this.helper}
.value=${this.value}
.valueRenderer=${this.valueRenderer}
.required=${this.required}

View File

@@ -1,14 +1,14 @@
import { mdiRestore } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../panels/lovelace/editor/card-editor/ha-grid-layout-slider";
import "./ha-icon-button";
import { mdiRestore } from "@mdi/js";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../common/dom/fire_event";
import { conditionalClamp } from "../common/number/clamp";
import type { CardGridSize } from "../panels/lovelace/common/compute-card-grid-size";
import { DEFAULT_GRID_SIZE } from "../panels/lovelace/common/compute-card-grid-size";
import "../panels/lovelace/editor/card-editor/ha-grid-layout-slider";
import type { HomeAssistant } from "../types";
import "./ha-icon-button";
@customElement("ha-grid-size-picker")
export class HaGridSizeEditor extends LitElement {
@@ -245,7 +245,7 @@ export class HaGridSizeEditor extends LitElement {
}
.reset {
grid-area: reset;
--mdc-icon-button-size: 36px;
--ha-icon-button-size: 36px;
}
.preview {
position: relative;

View File

@@ -14,6 +14,14 @@ export class HaIconButtonArrowPrev extends LitElement {
@property() public label?: string;
@property() href?: string;
@property() target?: "_blank" | "_parent" | "_self" | "_top";
@property() rel?: string;
@property() download?: string;
@state() private _icon =
mainWindow.document.dir === "rtl" ? mdiArrowRight : mdiArrowLeft;
@@ -23,6 +31,10 @@ export class HaIconButtonArrowPrev extends LitElement {
.disabled=${this.disabled}
.label=${this.label || this.hass?.localize("ui.common.back") || "Back"}
.path=${this._icon}
.href=${this.href}
.target=${this.target}
.rel=${this.rel}
.download=${this.download}
></ha-icon-button>
`;
}

View File

@@ -14,6 +14,14 @@ export class HaIconButtonNext extends LitElement {
@property() public label?: string;
@property() href?: string;
@property() target?: "_blank" | "_parent" | "_self" | "_top";
@property() rel?: string;
@property() download?: string;
@state() private _icon =
mainWindow.document.dir === "rtl" ? mdiChevronLeft : mdiChevronRight;
@@ -23,6 +31,10 @@ export class HaIconButtonNext extends LitElement {
.disabled=${this.disabled}
.label=${this.label || this.hass?.localize("ui.common.next") || "Next"}
.path=${this._icon}
.href=${this.href}
.target=${this.target}
.rel=${this.rel}
.download=${this.download}
></ha-icon-button>
`;
}

View File

@@ -14,6 +14,14 @@ export class HaIconButtonPrev extends LitElement {
@property() public label?: string;
@property() href?: string;
@property() target?: "_blank" | "_parent" | "_self" | "_top";
@property() rel?: string;
@property() download?: string;
@state() private _icon =
mainWindow.document.dir === "rtl" ? mdiChevronRight : mdiChevronLeft;
@@ -23,6 +31,10 @@ export class HaIconButtonPrev extends LitElement {
.disabled=${this.disabled}
.label=${this.label || this.hass?.localize("ui.common.back") || "Back"}
.path=${this._icon}
.href=${this.href}
.target=${this.target}
.rel=${this.rel}
.download=${this.download}
></ha-icon-button>
`;
}

View File

@@ -1,3 +1,4 @@
import type { CSSResultGroup } from "lit";
import { css } from "lit";
import { customElement, property } from "lit/decorators";
import { HaIconButton } from "./ha-icon-button";
@@ -6,41 +7,48 @@ import { HaIconButton } from "./ha-icon-button";
export class HaIconButtonToggle extends HaIconButton {
@property({ type: Boolean, reflect: true }) selected = false;
static styles = css`
:host {
position: relative;
}
mwc-icon-button {
position: relative;
transition: color 180ms ease-in-out;
}
mwc-icon-button::before {
opacity: 0;
transition: opacity 180ms ease-in-out;
background-color: var(--primary-text-color);
border-radius: var(--ha-border-radius-2xl);
height: 40px;
width: 40px;
content: "";
position: absolute;
top: -10px;
left: -10px;
bottom: -10px;
right: -10px;
margin: auto;
box-sizing: border-box;
}
:host([border-only]) mwc-icon-button::before {
background-color: transparent;
border: 2px solid var(--primary-text-color);
}
:host([selected]) mwc-icon-button {
color: var(--primary-background-color);
}
:host([selected]:not([disabled])) mwc-icon-button::before {
opacity: 1;
}
`;
static styles: CSSResultGroup = [
HaIconButton.styles,
css`
:host {
position: relative;
}
ha-button::part(base) {
position: relative;
transition: color 180ms ease-in-out;
}
ha-button::part(base)::before {
opacity: 0;
transition: opacity 180ms ease-in-out;
background-color: var(--primary-text-color);
border-radius: var(--ha-border-radius-2xl);
height: 40px;
width: 40px;
content: "";
position: absolute;
top: -10px;
left: -10px;
bottom: -10px;
right: -10px;
margin: auto;
box-sizing: border-box;
}
:host([border-only]) ha-button::part(base)::before {
background-color: transparent;
border: 2px solid var(--primary-text-color);
}
:host([selected]) ha-button::part(base) {
color: var(--primary-background-color);
background-color: unset;
}
:host([selected]:not([disabled])) ha-button::part(base)::before {
opacity: 1;
}
::slotted(*) {
display: block;
}
`,
];
}
declare global {

View File

@@ -109,7 +109,7 @@ export class HaIconButtonToolbar extends LitElement {
.icon-toolbar-button {
color: var(--secondary-text-color);
--mdc-icon-button-size: var(--icon-button-toolbar-button);
--ha-icon-button-size: var(--icon-button-toolbar-button);
--mdc-icon-size: var(--icon-button-toolbar-icon);
/* Ensure button is clickable on iOS */
cursor: pointer;

View File

@@ -1,9 +1,8 @@
import "@material/mwc-icon-button";
import type { IconButton } from "@material/mwc-icon-button";
import type { TemplateResult } from "lit";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import "./ha-button";
import "./ha-svg-icon";
@customElement("ha-icon-button")
@@ -19,15 +18,19 @@ export class HaIconButton extends LitElement {
// These should always be set as properties, not attributes,
// so that only the <button> element gets the attribute
@property({ type: String, attribute: "aria-haspopup" })
override ariaHasPopup!: IconButton["ariaHasPopup"];
ariaHasPopup!: "false" | "true" | "menu" | "listbox" | "tree" | "grid";
@property({ attribute: "hide-title", type: Boolean }) hideTitle = false;
@query("mwc-icon-button", true) private _button?: IconButton;
@property({ type: Boolean, reflect: true }) selected = false;
public override focus() {
this._button?.focus();
}
@property() href?: string;
@property() target?: "_blank" | "_parent" | "_self" | "_top";
@property() rel?: string;
@property() download?: string;
static shadowRootOptions: ShadowRootInit = {
mode: "open",
@@ -36,30 +39,68 @@ export class HaIconButton extends LitElement {
protected render(): TemplateResult {
return html`
<mwc-icon-button
<ha-button
appearance="plain"
variant="neutral"
aria-label=${ifDefined(this.label)}
title=${ifDefined(this.hideTitle ? undefined : this.label)}
aria-haspopup=${ifDefined(this.ariaHasPopup)}
.disabled=${this.disabled}
.iconTag=${this.path ? "ha-svg-icon" : "span"}
.href=${this.href}
.target=${this.target}
.rel=${this.rel}
.download=${this.download}
>
${this.path
? html`<ha-svg-icon .path=${this.path}></ha-svg-icon>`
: html`<slot></slot>`}
</mwc-icon-button>
: html`<span><slot></slot></span>`}
</ha-button>
`;
}
static styles = css`
static styles: CSSResultGroup = css`
:host {
display: inline-block;
outline: none;
--ha-button-height: var(--ha-icon-button-size, 48px);
}
:host([disabled]) {
ha-button {
position: relative;
isolation: isolate;
--wa-form-control-padding-inline: var(
--ha-icon-button-padding-inline,
--ha-space-2
);
--wa-color-on-normal: currentColor;
--wa-color-fill-quiet: transparent;
}
ha-button::after {
content: "";
position: absolute;
inset: 0;
z-index: -1;
border-radius: 50%;
background-color: currentColor;
opacity: 0;
pointer-events: none;
}
mwc-icon-button {
--mdc-theme-on-primary: currentColor;
--mdc-theme-text-disabled-on-light: var(--disabled-text-color);
ha-button::part(base) {
width: var(--wa-form-control-height);
aspect-ratio: 1;
outline-offset: -4px;
}
ha-button::part(label) {
display: flex;
}
:host([selected]) ha-button::after {
opacity: 0.1;
}
@media (hover: hover) {
:host(:hover:not([disabled])) ha-button::after {
opacity: 0.1;
}
}
`;
}

473
src/components/ha-input.ts Normal file
View File

@@ -0,0 +1,473 @@
import { preventDefault } from "@fullcalendar/core/internal";
import "@home-assistant/webawesome/dist/components/animation/animation";
import "@home-assistant/webawesome/dist/components/input/input";
import type WaInput from "@home-assistant/webawesome/dist/components/input/input";
import { mdiClose, mdiEye, mdiEyeOff, mdiInformationOutline } from "@mdi/js";
import { LitElement, type PropertyValues, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import "./ha-icon-button";
import "./ha-svg-icon";
import "./ha-tooltip";
@customElement("ha-input")
export class HaInput extends LitElement {
/** The type of input. */
@property()
public type:
| "date"
| "datetime-local"
| "email"
| "number"
| "password"
| "search"
| "tel"
| "text"
| "time"
| "url" = "text";
/** The current value of the input. */
@property()
public value?: string;
/** The input's size. */
@property()
public size: "small" | "medium" | "large" = "medium";
/** The input's visual appearance. */
@property()
public appearance: "filled" | "outlined" | "filled-outlined" = "outlined";
/** Draws a pill-style input with rounded edges. */
@property({ type: Boolean })
public pill = false;
/** The input's label. */
@property()
public label = "";
/** The input's hint. */
@property()
public hint? = "";
/** Adds a clear button when the input is not empty. */
@property({ type: Boolean, attribute: "with-clear" })
public withClear = false;
/** Placeholder text to show as a hint when the input is empty. */
@property()
public placeholder = "";
/** Makes the input readonly. */
@property({ type: Boolean })
public readonly = false;
/** Adds a button to toggle the password's visibility. */
@property({ type: Boolean, attribute: "password-toggle" })
public passwordToggle = false;
/** Determines whether or not the password is currently visible. */
@property({ type: Boolean, attribute: "password-visible" })
public passwordVisible = false;
/** Hides the browser's built-in increment/decrement spin buttons for number inputs. */
@property({ type: Boolean, attribute: "without-spin-buttons" })
public withoutSpinButtons = false;
/** Makes the input a required field. */
@property({ type: Boolean })
public required = false;
/** A regular expression pattern to validate input against. */
@property()
public pattern?: string;
/** The minimum length of input that will be considered valid. */
@property({ type: Number })
public minlength?: number;
/** The maximum length of input that will be considered valid. */
@property({ type: Number })
public maxlength?: number;
/** The input's minimum value. Only applies to date and number input types. */
@property()
public min?: number | string;
/** The input's maximum value. Only applies to date and number input types. */
@property()
public max?: number | string;
/** Specifies the granularity that the value must adhere to. */
@property()
public step?: number | "any";
/** Controls whether and how text input is automatically capitalized. */
@property()
// eslint-disable-next-line lit/no-native-attributes
public autocapitalize:
| "off"
| "none"
| "on"
| "sentences"
| "words"
| "characters"
| "" = "";
/** Indicates whether the browser's autocorrect feature is on or off. */
@property({ type: Boolean })
public autocorrect = false;
/** Specifies what permission the browser has to provide assistance in filling out form field values. */
@property()
public autocomplete?: string;
/** Indicates that the input should receive focus on page load. */
@property({ type: Boolean })
// eslint-disable-next-line lit/no-native-attributes
public autofocus = false;
/** Used to customize the label or icon of the Enter key on virtual keyboards. */
@property()
// eslint-disable-next-line lit/no-native-attributes
public enterkeyhint:
| "enter"
| "done"
| "go"
| "next"
| "previous"
| "search"
| "send"
| "" = "";
/** Enables spell checking on the input. */
@property({ type: Boolean })
// eslint-disable-next-line lit/no-native-attributes
public spellcheck = true;
/** Tells the browser what type of data will be entered by the user. */
@property()
// eslint-disable-next-line lit/no-native-attributes
public inputmode:
| "none"
| "text"
| "decimal"
| "numeric"
| "tel"
| "search"
| "email"
| "url"
| "" = "";
/** The name of the input, submitted as a name/value pair with form data. */
@property()
public name?: string;
/** Disables the form control. */
@property({ type: Boolean })
public disabled = false;
/** Custom validation message to show when the input is invalid. */
@property({ attribute: "validation-message" })
public validationMessage? = "";
/** When true, validates the input on blur instead of on form submit. */
@property({ type: Boolean, attribute: "auto-validate" })
public autoValidate = false;
@property({ type: Boolean })
public invalid = false;
@state()
private _invalid = false;
@query("wa-input")
private _input?: WaInput;
static shadowRootOptions: ShadowRootInit = {
mode: "open",
delegatesFocus: true,
};
/** Selects all the text in the input. */
public select(): void {
this._input?.select();
}
/** Sets the start and end positions of the text selection (0-based). */
public setSelectionRange(
selectionStart: number,
selectionEnd: number,
selectionDirection?: "forward" | "backward" | "none"
): void {
this._input?.setSelectionRange(
selectionStart,
selectionEnd,
selectionDirection
);
}
/** Replaces a range of text with a new string. */
public setRangeText(
replacement: string,
start?: number,
end?: number,
selectMode?: "select" | "start" | "end" | "preserve"
): void {
this._input?.setRangeText(replacement, start, end, selectMode);
}
/** Displays the browser picker for an input element. */
public showPicker(): void {
this._input?.showPicker();
}
/** Increments the value of a numeric input type by the value of the step attribute. */
public stepUp(): void {
this._input?.stepUp();
}
/** Decrements the value of a numeric input type by the value of the step attribute. */
public stepDown(): void {
this._input?.stepDown();
}
public checkValidity(): boolean {
return this._input?.checkValidity() ?? true;
}
public reportValidity(): boolean {
const valid = this.checkValidity();
this._invalid = !valid;
return valid;
}
protected override updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
const nativeInput = this._input?.input;
if (!nativeInput) return;
// wa-input hardcodes aria-describedby="hint" pointing to its internal hint slot wrapper.
// We remove it and use aria-description instead to properly convey our hint or error text.
// TODO: fix upstream in wa-input
nativeInput.removeAttribute("aria-describedby");
// wa-input doesn't set aria-invalid on its internal <input>, so we do it manually
// TODO: fix upstream in wa-input
if (changedProperties.has("invalid") || changedProperties.has("_invalid")) {
const isInvalid = this.invalid || this._invalid;
nativeInput.setAttribute("aria-invalid", String(isInvalid));
}
// Expose hint or validation error to screen readers on the input itself
const description =
this.invalid || this._invalid
? this.validationMessage || this._input?.validationMessage
: this.hint;
if (description) {
nativeInput.setAttribute("aria-description", description);
} else {
nativeInput.removeAttribute("aria-description");
}
}
protected render() {
return html`
<wa-input
.type=${this.type}
.value=${this.value ?? null}
.size=${this.size}
.appearance=${this.appearance}
.withClear=${this.withClear}
.placeholder=${this.placeholder}
.readonly=${this.readonly}
.passwordToggle=${this.passwordToggle}
.passwordVisible=${this.passwordVisible}
.withoutSpinButtons=${this.withoutSpinButtons}
.required=${this.required}
.pattern=${this.pattern}
.minlength=${this.minlength}
.maxlength=${this.maxlength}
.min=${this.min}
.max=${this.max}
.step=${this.step}
.autocapitalize=${this.autocapitalize || undefined}
.autocorrect=${this.autocorrect ? "on" : "off"}
.autocomplete=${this.autocomplete}
.autofocus=${this.autofocus}
.enterkeyhint=${this.enterkeyhint || undefined}
.spellcheck=${this.spellcheck}
.inputmode=${this.inputmode || undefined}
.name=${this.name}
.disabled=${this.disabled}
class=${this.invalid || this._invalid ? "invalid" : ""}
@input=${this._handleInput}
@change=${this._handleChange}
@blur=${this._handleBlur}
@wa-invalid=${this._handleInvalid}
>
<div class="label" slot="label">
<span>
<slot name="label">${this.label}</slot>
</span>
${this.hint
? html`<ha-icon-button
@click=${preventDefault}
.path=${mdiInformationOutline}
.label=${"Hint"}
hide-title
id="hint"
></ha-icon-button>
<ha-tooltip for="hint">${this.hint}</ha-tooltip> `
: nothing}
</div>
<slot name="start" slot="start"></slot>
<slot name="end" slot="end"></slot>
<slot name="clear-icon" slot="clear-icon">
<ha-svg-icon .path=${mdiClose}></ha-svg-icon>
</slot>
<slot name="show-password-icon" slot="show-password-icon">
<ha-svg-icon .path=${mdiEye}></ha-svg-icon>
</slot>
<slot name="hide-password-icon" slot="hide-password-icon">
<ha-svg-icon .path=${mdiEyeOff}></ha-svg-icon>
</slot>
<div
slot="hint"
class="error ${this.invalid || this._invalid ? "visible" : ""}"
role="alert"
aria-live="assertive"
>
<span
>${this.validationMessage || this._input?.validationMessage}</span
>
</div>
</wa-input>
`;
}
private _handleInput() {
this.value = this._input?.value ?? undefined;
if (this._invalid && this._input?.checkValidity()) {
this._invalid = false;
}
}
private _handleChange() {
this.value = this._input?.value ?? undefined;
}
private _handleBlur() {
if (this.autoValidate) {
this._invalid = !this._input?.checkValidity();
}
}
private _handleInvalid() {
this._invalid = true;
}
static styles = css`
:host {
display: flex;
align-items: flex-start;
padding-top: var(--ha-input-padding-top, var(--ha-space-2));
padding-bottom: var(--ha-input-padding-bottom, var(--ha-space-2));
text-align: var(--ha-input-text-align, start);
}
wa-input {
flex: 1;
min-width: 0;
}
wa-input::part(base):focus-within {
outline: none;
--wa-form-control-border-color: var(--ha-color-border-primary-normal);
}
wa-input.invalid,
wa-input.invalid::part(base):focus-within {
--wa-form-control-border-color: var(--ha-color-border-danger-normal);
}
wa-input::part(label) {
margin-block-end: 2px;
}
.label {
height: 24px;
display: flex;
width: 100%;
align-items: center;
color: var(--ha-color-text-secondary);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
gap: var(--ha-space-1);
}
.label span {
line-height: 1;
flex: 1;
min-width: 0;
overflow-x: clip;
overflow-y: visible;
text-overflow: ellipsis;
white-space: nowrap;
}
.label ha-svg-icon {
color: var(--ha-color-on-disabled-normal);
--mdc-icon-size: 16px;
}
#hint {
--ha-icon-button-size: 16px;
--mdc-icon-size: 16px;
color: var(--ha-color-on-disabled-normal);
}
wa-input::part(hint) {
margin-block-start: 0;
color: var(--ha-color-on-danger-quiet);
font-size: var(--ha-font-size-s);
margin-inline-start: var(--ha-space-3);
}
.error {
padding-top: var(--ha-space-1);
transition:
opacity 0.3s ease-out,
height 0.3s ease-out;
height: 0;
overflow: hidden;
}
.error span {
transition: transform 0.3s ease-out;
display: inline-block;
transform: translateY(
calc(-1 * (var(--ha-font-size-s) + var(--ha-space-1)))
);
}
.error.visible {
height: calc(var(--ha-font-size-s) + var(--ha-space-2));
}
.error.visible span {
transform: translateY(0);
}
wa-input::part(end) {
color: var(--ha-color-text-secondary);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-input": HaInput;
}
}

View File

@@ -191,7 +191,6 @@ export class HaLanguagePicker extends LitElement {
static styles = css`
ha-generic-picker {
width: 100%;
min-width: 200px;
display: block;
}
`;

View File

@@ -84,13 +84,11 @@ export class HaMarkdown extends LitElement {
ha-markdown-element > :is(ol, ul) {
padding-inline-start: var(--markdown-list-indent, revert);
}
li {
&:has(input[type="checkbox"]) {
list-style: none;
& > input[type="checkbox"] {
margin-left: 0;
}
}
li:has(input[type="checkbox"]) {
list-style: none;
}
li:has(input[type="checkbox"]) > input[type="checkbox"] {
margin-left: 0;
}
svg {
background-color: var(--markdown-svg-background-color, none);
@@ -137,10 +135,10 @@ export class HaMarkdown extends LitElement {
--markdown-table-border-width: 0;
--markdown-table-padding-inline: 0;
--markdown-table-padding-block: 0;
th,
td {
vertical-align: middle;
}
}
table[role="presentation"] th,
table[role="presentation"] td {
vertical-align: middle;
}
table[role="presentation"] td[valign="top"],
table[role="presentation"] th[valign="top"] {

View File

@@ -1,34 +0,0 @@
import { styles } from "@material/web/textfield/internal/filled-styles";
import { FilledTextField } from "@material/web/textfield/internal/filled-text-field";
import { styles as sharedStyles } from "@material/web/textfield/internal/shared-styles";
import { css } from "lit";
import { customElement } from "lit/decorators";
@customElement("ha-md-textfield")
export class HaMdTextfield extends FilledTextField {
static override styles = [
sharedStyles,
styles,
css`
:host {
--ha-icon-display: block;
--md-sys-color-primary: var(--primary-text-color);
--md-sys-color-secondary: var(--secondary-text-color);
--md-sys-color-surface: var(--card-background-color);
--md-sys-color-on-surface-variant: var(--secondary-text-color);
--md-sys-color-surface-container-highest: var(--input-fill-color);
--md-sys-color-on-surface: var(--input-ink-color);
--md-sys-color-surface-container: var(--input-fill-color);
--md-sys-color-secondary-container: var(--input-fill-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-md-textfield": HaMdTextfield;
}
}

View File

@@ -1,211 +0,0 @@
import type { TextAreaCharCounter } from "@material/mwc-textfield/mwc-textfield-base";
import { mdiEye, mdiEyeOff } from "@mdi/js";
import { LitElement, css, html } from "lit";
import {
customElement,
eventOptions,
property,
query,
state,
} from "lit/decorators";
import type { HomeAssistant } from "../types";
import "./ha-icon-button";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
@customElement("ha-password-field")
export class HaPasswordField extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ type: Boolean }) public invalid?: boolean;
@property({ attribute: "error-message" }) public errorMessage?: string;
@property({ type: Boolean }) public icon = false;
// eslint-disable-next-line lit/attribute-names
@property({ type: Boolean }) public iconTrailing = false;
@property() public autocomplete?: string;
@property({ type: Boolean }) public autocorrect = true;
@property({ attribute: "input-spellcheck" })
public inputSpellcheck?: string;
@property({ type: String }) value = "";
@property({ type: String }) placeholder = "";
@property({ type: String }) label = "";
@property({ type: Boolean, reflect: true }) disabled = false;
@property({ type: Boolean }) required = false;
// eslint-disable-next-line lit/attribute-names
@property({ type: Number }) minLength = -1;
// eslint-disable-next-line lit/attribute-names
@property({ type: Number }) maxLength = -1;
@property({ type: Boolean, reflect: true }) outlined = false;
@property({ type: String }) helper = "";
// eslint-disable-next-line lit/attribute-names
@property({ type: Boolean }) validateOnInitialRender = false;
// eslint-disable-next-line lit/attribute-names
@property({ type: String }) validationMessage = "";
// eslint-disable-next-line lit/attribute-names
@property({ type: Boolean }) autoValidate = false;
@property({ type: String }) pattern = "";
@property({ type: Number }) size: number | null = null;
// eslint-disable-next-line lit/attribute-names
@property({ type: Boolean }) helperPersistent = false;
// eslint-disable-next-line lit/attribute-names
@property({ type: Boolean }) charCounter: boolean | TextAreaCharCounter =
false;
// eslint-disable-next-line lit/attribute-names
@property({ type: Boolean }) endAligned = false;
@property({ type: String }) prefix = "";
@property({ type: String }) suffix = "";
@property({ type: String }) name = "";
@property({ type: String, attribute: "input-mode" })
inputMode!: string;
// eslint-disable-next-line lit/attribute-names
@property({ type: Boolean }) readOnly = false;
// eslint-disable-next-line lit/no-native-attributes
@property({ attribute: false }) autocapitalize = "";
@state() private _unmaskedPassword = false;
@query("ha-textfield") private _textField!: HaTextField;
protected render() {
return html`<ha-textfield
.invalid=${this.invalid}
.errorMessage=${this.errorMessage}
.icon=${this.icon}
.iconTrailing=${this.iconTrailing}
.autocomplete=${this.autocomplete}
.autocorrect=${this.autocorrect}
.inputSpellcheck=${this.inputSpellcheck}
.value=${this.value}
.placeholder=${this.placeholder}
.label=${this.label}
.disabled=${this.disabled}
.required=${this.required}
.minLength=${this.minLength}
.maxLength=${this.maxLength}
.outlined=${this.outlined}
.helper=${this.helper}
.validateOnInitialRender=${this.validateOnInitialRender}
.validationMessage=${this.validationMessage}
.autoValidate=${this.autoValidate}
.pattern=${this.pattern}
.size=${this.size}
.helperPersistent=${this.helperPersistent}
.charCounter=${this.charCounter}
.endAligned=${this.endAligned}
.prefix=${this.prefix}
.name=${this.name}
.inputMode=${this.inputMode}
.readOnly=${this.readOnly}
.autocapitalize=${this.autocapitalize}
.type=${this._unmaskedPassword ? "text" : "password"}
.suffix=${html`<div style="width: 24px"></div>`}
@input=${this._handleInputEvent}
@change=${this._handleChangeEvent}
></ha-textfield>
<ha-icon-button
.label=${this.hass?.localize(
this._unmaskedPassword
? "ui.components.selectors.text.hide_password"
: "ui.components.selectors.text.show_password"
) || (this._unmaskedPassword ? "Hide password" : "Show password")}
@click=${this._toggleUnmaskedPassword}
.path=${this._unmaskedPassword ? mdiEyeOff : mdiEye}
></ha-icon-button>`;
}
public focus(): void {
this._textField.focus();
}
public checkValidity(): boolean {
return this._textField.checkValidity();
}
public reportValidity(): boolean {
return this._textField.reportValidity();
}
public setCustomValidity(message: string): void {
return this._textField.setCustomValidity(message);
}
public layout(): Promise<void> {
return this._textField.layout();
}
private _toggleUnmaskedPassword(): void {
this._unmaskedPassword = !this._unmaskedPassword;
}
@eventOptions({ passive: true })
private _handleInputEvent(ev) {
this.value = ev.target.value;
}
@eventOptions({ passive: true })
private _handleChangeEvent(ev) {
this.value = ev.target.value;
this._reDispatchEvent(ev);
}
private _reDispatchEvent(oldEvent: Event) {
const newEvent = new Event(oldEvent.type, oldEvent);
this.dispatchEvent(newEvent);
}
static styles = css`
:host {
display: block;
position: relative;
}
ha-textfield {
width: 100%;
}
ha-icon-button {
position: absolute;
top: 8px;
right: 8px;
inset-inline-start: initial;
inset-inline-end: 8px;
--mdc-icon-button-size: 40px;
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
direction: var(--direction);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-password-field": HaPasswordField;
}
}

View File

@@ -796,7 +796,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
:host {
display: flex;
flex-direction: column;
padding-top: var(--ha-space-3);
padding-top: var(--ha-space-4);
flex: 1;
}

View File

@@ -126,6 +126,7 @@ export class HaPickerField extends PickerMixin(LitElement) {
);
}
ha-combo-box-item {
position: relative;
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-radius: var(--ha-border-radius-sm);
border-end-end-radius: 0;
@@ -184,8 +185,8 @@ export class HaPickerField extends PickerMixin(LitElement) {
.clear {
margin: 0 -8px;
--mdc-icon-button-size: 32px;
--mdc-icon-size: 20px;
--ha-icon-button-size: 32px;
--ha-icon-button-padding-inline: var(--ha-space-1);
}
.arrow {
--mdc-icon-size: 20px;

View File

@@ -5,6 +5,7 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import "./ha-dropdown";
import "./ha-dropdown-item";
import "./ha-input-helper-text";
import "./ha-picker-field";
import type { HaPickerField } from "./ha-picker-field";
import "./ha-svg-icon";
@@ -75,7 +76,7 @@ export class HaSelect extends LitElement {
protected override render() {
if (this.disabled) {
return this._renderField();
return html`${this._renderField()}${this._renderHelper()}`;
}
return html`
@@ -116,6 +117,7 @@ export class HaSelect extends LitElement {
)
: html`<slot></slot>`}
</ha-dropdown>
${this._renderHelper()}
`;
}
@@ -131,7 +133,6 @@ export class HaSelect extends LitElement {
aria-label=${ifDefined(this.label)}
@clear=${this._clearValue}
.label=${this.label}
.helper=${this.helper}
.value=${valueLabel}
.required=${this.required}
.disabled=${this.disabled}
@@ -144,6 +145,14 @@ export class HaSelect extends LitElement {
`;
}
private _renderHelper() {
return this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
: nothing;
}
private _handleSelect(ev: CustomEvent<{ item: { value: string } }>) {
ev.stopPropagation();
const value = ev.detail.item.value;
@@ -194,6 +203,11 @@ export class HaSelect extends LitElement {
ha-dropdown::part(menu) {
min-width: var(--select-menu-width);
}
ha-input-helper-text {
display: block;
margin: var(--ha-space-2) 0 0;
}
`;
}
declare global {

View File

@@ -66,6 +66,7 @@ export class HaTimeDuration extends LitElement {
.enableDay=${this.selector.duration?.enable_day}
.enableMillisecond=${this.selector.duration?.enable_millisecond}
.allowNegative=${this.selector.duration?.allow_negative}
.enableSecond=${this.selector.duration?.enable_second ?? true}
></ha-duration-input>
`;
}

View File

@@ -64,7 +64,7 @@ export class HaEntitySelector extends LitElement {
if (!this.selector.entity?.multiple) {
return html`<ha-entity-picker
.hass=${this.hass}
.value=${this.value}
.value=${typeof this.value === "string" ? this.value : ""}
.label=${this.label}
.placeholder=${this.placeholder}
.helper=${this.helper}

View File

@@ -13,7 +13,11 @@ import {
} from "../../data/media-player";
import type { MediaSelector, MediaSelectorValue } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import { brandsUrl, extractDomainFromBrandUrl } from "../../util/brands-url";
import {
brandsUrl,
extractDomainFromBrandUrl,
isBrandUrl,
} from "../../util/brands-url";
import "../ha-alert";
import "../ha-form/ha-form";
import type { SchemaUnion } from "../ha-form/types";
@@ -72,16 +76,7 @@ export class HaMediaSelector extends LitElement {
if (thumbnail === oldThumbnail) {
return;
}
if (thumbnail && thumbnail.startsWith("/")) {
this._thumbnailUrl = undefined;
// Thumbnails served by local API require authentication
getSignedPath(this.hass, thumbnail).then((signedPath) => {
this._thumbnailUrl = signedPath.path;
});
} else if (
thumbnail &&
thumbnail.startsWith("https://brands.home-assistant.io")
) {
if (thumbnail && isBrandUrl(thumbnail)) {
// The backend is not aware of the theme used by the users,
// so we rewrite the URL to show a proper icon
this._thumbnailUrl = brandsUrl({
@@ -89,6 +84,12 @@ export class HaMediaSelector extends LitElement {
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
});
} else if (thumbnail && thumbnail.startsWith("/")) {
this._thumbnailUrl = undefined;
// Thumbnails served by local API require authentication
getSignedPath(this.hass, thumbnail).then((signedPath) => {
this._thumbnailUrl = signedPath.path;
});
} else {
this._thumbnailUrl = thumbnail;
}

View File

@@ -221,7 +221,7 @@ export class HaSelectSelector extends LitElement {
.disabled=${this.disabled}
.required=${this.required}
.getItems=${this._getItems(options)}
.value=${this.value as string | undefined}
.value=${typeof this.value === "string" ? this.value : undefined}
@value-changed=${this._comboBoxValueChanged}
allow-custom-value
></ha-generic-picker>
@@ -231,7 +231,7 @@ export class HaSelectSelector extends LitElement {
return html`
<ha-select
.label=${this.label ?? ""}
.value=${(this.value as string) ?? ""}
.value=${typeof this.value === "string" ? this.value : ""}
.helper=${this.helper ?? ""}
.disabled=${this.disabled}
.required=${this.required}

View File

@@ -64,6 +64,15 @@ const SELECTOR_SCHEMAS = {
name: "enable_millisecond",
selector: { boolean: {} },
},
{
name: "enable_second",
default: true,
selector: { boolean: {} },
},
{
name: "allow_negative",
selector: { boolean: {} },
},
] as const,
entity: [
{

View File

@@ -141,7 +141,7 @@ export class HaTextSelector extends LitElement {
right: 8px;
inset-inline-start: initial;
inset-inline-end: 8px;
--mdc-icon-button-size: 40px;
--ha-icon-button-size: 40px;
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
direction: var(--direction);

View File

@@ -144,6 +144,7 @@ export const computePanels = memoizeOne(
if (
!isDefaultPanel &&
(!panel.title ||
panel.show_in_sidebar === false ||
hiddenPanels.includes(panel.url_path) ||
(panel.default_visible === false &&
!panelsOrder.includes(panel.url_path)))

View File

@@ -1,242 +1,351 @@
import { TextFieldBase } from "@material/mwc-textfield/mwc-textfield-base";
import { styles } from "@material/mwc-textfield/mwc-textfield.css";
import type { TemplateResult, PropertyValues } from "lit";
import { html, css } from "lit";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { mainWindow } from "../common/dom/get_main_window";
import "./ha-input";
import type { HaInput } from "./ha-input";
/**
* Legacy wrapper around ha-input that preserves the mwc-textfield API.
* New code should use ha-input directly.
* @deprecated Use ha-input instead.
*/
@customElement("ha-textfield")
export class HaTextField extends TextFieldBase {
@property({ type: Boolean }) public invalid?: boolean;
export class HaTextField extends LitElement {
@property({ type: String })
public value = "";
@property({ attribute: "error-message" }) public errorMessage?: string;
@property({ type: String })
public type:
| "text"
| "search"
| "tel"
| "url"
| "email"
| "password"
| "date"
| "month"
| "week"
| "time"
| "datetime-local"
| "number"
| "color" = "text";
@property({ type: String })
public label = "";
@property({ type: String })
public placeholder = "";
@property({ type: String })
public prefix = "";
@property({ type: String })
public suffix = "";
@property({ type: Boolean })
// @ts-ignore
@property({ type: Boolean }) public icon = false;
public icon = false;
@property({ type: Boolean })
// @ts-ignore
// eslint-disable-next-line lit/attribute-names
@property({ type: Boolean }) public iconTrailing = false;
public iconTrailing = false;
@property() public autocomplete?: string;
@property({ type: Boolean })
public disabled = false;
@property({ type: Boolean }) public autocorrect = true;
@property({ type: Boolean })
public required = false;
@property({ type: Number, attribute: "minlength" })
public minLength = -1;
@property({ type: Number, attribute: "maxlength" })
public maxLength = -1;
@property({ type: Boolean, reflect: true })
public outlined = false;
@property({ type: String })
public helper = "";
@property({ type: Boolean, attribute: "validateoninitialrender" })
public validateOnInitialRender = false;
@property({ type: String, attribute: "validationmessage" })
public validationMessage = "";
@property({ type: Boolean, attribute: "autovalidate" })
public autoValidate = false;
@property({ type: String })
public pattern = "";
@property()
public min: number | string = "";
@property()
public max: number | string = "";
@property()
public step: number | "any" | null = null;
@property({ type: Number })
public size: number | null = null;
@property({ type: Boolean, attribute: "helperpersistent" })
public helperPersistent = false;
@property({ attribute: "charcounter" })
public charCounter: boolean | "external" | "internal" = false;
@property({ type: Boolean, attribute: "endaligned" })
public endAligned = false;
@property({ type: String, attribute: "inputmode" })
public inputMode = "";
@property({ type: Boolean, reflect: true, attribute: "readonly" })
public readOnly = false;
@property({ type: String })
public name = "";
@property({ type: String })
// eslint-disable-next-line lit/no-native-attributes
public autocapitalize = "";
// --- ha-textfield-specific properties ---
@property({ type: Boolean })
public invalid = false;
@property({ attribute: "error-message" })
public errorMessage?: string;
@property()
public autocomplete?: string;
@property({ type: Boolean })
public autocorrect = true;
@property({ attribute: "input-spellcheck" })
public inputSpellcheck?: string;
@query("input") public formElement!: HTMLInputElement;
@query("ha-input")
private _haInput?: HaInput;
override updated(changedProperties: PropertyValues) {
static shadowRootOptions: ShadowRootInit = {
mode: "open",
delegatesFocus: true,
};
public get formElement(): HTMLInputElement | undefined {
return (this._haInput as any)?._input?.input;
}
public select(): void {
this._haInput?.select();
}
public setSelectionRange(
selectionStart: number,
selectionEnd: number,
selectionDirection?: "forward" | "backward" | "none"
): void {
this._haInput?.setSelectionRange(
selectionStart,
selectionEnd,
selectionDirection
);
}
public setRangeText(
replacement: string,
start?: number,
end?: number,
selectMode?: "select" | "start" | "end" | "preserve"
): void {
this._haInput?.setRangeText(replacement, start, end, selectMode);
}
public checkValidity(): boolean {
return this._haInput?.checkValidity() ?? true;
}
public reportValidity(): boolean {
return this._haInput?.reportValidity() ?? true;
}
public setCustomValidity(message: string): void {
this.validationMessage = message;
this.invalid = !!message;
}
/** No-op. Preserved for backward compatibility. */
public layout(): void {
// no-op — mwc-textfield needed this for notched outline recalculation
}
protected override firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties);
if (this.validateOnInitialRender) {
this.reportValidity();
}
}
protected override updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (
changedProperties.has("invalid") ||
changedProperties.has("errorMessage")
) {
this.setCustomValidity(
this.invalid
? this.errorMessage || this.validationMessage || "Invalid"
: ""
);
if (changedProperties.has("invalid") && this._haInput) {
if (
this.invalid ||
this.validateOnInitialRender ||
(changedProperties.has("invalid") &&
changedProperties.get("invalid") !== undefined)
(changedProperties.get("invalid") !== undefined && !this.invalid)
) {
// Only report validity if the field is invalid or the invalid state has changed from
// true to false to prevent setting empty required fields to invalid on first render
this.reportValidity();
}
}
if (changedProperties.has("autocomplete")) {
if (this.autocomplete) {
this.formElement.setAttribute("autocomplete", this.autocomplete);
} else {
this.formElement.removeAttribute("autocomplete");
}
}
if (changedProperties.has("autocorrect")) {
if (this.autocorrect === false) {
this.formElement.setAttribute("autocorrect", "off");
} else {
this.formElement.removeAttribute("autocorrect");
}
}
if (changedProperties.has("inputSpellcheck")) {
if (this.inputSpellcheck) {
this.formElement.setAttribute("spellcheck", this.inputSpellcheck);
} else {
this.formElement.removeAttribute("spellcheck");
}
}
private _mapType(
type: string
):
| "text"
| "search"
| "tel"
| "url"
| "email"
| "password"
| "date"
| "datetime-local"
| "number"
| "time" {
// mwc-textfield supports "color", "month", "week" which ha-input doesn't
switch (type) {
case "text":
case "search":
case "tel":
case "url":
case "email":
case "password":
case "date":
case "datetime-local":
case "number":
case "time":
return type;
default:
return "text";
}
}
protected override renderIcon(
_icon: string,
isTrailingIcon = false
): TemplateResult {
const type = isTrailingIcon ? "trailing" : "leading";
protected override render(): TemplateResult {
const errorMsg = this.errorMessage || this.validationMessage;
return html`
<span
class="mdc-text-field__icon mdc-text-field__icon--${type}"
tabindex=${isTrailingIcon ? 1 : -1}
<ha-input
.type=${this._mapType(this.type)}
.value=${this.value || undefined}
.label=${this.label}
.placeholder=${this.placeholder}
.disabled=${this.disabled}
.required=${this.required}
.readonly=${this.readOnly}
.pattern=${this.pattern || undefined}
.minlength=${this.minLength > 0 ? this.minLength : undefined}
.maxlength=${this.maxLength > 0 ? this.maxLength : undefined}
.min=${this.min !== "" ? this.min : undefined}
.max=${this.max !== "" ? this.max : undefined}
.step=${this.step ?? undefined}
.name=${this.name || undefined}
.autocomplete=${this.autocomplete}
.autocorrect=${this.autocorrect}
.spellcheck=${this.inputSpellcheck === "true"}
.inputmode=${this._mapInputMode(this.inputMode)}
.autocapitalize=${this.autocapitalize || ""}
.invalid=${this.invalid}
.validationMessage=${errorMsg || ""}
.autoValidate=${this.autoValidate}
.hint=${this.helper}
.withoutSpinButtons=${this.type === "number"}
@input=${this._onInput}
@change=${this._onChange}
>
<slot name="${type}Icon"></slot>
</span>
${this.icon
? html`<slot name="leadingIcon" slot="start"></slot>`
: nothing}
${this.prefix
? html`<span class="prefix" slot="start">${this.prefix}</span>`
: nothing}
${this.suffix
? html`<span class="suffix" slot="end">${this.suffix}</span>`
: nothing}
${this.iconTrailing
? html`<slot name="trailingIcon" slot="end"></slot>`
: nothing}
</ha-input>
`;
}
static override styles = [
styles,
css`
.mdc-text-field__input {
width: var(--ha-textfield-input-width, 100%);
}
.mdc-text-field:not(.mdc-text-field--with-leading-icon) {
padding: var(--text-field-padding, 0px 16px);
}
.mdc-text-field__affix--suffix {
padding-left: var(--text-field-suffix-padding-left, 12px);
padding-right: var(--text-field-suffix-padding-right, 0px);
padding-inline-start: var(--text-field-suffix-padding-left, 12px);
padding-inline-end: var(--text-field-suffix-padding-right, 0px);
direction: ltr;
}
.mdc-text-field--with-leading-icon {
padding-inline-start: var(--text-field-suffix-padding-left, 0px);
padding-inline-end: var(--text-field-suffix-padding-right, 16px);
direction: var(--direction);
}
private _mapInputMode(
mode: string
):
| "none"
| "text"
| "decimal"
| "numeric"
| "tel"
| "search"
| "email"
| "url"
| "" {
switch (mode) {
case "none":
case "text":
case "decimal":
case "numeric":
case "tel":
case "search":
case "email":
case "url":
return mode;
default:
return "";
}
}
.mdc-text-field--with-leading-icon.mdc-text-field--with-trailing-icon {
padding-left: var(--text-field-suffix-padding-left, 0px);
padding-right: var(--text-field-suffix-padding-right, 0px);
padding-inline-start: var(--text-field-suffix-padding-left, 0px);
padding-inline-end: var(--text-field-suffix-padding-right, 0px);
}
.mdc-text-field:not(.mdc-text-field--disabled)
.mdc-text-field__affix--suffix {
color: var(--secondary-text-color);
}
private _onInput(): void {
this.value = this._haInput?.value ?? "";
}
.mdc-text-field:not(.mdc-text-field--disabled) .mdc-text-field__icon {
color: var(--secondary-text-color);
}
private _onChange(): void {
this.value = this._haInput?.value ?? "";
}
.mdc-text-field__icon--leading {
margin-inline-start: 16px;
margin-inline-end: 8px;
direction: var(--direction);
}
static override styles = css`
:host {
display: inline-flex;
flex-direction: column;
outline: none;
}
.mdc-text-field__icon--trailing {
padding: var(--textfield-icon-trailing-padding, 12px);
}
ha-input {
--ha-input-padding-bottom: 0;
width: 100%;
}
.mdc-floating-label:not(.mdc-floating-label--float-above) {
max-width: calc(100% - 16px);
}
.prefix,
.suffix {
color: var(--secondary-text-color);
}
.mdc-floating-label--float-above {
max-width: calc((100% - 16px) / 0.75);
transition: none;
}
.prefix {
margin-inline-end: var(--text-field-prefix-padding-right);
}
input {
text-align: var(--text-field-text-align, start);
}
input[type="color"] {
height: 20px;
}
/* Edge, hide reveal password icon */
::-ms-reveal {
display: none;
}
/* Chrome, Safari, Edge, Opera */
:host([no-spinner]) input::-webkit-outer-spin-button,
:host([no-spinner]) input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="color"]::-webkit-color-swatch-wrapper {
padding: 0;
}
/* Firefox */
:host([no-spinner]) input[type="number"] {
-moz-appearance: textfield;
}
.mdc-text-field__ripple {
overflow: hidden;
}
.mdc-text-field {
overflow: var(--text-field-overflow);
}
.mdc-floating-label {
padding-inline-end: 16px;
padding-inline-start: initial;
inset-inline-start: 16px !important;
inset-inline-end: initial !important;
transform-origin: var(--float-start);
direction: var(--direction);
text-align: var(--float-start);
box-sizing: border-box;
text-overflow: ellipsis;
}
.mdc-text-field--with-leading-icon.mdc-text-field--filled
.mdc-floating-label {
max-width: calc(
100% - 48px - var(--text-field-suffix-padding-left, 0px)
);
inset-inline-start: calc(
48px + var(--text-field-suffix-padding-left, 0px)
) !important;
inset-inline-end: initial !important;
direction: var(--direction);
}
.mdc-text-field__input[type="number"] {
direction: var(--direction);
}
.mdc-text-field__affix--prefix {
padding-right: var(--text-field-prefix-padding-right, 2px);
padding-inline-end: var(--text-field-prefix-padding-right, 2px);
padding-inline-start: initial;
}
.mdc-text-field:not(.mdc-text-field--disabled)
.mdc-text-field__affix--prefix {
color: var(--mdc-text-field-label-ink-color);
}
#helper-text ha-markdown {
display: inline-block;
}
`,
// safari workaround - must be explicit
mainWindow.document.dir === "rtl"
? css`
.mdc-text-field--with-leading-icon,
.mdc-text-field__icon--leading,
.mdc-floating-label,
.mdc-text-field--with-leading-icon.mdc-text-field--filled
.mdc-floating-label,
.mdc-text-field__input[type="number"] {
direction: rtl;
--direction: rtl;
}
`
: css``,
];
/* Edge, hide reveal password icon */
::-ms-reveal {
display: none;
}
`;
}
declare global {

View File

@@ -0,0 +1,204 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import type { Segment } from "../data/vacuum";
import { getVacuumSegments } from "../data/vacuum";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-alert";
import "./ha-area-picker";
import "./ha-md-list";
import "./ha-md-list-item";
type AreaSegmentMapping = Record<string, string[]>; // area ID -> segment IDs
@customElement("ha-vacuum-segment-area-mapper")
export class HaVacuumSegmentAreaMapper extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "entity-id" }) public entityId!: string;
@property({ attribute: false }) public value?: AreaSegmentMapping;
@state() private _segments?: Segment[];
@state() private _loading = false;
@state() private _error?: string;
public get lastSeenSegments() {
return this._segments;
}
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("entityId") && this.entityId) {
this._loadSegments();
}
}
private async _loadSegments() {
this._loading = true;
this._error = undefined;
try {
const result = await getVacuumSegments(this.hass, this.entityId);
this._segments = result.segments;
} catch (err: any) {
this._error = err.message || "Failed to load segments";
this._segments = undefined;
} finally {
this._loading = false;
}
}
protected render() {
if (this._loading) {
return html`
<div class="loading">${this.hass.localize("ui.common.loading")}...</div>
`;
}
if (this._error) {
return html` <ha-alert alert-type="error">${this._error}</ha-alert> `;
}
if (!this._segments || this._segments.length === 0) {
return html`
<ha-alert alert-type="info">
${this.hass.localize("ui.dialogs.vacuum_segment_mapping.no_segments")}
</ha-alert>
`;
}
// Group segments by group (if available)
const groupedSegments = this._groupSegments(this._segments);
return html`
${Object.entries(groupedSegments).map(
([groupName, segments]) => html`
${groupName ? html`<h2>${groupName}</h2>` : nothing}
<ha-md-list>
${segments.map((segment) => this._renderSegment(segment))}
</ha-md-list>
`
)}
`;
}
private _groupSegments(segments: Segment[]): Record<string, Segment[]> {
const grouped: Record<string, Segment[]> = {};
for (const segment of segments) {
const group = segment.group || "";
if (!grouped[group]) {
grouped[group] = [];
}
grouped[group].push(segment);
}
return grouped;
}
private _renderSegment(segment: Segment) {
const mappedAreas = this._getSegmentAreas(segment.id);
return html`
<ha-md-list-item>
<span slot="headline">${segment.name}</span>
<ha-area-picker
slot="end"
.hass=${this.hass}
.value=${mappedAreas}
.label=${this.hass.localize(
"ui.dialogs.vacuum_segment_mapping.area_label"
)}
@value-changed=${this._handleAreaChanged}
data-segment-id=${segment.id}
></ha-area-picker>
</ha-md-list-item>
`;
}
private _handleAreaChanged = (ev: CustomEvent) => {
const target = ev.currentTarget as HTMLElement;
const segmentId = target.dataset.segmentId;
if (segmentId) {
this._areaChanged(segmentId, ev);
}
};
private _getSegmentAreas(segmentId: string): string | undefined {
if (!this.value) {
return undefined;
}
// Find which area(s) contain this segment
for (const [areaId, segmentIds] of Object.entries(this.value)) {
if (segmentIds.includes(segmentId)) {
return areaId;
}
}
return undefined;
}
private _areaChanged(segmentId: string, ev: CustomEvent) {
ev.stopPropagation();
const newAreaId = ev.detail.value as string | undefined;
// Create a copy of the current mapping
const newMapping: AreaSegmentMapping = { ...this.value };
// Remove segment from all areas
for (const areaId of Object.keys(newMapping)) {
newMapping[areaId] = newMapping[areaId].filter((id) => id !== segmentId);
// Remove empty area entries
if (newMapping[areaId].length === 0) {
delete newMapping[areaId];
}
}
// Add segment to new area if specified
if (newAreaId) {
if (!newMapping[newAreaId]) {
newMapping[newAreaId] = [];
}
newMapping[newAreaId].push(segmentId);
}
fireEvent(this, "value-changed", { value: newMapping });
}
static styles: CSSResultGroup = [
haStyle,
css`
:host {
display: block;
}
ha-area-picker {
flex: 1;
}
h2 {
margin: 0;
margin-inline-start: var(--ha-space-4);
}
.loading {
padding: var(--ha-space-4);
text-align: center;
color: var(--secondary-text-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-vacuum-segment-area-mapper": HaVacuumSegmentAreaMapper;
}
}

View File

@@ -423,31 +423,77 @@ export class HaMap extends ReactiveElement {
? baseOpacity! + pointIndex * opacityStep!
: undefined;
const thisPoint = path.points[pointIndex];
const nextPoint = path.points[pointIndex + 1];
// DRAW point
this._mapPaths.push(
Leaflet.circleMarker(path.points[pointIndex].point, {
Leaflet.circleMarker(thisPoint.point, {
radius: isTouch ? 8 : 3,
color: path.color || darkPrimaryColor,
opacity,
fillOpacity: opacity,
interactive: true,
}).bindTooltip(
this._computePathTooltip(path, path.points[pointIndex]),
{ direction: "top" }
)
}).bindTooltip(this._computePathTooltip(path, thisPoint), {
direction: "top",
})
);
// DRAW line between this and next point
this._mapPaths.push(
Leaflet.polyline(
[path.points[pointIndex].point, path.points[pointIndex + 1].point],
{
if (Math.abs(thisPoint.point[1] - nextPoint.point[1]) <= 180) {
// if the path does not cross the antimeridian, draw a simple line
// between the two points
this._mapPaths.push(
Leaflet.polyline([thisPoint.point, nextPoint.point], {
color: path.color || darkPrimaryColor,
opacity,
interactive: false,
}
)
);
})
);
} else {
// if the path crosses the antimeridian, split the line into two, to
// avoid it being drawn across the entire map
const longitudeDifference =
((nextPoint.point[1] - thisPoint.point[1] + 540) % 360) - 180;
let intersectionLatitude: number;
if (longitudeDifference === 0) {
// very, very unlikely edge case
intersectionLatitude =
(thisPoint.point[0] + nextPoint.point[0]) / 2;
} else {
intersectionLatitude =
thisPoint.point[0] +
((nextPoint.point[0] - thisPoint.point[0]) *
(thisPoint.point[1] > 0
? 180 - thisPoint.point[1]
: -180 - thisPoint.point[1])) /
longitudeDifference;
}
const intersectionPoint1: LatLngTuple = [
intersectionLatitude,
thisPoint.point[1] > 0 ? 180 : -180,
];
const intersectionPoint2: LatLngTuple = [
intersectionLatitude,
nextPoint.point[1] > 0 ? 180 : -180,
];
this._mapPaths.push(
Leaflet.polyline([thisPoint.point, intersectionPoint1], {
color: path.color || darkPrimaryColor,
opacity,
interactive: false,
})
);
this._mapPaths.push(
Leaflet.polyline([intersectionPoint2, nextPoint.point], {
color: path.color || darkPrimaryColor,
opacity,
interactive: false,
})
);
}
}
const pointIndex = path.points.length - 1;
if (pointIndex >= 0) {

View File

@@ -242,6 +242,10 @@ class BrowseMediaTTS extends LitElement {
margin-top: 16px;
display: flex;
justify-content: space-between;
gap: var(--ha-space-2);
}
ha-language-picker {
width: 100%;
}
ha-textarea {
width: 100%;
@@ -260,7 +264,7 @@ class BrowseMediaTTS extends LitElement {
}
.footer {
--mdc-icon-size: 14px;
--mdc-icon-button-size: 24px;
--ha-icon-button-size: 24px;
display: flex;
justify-content: center;
align-items: center;

View File

@@ -36,7 +36,7 @@ import {
} from "../../data/media_source";
import { isTTSMediaSource } from "../../data/tts";
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../resources/styles";
import { haStyle, haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types";
import {
@@ -584,7 +584,7 @@ export class HaMediaPlayerBrowse extends LitElement {
})}
.items=${children}
.renderItem=${this._renderGridItem}
class="children ${classMap({
class="children ha-scrollbar ${classMap({
portrait:
childrenMediaClass.thumbnail_ratio ===
"portrait",
@@ -612,6 +612,7 @@ export class HaMediaPlayerBrowse extends LitElement {
style=${styleMap({
height: `${children.length * 72 + 26}px`,
})}
class="ha-scrollbar"
.renderItem=${this._renderListItem}
></lit-virtualizer>
${currentItem.not_shown
@@ -764,6 +765,16 @@ export class HaMediaPlayerBrowse extends LitElement {
return "";
}
if (isBrandUrl(thumbnailUrl)) {
// The backend is not aware of the theme used by the users,
// so we rewrite the URL to show a proper icon
return brandsUrl({
domain: extractDomainFromBrandUrl(thumbnailUrl),
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
});
}
if (thumbnailUrl.startsWith("/")) {
// Thumbnails served by local API require authentication
return new Promise((resolve, reject) => {
@@ -786,16 +797,6 @@ export class HaMediaPlayerBrowse extends LitElement {
});
}
if (isBrandUrl(thumbnailUrl)) {
// The backend is not aware of the theme used by the users,
// so we rewrite the URL to show a proper icon
thumbnailUrl = brandsUrl({
domain: extractDomainFromBrandUrl(thumbnailUrl),
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
});
}
return thumbnailUrl;
}
@@ -979,6 +980,7 @@ export class HaMediaPlayerBrowse extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleScrollbar,
css`
:host {
display: flex;
@@ -1232,7 +1234,7 @@ export class HaMediaPlayerBrowse extends LitElement {
}
.child .play:not(.can_expand) {
--mdc-icon-button-size: 70px;
--ha-icon-button-size: 70px;
--mdc-icon-size: 48px;
background-color: var(--primary-color);
color: var(--text-primary-color);
@@ -1293,7 +1295,7 @@ export class HaMediaPlayerBrowse extends LitElement {
transition: all 0.5s;
background-color: rgba(var(--rgb-card-background-color), 0.5);
border-radius: var(--ha-border-radius-circle);
--mdc-icon-button-size: 40px;
--ha-icon-button-size: 40px;
}
ha-list-item:hover .graphic .play {

View File

@@ -93,8 +93,8 @@ class SearchInputOutlined extends LitElement {
}
ha-svg-icon,
ha-icon-button {
--mdc-icon-button-size: 24px;
height: var(--mdc-icon-button-size);
--ha-icon-button-size: 24px;
height: var(--ha-icon-button-size);
display: flex;
color: var(--primary-text-color);
}

View File

@@ -641,7 +641,7 @@ export class HaTargetPickerItemRow extends LitElement {
z-index: 1;
}
ha-icon-button {
--mdc-icon-button-size: 32px;
--ha-icon-button-size: 32px;
}
.summary {
display: flex;

View File

@@ -247,7 +247,7 @@ export class HaTargetPickerValueChip extends LitElement {
cursor: default;
}
.mdc-chip ha-icon-button {
--mdc-icon-button-size: 24px;
--ha-icon-button-size: 24px;
display: flex;
align-items: center;
outline: none;

View File

@@ -159,6 +159,9 @@ export interface GasSourceTypeEnergyPreference {
// kWh/volume meter
stat_energy_from: string;
// Flow rate (m³/h, L/min, etc.)
stat_rate?: string;
// $ meter
stat_cost: string | null;
@@ -174,6 +177,9 @@ export interface WaterSourceTypeEnergyPreference {
// volume meter
stat_energy_from: string;
// Flow rate (L/min, gal/min, m³/h, etc.)
stat_rate?: string;
// $ meter
stat_cost: string | null;
@@ -368,6 +374,9 @@ export const getReferencedStatisticIdsPower = (
for (const source of prefs.energy_sources) {
if (source.type === "gas" || source.type === "water") {
if (source.stat_rate) {
statIDs.push(source.stat_rate);
}
continue;
}
@@ -389,6 +398,7 @@ export const getReferencedStatisticIdsPower = (
}
}
statIDs.push(...prefs.device_consumption.map((d) => d.stat_rate));
statIDs.push(...prefs.device_consumption_water.map((d) => d.stat_rate));
return statIDs.filter(Boolean) as string[];
};
@@ -1391,6 +1401,80 @@ export const calculateSolarConsumedGauge = (
return undefined;
};
/**
* Conversion factors from each flow rate unit to L/min.
* All HA-supported UnitOfVolumeFlowRate values are covered.
*
* m³/h → 1000/60 = 16.6667 L/min
* m³/min → 1000 L/min
* m³/s → 60000 L/min
* ft³/min→ 28.3168 L/min
* L/h → 1/60 L/min
* L/min → 1 L/min
* L/s → 60 L/min
* gal/h → 3.78541/60 L/min
* gal/min→ 3.78541 L/min
* gal/d → 3.78541/1440 L/min
* mL/s → 0.06 L/min
*/
/** Exact number of liters in one US gallon */
const LITERS_PER_GALLON = 3.785411784;
const FLOW_RATE_TO_LMIN: Record<string, number> = {
"m³/h": 1000 / 60,
"m³/min": 1000,
"m³/s": 60000,
"ft³/min": 28.316846592,
"L/h": 1 / 60,
"L/min": 1,
"L/s": 60,
"gal/h": LITERS_PER_GALLON / 60,
"gal/min": LITERS_PER_GALLON,
"gal/d": LITERS_PER_GALLON / 1440,
"mL/s": 60 / 1000,
};
/**
* Get current flow rate from an entity state, converted to L/min.
* @returns Flow rate in L/min, or undefined if unavailable/invalid.
*/
export const getFlowRateFromState = (
stateObj?: HassEntity
): number | undefined => {
if (!stateObj) {
return undefined;
}
const value = parseFloat(stateObj.state);
if (isNaN(value)) {
return undefined;
}
const unit = stateObj.attributes.unit_of_measurement;
const factor = unit ? FLOW_RATE_TO_LMIN[unit] : undefined;
if (factor === undefined) {
// Unknown unit return raw value as-is (best effort)
return value;
}
return value * factor;
};
/**
* Format a flow rate value (in L/min) to a human-readable string using
* the preferred unit system: metric → L/min, imperial → gal/min.
*/
export const formatFlowRateShort = (
hassLocale: HomeAssistant["locale"],
lengthUnitSystem: string,
litersPerMin: number
): string => {
const isMetric = lengthUnitSystem === "km";
if (isMetric) {
return `${formatNumber(litersPerMin, hassLocale, { maximumFractionDigits: 1 })} L/min`;
}
const galPerMin = litersPerMin / LITERS_PER_GALLON;
return `${formatNumber(galPerMin, hassLocale, { maximumFractionDigits: 1 })} gal/min`;
};
/**
* Get current power value from entity state, normalized to watts (W)
* @param stateObj - The entity state object to get power value from

View File

@@ -3,7 +3,6 @@ import { formatDurationDigital } from "../../common/datetime/format_duration";
import type { FrontendLocaleData } from "../translation";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
// These attributes are hidden from the more-info window for all entities.
export const STATE_ATTRIBUTES = [
"entity_id",
"assumed_state",
@@ -29,8 +28,6 @@ export const STATE_ATTRIBUTES = [
"available_tones",
];
// These attributes are hidden from the more-info window for entities of the
// matching domain and device_class.
export const STATE_ATTRIBUTES_DOMAIN_CLASS = {
sensor: {
enum: ["options"],

View File

@@ -9,6 +9,7 @@ import { debounce } from "../../common/util/debounce";
import type { HomeAssistant } from "../../types";
import type { LightColor } from "../light";
import type { RegistryEntry } from "../registry";
import type { Segment } from "../vacuum";
type EntityCategory = "config" | "diagnostic";
@@ -120,6 +121,11 @@ export interface SwitchAsXEntityOptions {
invert: boolean;
}
export interface VacuumEntityOptions {
area_mapping?: Record<string, string[]>;
last_seen_segments?: Segment[];
}
export interface EntityRegistryOptions {
number?: NumberEntityOptions;
sensor?: SensorEntityOptions;
@@ -128,6 +134,7 @@ export interface EntityRegistryOptions {
lock?: LockEntityOptions;
weather?: WeatherEntityOptions;
light?: LightEntityOptions;
vacuum?: VacuumEntityOptions;
switch_as_x?: SwitchAsXEntityOptions;
conversation?: Record<string, unknown>;
"cloud.alexa"?: Record<string, unknown>;
@@ -150,7 +157,8 @@ export interface EntityRegistryEntryUpdateParams {
| AlarmControlPanelEntityOptions
| CalendarEntityOptions
| WeatherEntityOptions
| LightEntityOptions;
| LightEntityOptions
| VacuumEntityOptions;
aliases?: string[];
labels?: string[];
categories?: Record<string, string | null>;

View File

@@ -37,6 +37,11 @@ export interface LovelaceViewHeaderConfig {
badges_wrap?: "wrap" | "scroll";
}
export interface LovelaceViewFooterConfig {
card?: LovelaceCardConfig;
column_span?: number;
}
export interface LovelaceViewSidebarConfig {
sections?: LovelaceSectionConfig[];
content_label?: string;
@@ -68,6 +73,7 @@ export interface LovelaceViewConfig extends LovelaceBaseViewConfig {
cards?: LovelaceCardConfig[];
sections?: LovelaceSectionRawConfig[];
header?: LovelaceViewHeaderConfig;
footer?: LovelaceViewFooterConfig;
// Only used for section view, it should move to a section view config type when the views will have dedicated editor.
sidebar?: LovelaceViewSidebarConfig;
}

View File

@@ -8,12 +8,17 @@ import {
mdiPlayBoxMultiple,
mdiTooltipAccount,
} from "@mdi/js";
import type { HomeAssistant, PanelInfo } from "../types";
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
import type { LocalizeKeys } from "../common/translations/localize";
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
import type { HomeAssistant, PanelInfo } from "../types";
export const HOME_PANEL = "home";
export const NOT_FOUND_PANEL = "notfound";
export const PROFILE_PANEL = "profile";
export const LOVELACE_PANEL = "lovelace";
/** Panel to show when no panel is picked. */
export const DEFAULT_PANEL = "home";
export const DEFAULT_PANEL = HOME_PANEL;
export const hasLegacyOverviewPanel = (hass: HomeAssistant): boolean =>
Boolean(hass.panels.lovelace?.config);
@@ -30,7 +35,7 @@ export const getDefaultPanelUrlPath = (hass: HomeAssistant): string => {
getLegacyDefaultPanelUrlPath() ||
DEFAULT_PANEL;
// If default panel is lovelace and no old overview exists, fall back to home
if (defaultPanel === "lovelace" && !hasLegacyOverviewPanel(hass)) {
if (defaultPanel === LOVELACE_PANEL && !hasLegacyOverviewPanel(hass)) {
return DEFAULT_PANEL;
}
return defaultPanel;
@@ -39,12 +44,16 @@ export const getDefaultPanelUrlPath = (hass: HomeAssistant): string => {
export const getDefaultPanel = (hass: HomeAssistant): PanelInfo => {
const panel = getDefaultPanelUrlPath(hass);
return (panel ? hass.panels[panel] : undefined) ?? hass.panels[DEFAULT_PANEL];
return (
(panel ? hass.panels[panel] : undefined) ??
hass.panels[DEFAULT_PANEL] ??
hass.panels[NOT_FOUND_PANEL]
);
};
export const getPanelNameTranslationKey = (panel: PanelInfo) => {
if (panel.url_path === "profile") {
return "panel.profile" as const;
if ([PROFILE_PANEL, NOT_FOUND_PANEL].includes(panel.url_path)) {
return `panel.${panel.url_path}` as const;
}
return `panel.${panel.title}` as const;
@@ -137,4 +146,22 @@ export const PANEL_ICON_PATHS = {
export const getPanelIconPath = (panel: PanelInfo): string | undefined =>
PANEL_ICON_PATHS[panel.url_path];
export const FIXED_PANELS = ["profile", "config"];
export const FIXED_PANELS = [PROFILE_PANEL, "config", NOT_FOUND_PANEL];
export interface PanelMutableParams {
title?: string | null;
icon?: string | null;
require_admin?: boolean | null;
show_in_sidebar?: boolean | null;
}
export const updatePanel = (
hass: HomeAssistant,
urlPath: string,
updates: PanelMutableParams
) =>
hass.callWS({
type: "frontend/update_panel",
url_path: urlPath,
...updates,
});

View File

@@ -231,6 +231,7 @@ export interface DurationSelector {
enable_day?: boolean;
enable_millisecond?: boolean;
allow_negative?: boolean;
enable_second?: boolean;
} | null;
}

View File

@@ -2,6 +2,7 @@ import type {
HassEntityAttributeBase,
HassEntityBase,
} from "home-assistant-js-websocket";
import type { HomeAssistant } from "../types";
import { UNAVAILABLE } from "./entity/entity";
export type VacuumEntityState =
@@ -29,6 +30,7 @@ export const enum VacuumEntityFeature {
MAP = 2048,
STATE = 4096,
START = 8192,
CLEAN_AREA = 16384,
}
interface VacuumEntityAttributes extends HassEntityAttributeBase {
@@ -62,3 +64,18 @@ export function canReturnHome(stateObj: VacuumEntity): boolean {
}
return stateObj.state !== "returning";
}
export interface Segment {
id: string;
name: string;
group?: string;
}
export const getVacuumSegments = (
hass: HomeAssistant,
entity_id: string
): Promise<{ segments: Segment[] }> =>
hass.callWS({
type: "vacuum/get_segments",
entity_id,
});

View File

@@ -468,13 +468,13 @@ class LightRgbColorPicker extends LitElement {
border: none;
outline: none;
display: block;
width: var(--mdc-icon-button-size, 48px);
height: var(--mdc-icon-button-size, 48px);
width: var(--ha-icon-button-size, 48px);
height: var(--ha-icon-button-size, 48px);
padding: calc(
(var(--mdc-icon-button-size, 48px) - var(--mdc-icon-size, 24px)) / 2
(var(--ha-icon-button-size, 48px) - var(--mdc-icon-size, 24px)) / 2
);
background-color: transparent;
border-radius: calc(var(--mdc-icon-button-size, 48px) / 2);
border-radius: calc(var(--ha-icon-button-size, 48px) / 2);
overflow: hidden;
cursor: pointer;
transition: background-color 180ms ease-in-out;

View File

@@ -0,0 +1,153 @@
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../components/ha-button";
import "../../../../components/ha-spinner";
import "../../../../components/ha-vacuum-segment-area-mapper";
import type { HaVacuumSegmentAreaMapper } from "../../../../components/ha-vacuum-segment-area-mapper";
import type {
ExtEntityRegistryEntry,
VacuumEntityOptions,
} from "../../../../data/entity/entity_registry";
import {
getExtendedEntityRegistryEntry,
updateEntityRegistryEntry,
} from "../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../types";
@customElement("ha-more-info-view-vacuum-segment-mapping")
export class HaMoreInfoViewVacuumSegmentMapping extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public params!: { entityId: string };
@state() private _areaMapping?: Record<string, string[]>;
@state() private _submitting = false;
@state() private _dirty = false;
@state() private _error?: string;
private _entry?: ExtEntityRegistryEntry;
protected firstUpdated() {
this._loadCurrentMapping();
}
private async _loadCurrentMapping() {
if (!this.params.entityId) return;
this._entry = await getExtendedEntityRegistryEntry(
this.hass,
this.params.entityId
);
if (this._entry?.options?.vacuum) {
this._areaMapping = this._entry.options.vacuum.area_mapping || {};
} else {
this._areaMapping = {};
}
}
private _valueChanged(ev: CustomEvent) {
this._areaMapping = ev.detail.value;
this._dirty = true;
}
private async _save() {
if (!this.params.entityId || !this._areaMapping) return;
this._error = undefined;
this._submitting = true;
// Get current segments from the mapper component
const mapper = this.shadowRoot!.querySelector(
"ha-vacuum-segment-area-mapper"
) as HaVacuumSegmentAreaMapper;
const options: VacuumEntityOptions = {
...(this._entry?.options?.vacuum ?? {}),
area_mapping: this._areaMapping,
last_seen_segments: mapper.lastSeenSegments,
};
try {
await updateEntityRegistryEntry(this.hass, this.params.entityId, {
options_domain: "vacuum",
options: options,
});
this._dirty = false;
} catch (err: any) {
this._error = err.message;
} finally {
this._submitting = false;
}
}
protected render() {
if (!this._areaMapping) {
return html`<ha-spinner active></ha-spinner>`;
}
return html`
<div class="content">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<ha-vacuum-segment-area-mapper
.hass=${this.hass}
.entityId=${this.params.entityId}
.value=${this._areaMapping}
@value-changed=${this._valueChanged}
></ha-vacuum-segment-area-mapper>
<div class="footer">
<ha-button
@click=${this._save}
.disabled=${!this._dirty || this._submitting}
>
${this.hass.localize("ui.common.save")}
</ha-button>
</div>
</div>
`;
}
static styles: CSSResultGroup = css`
:host {
display: block;
height: 100%;
}
.content {
display: flex;
flex-direction: column;
height: 100%;
}
ha-spinner {
margin: var(--ha-space-8);
display: flex;
justify-self: center;
}
ha-vacuum-segment-area-mapper {
flex: 1;
padding-inline-start: var(--ha-space-2);
}
.footer {
display: flex;
justify-content: flex-end;
padding: var(--ha-space-4);
border-top: 1px solid var(--divider-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-view-vacuum-segment-mapping": HaMoreInfoViewVacuumSegmentMapping;
}
}

View File

@@ -0,0 +1,18 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
export const loadVacuumSegmentMappingView = () =>
import("./ha-more-info-view-vacuum-segment-mapping");
export const showVacuumSegmentMappingView = (
element: HTMLElement,
localize: LocalizeFunc,
entityId: string
): void => {
fireEvent(element, "show-child-view", {
viewTag: "ha-more-info-view-vacuum-segment-mapping",
viewImport: loadVacuumSegmentMappingView,
viewTitle: localize("ui.dialogs.vacuum_segment_mapping.title"),
viewParams: { entityId },
});
};

View File

@@ -599,7 +599,7 @@ class MoreInfoMediaPlayer extends LitElement {
}
.volume ha-icon-button {
--mdc-icon-button-size: 32px;
--ha-icon-button-size: 32px;
--mdc-icon-size: 16px;
}

View File

@@ -1,136 +0,0 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeAttributeNameDisplay } from "../../common/entity/compute_attribute_display";
import "../../components/ha-attribute-value";
import "../../components/ha-card";
import { computeShownAttributes } from "../../data/entity/entity_attributes";
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
import type { HomeAssistant } from "../../types";
interface AttributesViewParams {
entityId: string;
}
@customElement("ha-more-info-attributes")
class HaMoreInfoAttributes extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entry?: ExtEntityRegistryEntry | null;
@property({ attribute: false }) public params?: AttributesViewParams;
@state() private _stateObj?: HassEntity;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("params") || changedProps.has("hass")) {
if (this.params?.entityId && this.hass) {
this._stateObj = this.hass.states[this.params.entityId];
}
}
}
protected render() {
if (!this.params || !this._stateObj) {
return nothing;
}
const attributes = computeShownAttributes(this._stateObj);
return html`
<div class="content">
<ha-card>
<div class="card-content">
${attributes.map(
(attribute) => html`
<div class="data-entry">
<div class="key">
${computeAttributeNameDisplay(
this.hass.localize,
this._stateObj!,
this.hass.entities,
attribute
)}
</div>
<div class="value">
<ha-attribute-value
.hass=${this.hass}
.attribute=${attribute}
.stateObj=${this._stateObj}
></ha-attribute-value>
</div>
</div>
`
)}
</div>
</ha-card>
${this._stateObj.attributes.attribution
? html`
<div class="attribution">
${this._stateObj.attributes.attribution}
</div>
`
: nothing}
</div>
`;
}
static styles: CSSResultGroup = css`
:host {
display: flex;
flex-direction: column;
flex: 1;
}
.content {
padding: var(--ha-space-6);
padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-6));
}
ha-card {
direction: ltr;
}
.card-content {
padding: var(--ha-space-2) var(--ha-space-4);
}
.data-entry {
display: flex;
flex-direction: row;
justify-content: space-between;
padding: var(--ha-space-2) 0;
border-bottom: 1px solid var(--divider-color);
}
.data-entry:last-of-type {
border-bottom: none;
}
.data-entry .value {
max-width: 60%;
overflow-wrap: break-word;
text-align: right;
}
.key {
flex-grow: 1;
color: var(--secondary-text-color);
}
.attribution {
color: var(--secondary-text-color);
text-align: center;
margin-top: var(--ha-space-4);
font-size: var(--ha-font-size-s);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-attributes": HaMoreInfoAttributes;
}
}

View File

@@ -0,0 +1,189 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeAttributeNameDisplay } from "../../common/entity/compute_attribute_display";
import "../../components/ha-attribute-value";
import "../../components/ha-card";
import { computeShownAttributes } from "../../data/entity/entity_attributes";
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
import type { HomeAssistant } from "../../types";
interface DetailsViewParams {
entityId: string;
}
@customElement("ha-more-info-details")
class HaMoreInfoDetails extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entry?: ExtEntityRegistryEntry | null;
@property({ attribute: false }) public params?: DetailsViewParams;
@state() private _stateObj?: HassEntity;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("params") || changedProps.has("hass")) {
if (this.params?.entityId && this.hass) {
this._stateObj = this.hass.states[this.params.entityId];
}
}
}
protected render() {
if (!this.params || !this._stateObj) {
return nothing;
}
const translatedState = this.hass.formatEntityState(this._stateObj);
const detailsAttributes = computeShownAttributes(this._stateObj);
const detailsAttributeSet = new Set(detailsAttributes);
const builtInAttributes = Object.keys(this._stateObj.attributes).filter(
(attribute) => !detailsAttributeSet.has(attribute)
);
const allAttributes = [...detailsAttributes, ...builtInAttributes];
return html`
<div class="content">
<section class="section">
<h2 class="section-title">
${this.hass.localize(
"ui.components.entity.entity-state-picker.state"
)}
</h2>
<ha-card>
<div class="card-content">
<div class="attribute-group">
<div class="data-entry">
<div class="key">
${this.hass.localize(
"ui.dialogs.more_info_control.translated"
)}
</div>
<div class="value">${translatedState}</div>
</div>
<div class="data-entry">
<div class="key">
${this.hass.localize("ui.dialogs.more_info_control.raw")}
</div>
<div class="value">${this._stateObj.state}</div>
</div>
</div>
</div>
</ha-card>
</section>
<section class="section">
<h2 class="section-title">
${this.hass.localize("ui.dialogs.more_info_control.attributes")}
</h2>
<ha-card>
<div class="card-content">
<div class="attribute-group">
${this._renderAttributes(allAttributes)}
</div>
</div>
</ha-card>
</section>
</div>
`;
}
private _renderAttributes(attributes: string[]) {
if (attributes.length === 0) {
return html`<div class="empty">
${this.hass.localize("ui.common.none")}
</div>`;
}
return attributes.map(
(attribute) => html`
<div class="data-entry">
<div class="key">
${computeAttributeNameDisplay(
this.hass.localize,
this._stateObj!,
this.hass.entities,
attribute
)}
</div>
<div class="value">
<ha-attribute-value
.hass=${this.hass}
.attribute=${attribute}
.stateObj=${this._stateObj}
></ha-attribute-value>
</div>
</div>
`
);
}
static styles: CSSResultGroup = css`
:host {
display: flex;
flex-direction: column;
flex: 1;
}
.content {
padding: var(--ha-space-6);
padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-6));
}
.section + .section {
margin-top: var(--ha-space-4);
}
.section-title {
margin: 0 0 var(--ha-space-2);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
}
ha-card {
direction: ltr;
}
.card-content {
padding: var(--ha-space-2) var(--ha-space-4);
}
.data-entry {
display: flex;
flex-direction: row;
justify-content: space-between;
padding: var(--ha-space-2) 0;
border-bottom: 1px solid var(--divider-color);
}
.attribute-group .data-entry:last-of-type {
border-bottom: none;
}
.data-entry .value {
max-width: 60%;
overflow-wrap: break-word;
text-align: right;
}
.key {
flex-grow: 1;
color: var(--secondary-text-color);
}
.empty {
color: var(--secondary-text-color);
text-align: center;
padding: var(--ha-space-2) 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-details": HaMoreInfoDetails;
}
}

View File

@@ -44,7 +44,6 @@ import "../../components/ha-dropdown-item";
import "../../components/ha-icon-button";
import "../../components/ha-icon-button-prev";
import "../../components/ha-related-items";
import { computeShownAttributes } from "../../data/entity/entity_attributes";
import type {
EntityRegistryEntry,
ExtEntityRegistryEntry,
@@ -344,31 +343,21 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
case "info":
this._resetInitialView();
break;
case "attributes":
this._showAttributes();
case "details":
this._showDetails();
break;
}
}
private _showAttributes(): void {
import("./ha-more-info-attributes");
private _showDetails(): void {
import("./ha-more-info-details");
this._childView = {
viewTag: "ha-more-info-attributes",
viewTag: "ha-more-info-details",
viewTitle: this.hass.localize("ui.dialogs.more_info_control.details"),
viewParams: { entityId: this._entityId },
};
}
private _hasDisplayableAttributes(): boolean {
if (!this._entityId) {
return false;
}
const stateObj = this.hass.states[this._entityId];
if (!stateObj) {
return false;
}
return computeShownAttributes(stateObj).length > 0;
}
private _goToAddEntityTo(ev) {
// Only check for request-selected events (from menu items), not regular clicks (from icon button)
if (ev.type === "request-selected" && !shouldHandleRequestSelectedEvent(ev))
@@ -590,19 +579,15 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
"ui.dialogs.more_info_control.related"
)}
</ha-dropdown-item>
${this._hasDisplayableAttributes()
? html`
<ha-dropdown-item value="attributes">
<ha-svg-icon
slot="icon"
.path=${mdiFormatListBulletedSquare}
></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.attributes"
)}
</ha-dropdown-item>
`
: nothing}
<ha-dropdown-item value="details">
<ha-svg-icon
slot="icon"
.path=${mdiFormatListBulletedSquare}
></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.details"
)}
</ha-dropdown-item>
${this._shouldShowAddEntityTo()
? html`
<ha-dropdown-item value="add_to">

View File

@@ -105,9 +105,11 @@ class MoreInfoContent extends LitElement {
if (!stateObj) {
return null;
}
const entityName = entry
? computeEntityName(stateObj, hass.entities, hass.devices)
: undefined;
const entityName = computeEntityName(
stateObj,
hass.entities,
hass.devices
);
const { area } = getEntityContext(
stateObj,
hass.entities,

View File

@@ -4,11 +4,9 @@ import { fireEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-password-field";
import type { HaPasswordField } from "../../../components/ha-password-field";
import "../../../components/ha-input";
import type { HaInput } from "../../../components/ha-input";
import "../../../components/ha-svg-icon";
import "../../../components/ha-textfield";
import type { HaTextField } from "../../../components/ha-textfield";
import { cloudLogin } from "../../../data/cloud";
import { showCloudAlreadyConnectedDialog } from "../../../panels/config/cloud/dialog-cloud-already-connected/show-dialog-cloud-already-connected";
import type { HomeAssistant } from "../../../types";
@@ -28,9 +26,9 @@ export class CloudStepSignin extends LitElement {
@state() private _checkConnection = true;
@query("#email", true) private _emailField!: HaTextField;
@query("#email", true) private _emailField!: HaInput;
@query("#password", true) private _passwordField!: HaPasswordField;
@query("#password", true) private _passwordField!: HaInput;
render() {
return html`<div class="content">
@@ -42,7 +40,7 @@ export class CloudStepSignin extends LitElement {
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
<ha-textfield
<ha-input
autofocus
id="email"
name="email"
@@ -54,12 +52,14 @@ export class CloudStepSignin extends LitElement {
autocomplete="email"
required
@keydown=${this._keyDown}
validationMessage=${this.hass.localize(
.validationMessage=${this.hass.localize(
"ui.panel.config.cloud.register.email_error_msg"
)}
></ha-textfield>
<ha-password-field
></ha-input>
<ha-input
id="password"
type="password"
password-toggle
name="password"
.label=${this.hass.localize(
"ui.panel.config.cloud.register.password"
@@ -69,10 +69,10 @@ export class CloudStepSignin extends LitElement {
minlength="8"
required
@keydown=${this._keyDown}
validationMessage=${this.hass.localize(
.validationMessage=${this.hass.localize(
"ui.panel.config.cloud.register.password_error_msg"
)}
></ha-password-field>
></ha-input>
</div>
<div class="footer">
<ha-button
@@ -95,8 +95,8 @@ export class CloudStepSignin extends LitElement {
const emailField = this._emailField;
const passwordField = this._passwordField;
const email = emailField.value;
const password = passwordField.value;
const email = emailField.value as string;
const password = passwordField.value as string;
if (!emailField.reportValidity()) {
passwordField.reportValidity();
@@ -216,8 +216,7 @@ export class CloudStepSignin extends LitElement {
:host {
display: block;
}
ha-textfield,
ha-password-field {
ha-textfield {
display: block;
}
`,

View File

@@ -3,11 +3,9 @@ import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-password-field";
import type { HaPasswordField } from "../../../components/ha-password-field";
import "../../../components/ha-input";
import type { HaInput } from "../../../components/ha-input";
import "../../../components/ha-svg-icon";
import "../../../components/ha-textfield";
import type { HaTextField } from "../../../components/ha-textfield";
import {
cloudLogin,
cloudRegister,
@@ -30,9 +28,9 @@ export class CloudStepSignup extends LitElement {
@state() private _state?: "VERIFY";
@query("#email", true) private _emailField!: HaTextField;
@query("#email", true) private _emailField!: HaInput;
@query("#password", true) private _passwordField!: HaPasswordField;
@query("#password", true) private _passwordField!: HaInput;
render() {
return html`<div class="content">
@@ -53,7 +51,7 @@ export class CloudStepSignup extends LitElement {
{ email: this._email }
)}
</p>`
: html`<ha-textfield
: html`<ha-input
autofocus
id="email"
name="email"
@@ -65,12 +63,14 @@ export class CloudStepSignup extends LitElement {
autocomplete="email"
required
@keydown=${this._keyDown}
validationMessage=${this.hass.localize(
.validationMessage=${this.hass.localize(
"ui.panel.config.cloud.register.email_error_msg"
)}
></ha-textfield>
<ha-password-field
></ha-input>
<ha-input
id="password"
type="password"
password-toggle
name="password"
.label=${this.hass.localize(
"ui.panel.config.cloud.register.password"
@@ -80,10 +80,10 @@ export class CloudStepSignup extends LitElement {
minlength="8"
required
@keydown=${this._keyDown}
validationMessage=${this.hass.localize(
.validationMessage=${this.hass.localize(
"ui.panel.config.cloud.register.password_error_msg"
)}
></ha-password-field>`}
></ha-input>`}
</div>
<div class="footer side-by-side">
${this._state === "VERIFY"
@@ -131,19 +131,26 @@ export class CloudStepSignup extends LitElement {
const emailField = this._emailField;
const passwordField = this._passwordField;
let invalid = false;
if (!emailField.reportValidity()) {
passwordField.reportValidity();
invalid = true;
emailField.focus();
return;
}
if (!passwordField.reportValidity()) {
passwordField.focus();
if (!invalid) {
passwordField.focus();
}
invalid = true;
}
if (invalid) {
return;
}
const email = emailField.value.toLowerCase();
const password = passwordField.value;
const email = emailField.value!.toLowerCase();
const password = passwordField.value!;
this._requestInProgress = true;
@@ -211,10 +218,6 @@ export class CloudStepSignup extends LitElement {
.content {
width: 100%;
}
ha-textfield,
ha-password-field {
display: block;
}
`,
];
}

View File

@@ -14,6 +14,7 @@ import { stopPropagation } from "../../common/dom/stop_propagation";
import "../../components/ha-alert";
import "../../components/ha-assist-chat";
import "../../components/ha-button";
import "../../components/ha-dialog";
import "../../components/ha-dialog-header";
import "../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../components/ha-dropdown";
@@ -21,7 +22,6 @@ import "../../components/ha-dropdown-item";
import "../../components/ha-icon-button";
import "../../components/ha-icon-next";
import "../../components/ha-spinner";
import "../../components/ha-dialog";
import type { AssistPipeline } from "../../data/assist_pipeline";
import {
getAssistPipeline,
@@ -164,17 +164,14 @@ export class HaVoiceCommandDialog extends LitElement {
: nothing}
</ha-dropdown>
</div>
<a
<ha-icon-button
.label=${this.hass.localize("ui.common.help")}
.path=${mdiHelpCircleOutline}
href=${documentationUrl(this.hass, "/docs/assist/")}
slot="actionItems"
target="_blank"
rel="noopener noreferrer"
>
<ha-icon-button
.label=${this.hass.localize("ui.common.help")}
.path=${mdiHelpCircleOutline}
></ha-icon-button>
</a>
></ha-icon-button>
</ha-dialog-header>
${this._errorLoadAssist

View File

@@ -32,21 +32,28 @@ const initRouting = () => {
new CacheFirst({ matchOptions: { ignoreSearch: true } })
);
// Cache any brand images used for 30 days
// Use revalidation so cache is always available during an extended outage
// Cache any brand images used for 1 day
// Brands are proxied via the local API with backend caching.
// Strip the rotating access token from cache keys so token rotation
// doesn't bust the cache, while preserving other params like "placeholder".
registerRoute(
({ url, request }) =>
url.origin === "https://brands.home-assistant.io" &&
url.pathname.startsWith("/api/brands/") &&
request.destination === "image",
new StaleWhileRevalidate({
cacheName: "brands",
// CORS must be forced to work for CSS images
fetchOptions: { mode: "cors", credentials: "omit" },
plugins: [
{
cacheKeyWillBeUsed: async ({ request }) => {
const url = new URL(request.url);
url.searchParams.delete("token");
return url.href;
},
},
// Add 404 so we quickly respond to domains with missing images
new CacheableResponsePlugin({ statuses: [0, 200, 404] }),
new ExpirationPlugin({
maxAgeSeconds: 60 * 60 * 24 * 30,
maxAgeSeconds: 60 * 60 * 24,
purgeOnQuotaError: true,
}),
],

View File

@@ -39,11 +39,10 @@ class HassSubpage extends LitElement {
`
: this.backPath
? html`
<a href=${this.backPath}>
<ha-icon-button-arrow-prev
.hass=${this.hass}
></ha-icon-button-arrow-prev>
</a>
<ha-icon-button-arrow-prev
href=${this.backPath}
.hass=${this.hass}
></ha-icon-button-arrow-prev>
`
: html`
<ha-icon-button-arrow-prev

View File

@@ -51,8 +51,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ type: Boolean }) public supervisor = false;
@property({ type: Boolean, attribute: "main-page" }) public mainPage = false;
@property({ attribute: false }) public initialCollapsedGroups: string[] = [];
@@ -322,7 +320,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
? html`
<ha-dropdown-item
.value=${id}
.clickAction=${this._handleGroupBy}
.selected=${id === this._groupColumn}
class=${classMap({ selected: id === this._groupColumn })}
>
@@ -383,7 +380,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
.route=${this.route}
.tabs=${this.tabs}
.mainPage=${this.mainPage}
.supervisor=${this.supervisor}
.pane=${showPane && this.showFilters}
@sorting-changed=${this._sortingChanged}
>
@@ -489,7 +485,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
: ""}
<ha-data-table
.hass=${this.hass}
.localize=${localize}
.narrow=${this.narrow}
.columns=${this.columns}
.data=${this.data}

View File

@@ -5,7 +5,8 @@ import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { canShowPage } from "../common/config/can_show_page";
import { restoreScroll } from "../common/decorators/restore-scroll";
import { goBack } from "../common/navigate";
import { isNavigationClick } from "../common/dom/is-navigation-click";
import { goBack, navigate } from "../common/navigate";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-menu-button";
@@ -14,6 +15,11 @@ import "../components/ha-tab";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant, Route } from "../types";
const normalizePathname = (pathname: string): string =>
pathname.endsWith("/") && pathname.length > 1
? pathname.slice(0, -1)
: pathname;
export interface PageNavigation {
path: string;
translationKey?: string;
@@ -88,9 +94,8 @@ class HassTabsSubpage extends LitElement {
return shownTabs.map(
(page) => html`
<a href=${page.path}>
<a href=${page.path} @click=${this._tabClicked}>
<ha-tab
.hass=${this.hass}
.active=${page.path === activeTab?.path}
.narrow=${this.narrow}
.name=${page.translationKey
@@ -112,8 +117,9 @@ class HassTabsSubpage extends LitElement {
public willUpdate(changedProperties: PropertyValues) {
if (changedProperties.has("route")) {
const currentPath = `${this.route.prefix}${this.route.path}`;
this._activeTab = this.tabs.find((tab) =>
`${this.route.prefix}${this.route.path}`.includes(tab.path)
this._isActiveTabPath(tab.path, currentPath)
);
}
super.willUpdate(changedProperties);
@@ -143,11 +149,10 @@ class HassTabsSubpage extends LitElement {
`
: this.backPath
? html`
<a href=${this.backPath}>
<ha-icon-button-arrow-prev
.hass=${this.hass}
></ha-icon-button-arrow-prev>
</a>
<ha-icon-button-arrow-prev
.href=${this.backPath}
.hass=${this.hass}
></ha-icon-button-arrow-prev>
`
: html`
<ha-icon-button-arrow-prev
@@ -210,6 +215,36 @@ class HassTabsSubpage extends LitElement {
goBack();
}
private _isActiveTabPath(tabPath: string, currentPath: string): boolean {
try {
const tabUrl = new URL(tabPath, window.location.origin);
const currentUrl = new URL(currentPath, window.location.origin);
const tabPathname = normalizePathname(tabUrl.pathname);
const currentPathname = normalizePathname(currentUrl.pathname);
if (
currentPathname === tabPathname ||
currentPathname.startsWith(`${tabPathname}/`)
) {
return true;
}
return false;
} catch (_err) {
return currentPath === tabPath || currentPath.startsWith(`${tabPath}/`);
}
}
private async _tabClicked(ev: MouseEvent): Promise<void> {
const href = isNavigationClick(ev);
if (!href) {
return;
}
await navigate(href, { replace: true });
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,

View File

@@ -35,6 +35,7 @@ const COMPONENTS = {
security: () => import("../panels/security/ha-panel-security"),
climate: () => import("../panels/climate/ha-panel-climate"),
home: () => import("../panels/home/ha-panel-home"),
notfound: () => import("../panels/notfound/ha-panel-notfound"),
};
@customElement("partial-panel-resolver")

View File

@@ -11,8 +11,8 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-alert";
import "../components/ha-list";
import "../components/ha-button";
import "../components/ha-list";
import "../components/ha-list-item";
import "../components/ha-radio";
import "../components/ha-spinner";
@@ -486,7 +486,7 @@ class OnboardingLocation extends LitElement {
right: 10px;
inset-inline-end: 10px;
inset-inline-start: initial;
--mdc-icon-button-size: 36px;
--ha-icon-button-size: 36px;
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
inset-inline-start: initial;

View File

@@ -1,25 +1,25 @@
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
import { customElement, property, state, query } from "lit/decorators";
import "../../components/ha-button";
import { customElement, property, query, state } from "lit/decorators";
import { formatDateTimeWithBrowserDefaults } from "../../common/datetime/format_date_time";
import { fireEvent } from "../../common/dom/fire_event";
import type { LocalizeFunc } from "../../common/translations/localize";
import "../../components/buttons/ha-progress-button";
import type { HaProgressButton } from "../../components/buttons/ha-progress-button";
import "../../components/ha-alert";
import "../../components/ha-button";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-input";
import "../../components/ha-md-list";
import "../../components/ha-md-list-item";
import "../../components/buttons/ha-progress-button";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-password-field";
import "../../panels/config/backup/components/ha-backup-data-picker";
import "../../panels/config/backup/components/ha-backup-formfield-label";
import type { LocalizeFunc } from "../../common/translations/localize";
import {
getPreferredAgentForDownload,
type BackupContentExtended,
type BackupData,
} from "../../data/backup";
import { restoreOnboardingBackup } from "../../data/backup_onboarding";
import type { HaProgressButton } from "../../components/buttons/ha-progress-button";
import { fireEvent } from "../../common/dom/fire_event";
import "../../panels/config/backup/components/ha-backup-data-picker";
import "../../panels/config/backup/components/ha-backup-formfield-label";
import { onBoardingStyles } from "../styles";
import { formatDateTimeWithBrowserDefaults } from "../../common/datetime/format_date_time";
@customElement("onboarding-restore-backup-restore")
class OnboardingRestoreBackupRestore extends LitElement {
@@ -170,7 +170,7 @@ class OnboardingRestoreBackupRestore extends LitElement {
`ui.panel.page-onboarding.restore.details.restore.encryption.description${this.mode === "cloud" ? "_cloud" : ""}`
)}
</span>
<ha-password-field
<ha-input
.disabled=${this._loading}
@input=${this._encryptionKeyChanged}
.label=${this.localize(
@@ -178,13 +178,11 @@ class OnboardingRestoreBackupRestore extends LitElement {
)}
.value=${this._encryptionKey}
@keydown=${this._keyDown}
.errorMessage=${this._encryptionKeyWrong
? this.localize(
"ui.panel.page-onboarding.restore.details.restore.encryption.incorrect_key"
)
: ""}
.validationMessage=${this.localize(
"ui.panel.page-onboarding.restore.details.restore.encryption.incorrect_key"
)}
.invalid=${this._encryptionKeyWrong}
></ha-password-field>
></ha-input>
</div>`
: nothing}
@@ -353,7 +351,7 @@ class OnboardingRestoreBackupRestore extends LitElement {
.encryption {
margin-bottom: 32px;
}
.encryption ha-password-field {
.encryption ha-input {
margin-top: 24px;
}
.actions {

View File

@@ -1,12 +1,13 @@
import { mdiMenu } from "@mdi/js";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { createRef, ref } from "lit/directives/ref";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { navigate } from "../../common/navigate";
import { computeRouteTail } from "../../common/url/route";
import { nextRender } from "../../common/util/render-status";
import "../../components/ha-icon-button";
import type { HassioAddonDetails } from "../../data/hassio/addon";
@@ -24,7 +25,6 @@ import {
showConfirmationDialog,
} from "../../dialogs/generic/show-dialog-box";
import "../../layouts/hass-loading-screen";
import { computeRouteTail } from "../../common/url/route";
import type { HomeAssistant, PanelInfo, Route } from "../../types";
interface AppPanelConfig {
@@ -43,7 +43,7 @@ class HaPanelApp extends LitElement {
@property({ attribute: false }) public panel!: PanelInfo<AppPanelConfig>;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public narrow = false;
@state() private _addon?: HassioAddonDetails;
@@ -119,7 +119,7 @@ class HaPanelApp extends LitElement {
${!this._kioskMode &&
(this.narrow || this.hass.dockedSidebar === "always_hidden")
? html`
<div class="header ${classMap({ narrow: this.narrow })}">
<div class="header">
<ha-icon-button
.label=${this.hass.localize("ui.sidebar.sidebar_toggle")}
.path=${mdiMenu}
@@ -130,7 +130,10 @@ class HaPanelApp extends LitElement {
`
: nothing}
<iframe
class=${classMap({ loaded: this._iframeLoaded })}
class=${classMap({
loaded: this._iframeLoaded,
"kiosk-mode": this._kioskMode,
})}
title=${this._addon.name}
src=${this._addon.ingress_url!}
@load=${this._checkLoaded}
@@ -451,6 +454,16 @@ class HaPanelApp extends LitElement {
height: calc(100% - 40px);
}
:host([narrow]) iframe {
padding-top: var(--safe-area-inset-top);
height: calc(100% - var(--safe-area-inset-top, 0px));
}
:host([narrow]) .header + iframe {
padding-top: 0;
height: calc(100% - 40px - var(--safe-area-inset-top, 0px));
}
.header {
display: flex;
align-items: center;
@@ -466,6 +479,11 @@ class HaPanelApp extends LitElement {
--mdc-icon-size: 20px;
}
:host([narrow]) .header {
height: calc(40px + var(--safe-area-inset-top, 0px));
padding-top: var(--safe-area-inset-top, 0);
}
.main-title {
margin-inline-start: var(--ha-space-6);
line-height: var(--ha-line-height-condensed);

View File

@@ -488,7 +488,7 @@ export class HAFullCalendar extends LitElement {
.prev,
.next {
--mdc-icon-button-size: 32px;
--ha-icon-button-size: 32px;
}
ha-fab {

View File

@@ -58,7 +58,8 @@ class PanelClimate extends LitElement {
oldHass.entities !== this.hass.entities ||
oldHass.devices !== this.hass.devices ||
oldHass.areas !== this.hass.areas ||
oldHass.floors !== this.hass.floors
oldHass.floors !== this.hass.floors ||
oldHass.panels !== this.hass.panels
) {
if (this.hass.config.state === "RUNNING") {
this._debounceRegistriesChanged();

View File

@@ -103,10 +103,12 @@ const processAreasForClimate = (
heading_style: "subtitle",
type: "heading",
heading: area.name,
tap_action: {
action: "navigate",
navigation_path: `/home/areas-${area.area_id}`,
},
tap_action: hass.panels.home
? {
action: "navigate",
navigation_path: `/home/areas-${area.area_id}`,
}
: undefined,
});
cards.push(...areaCards);
}

View File

@@ -5,15 +5,14 @@ import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-dialog";
import "../../../components/ha-dialog-footer";
import "../../../components/ha-fade-in";
import "../../../components/ha-generic-picker";
import "../../../components/ha-input";
import "../../../components/ha-markdown";
import "../../../components/ha-password-field";
import type { PickerComboBoxItem } from "../../../components/ha-picker-combo-box";
import "../../../components/ha-spinner";
import "../../../components/ha-textfield";
import "../../../components/ha-dialog";
import type {
ApplicationCredential,
ApplicationCredentialsConfig,
@@ -69,6 +68,7 @@ export class DialogAddApplicationCredential extends LitElement {
this._params = params;
this._domain = params.selectedDomain;
this._manifest = params.manifest;
this._invalid = false;
this._name = "";
this._description = "";
this._clientId = "";
@@ -195,7 +195,7 @@ export class DialogAddApplicationCredential extends LitElement {
.content=${this._description}
></ha-markdown>`
: nothing}
<ha-textfield
<ha-input
class="name"
name="name"
.label=${this.hass.localize(
@@ -205,12 +205,12 @@ export class DialogAddApplicationCredential extends LitElement {
.invalid=${this._invalid && !this._name}
required
@input=${this._handleValueChanged}
.errorMessage=${this.hass.localize(
.validationMessage=${this.hass.localize(
"ui.common.error_required"
)}
dialogInitialFocus
></ha-textfield>
<ha-textfield
></ha-input>
<ha-input
class="clientId"
name="clientId"
.label=${this.hass.localize(
@@ -220,16 +220,17 @@ export class DialogAddApplicationCredential extends LitElement {
.invalid=${this._invalid && !this._clientId}
required
@input=${this._handleValueChanged}
.errorMessage=${this.hass.localize(
.validationMessage=${this.hass.localize(
"ui.common.error_required"
)}
dialogInitialFocus
.helper=${this.hass.localize(
.hint=${this.hass.localize(
"ui.panel.config.application_credentials.editor.client_id_helper"
)}
helperPersistent
></ha-textfield>
<ha-password-field
></ha-input>
<ha-input
type="password"
password-toggle
.label=${this.hass.localize(
"ui.panel.config.application_credentials.editor.client_secret"
)}
@@ -238,14 +239,13 @@ export class DialogAddApplicationCredential extends LitElement {
.invalid=${this._invalid && !this._clientSecret}
required
@input=${this._handleValueChanged}
.errorMessage=${this.hass.localize(
.validationMessage=${this.hass.localize(
"ui.common.error_required"
)}
.helper=${this.hass.localize(
.hint=${this.hass.localize(
"ui.panel.config.application_credentials.editor.client_secret_helper"
)}
helperPersistent
></ha-password-field>
></ha-input>
</div>
<ha-dialog-footer slot="footer">
@@ -377,11 +377,6 @@ export class DialogAddApplicationCredential extends LitElement {
display: flex;
padding: var(--ha-space-2) 0;
}
ha-textfield {
display: block;
margin-top: var(--ha-space-4);
margin-bottom: var(--ha-space-4);
}
a {
text-decoration: none;
}

View File

@@ -106,12 +106,11 @@ export class HaConfigApplicationCredentials extends LitElement {
filterable: true,
},
actions: {
lastFixed: true,
title: "",
label: localize("ui.panel.config.generic.headers.actions"),
type: "overflow-menu",
showNarrow: true,
hideable: false,
moveable: false,
template: (credential) => html`
<ha-icon-overflow-menu
.hass=${this.hass}

View File

@@ -9,10 +9,16 @@ import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import { extractSearchParam } from "../../../common/url/search-params";
import type { HassioAddonDetails } from "../../../data/hassio/addon";
import { fetchHassioAddonInfo } from "../../../data/hassio/addon";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import {
addStoreRepository,
fetchSupervisorStore,
} from "../../../data/supervisor/store";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-error-screen";
import "../../../layouts/hass-loading-screen";
import "../../../layouts/hass-tabs-subpage";
@@ -39,6 +45,8 @@ class HaConfigAppDashboard extends LitElement {
@state() private _fromStore = false;
@state() private _loading = true;
private _computeTail = memoizeOne((route: Route) => {
const pathParts = route.path.split("/").filter(Boolean);
// Path is like /<slug>/info or /<slug>/config
@@ -53,8 +61,15 @@ class HaConfigAppDashboard extends LitElement {
protected async firstUpdated(): Promise<void> {
this._fromStore = extractSearchParam("store") === "true";
await this._loadAddon();
const repositoryUrl = extractSearchParam("repository_url");
if (repositoryUrl) {
navigate(`/config/app/${this.route.path.split("/")[1]}`, {
replace: true,
});
}
await this._loadAddon(repositoryUrl);
this.addEventListener("hass-api-called", (ev) => this._apiCalled(ev));
this._loading = false;
}
protected updated(changedProperties: PropertyValues) {
@@ -63,7 +78,7 @@ class HaConfigAppDashboard extends LitElement {
const oldSlug = oldRoute?.path.split("/")[1];
const newSlug = this.route.path.split("/")[1];
if (oldSlug !== newSlug && newSlug) {
if (oldSlug !== newSlug && newSlug && !this._loading) {
this._loadAddon();
}
}
@@ -138,7 +153,7 @@ class HaConfigAppDashboard extends LitElement {
`;
}
private async _loadAddon(): Promise<void> {
private async _loadAddon(repositoryUrl?: string | null): Promise<void> {
const slug = this.route.path.split("/")[1];
if (!slug) {
this._error = "No addon specified";
@@ -148,10 +163,56 @@ class HaConfigAppDashboard extends LitElement {
try {
this._addon = await fetchHassioAddonInfo(this.hass, slug);
} catch (err: any) {
this._error = `Error loading addon: ${extractApiErrorMessage(err)}`;
if (repositoryUrl) {
try {
await this._handleMissingRepository(slug, repositoryUrl);
if (this._addon) {
// Clear error if we successfully added the repository and loaded the addon
this._error = undefined;
return;
}
} catch (addRepoErr: any) {
this._error = extractApiErrorMessage(addRepoErr);
return;
}
}
this._error = `Error loading app: ${extractApiErrorMessage(err)}`;
}
}
private async _handleMissingRepository(
slug: string,
repositoryUrl: string
): Promise<void> {
const storeInfo = await fetchSupervisorStore(this.hass);
if (storeInfo.repositories.some((repo) => repo.source === repositoryUrl)) {
// Repository is already installed, addon just doesn't exist
return;
}
if (
!(await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.apps.my.add_repository_title"
),
text: this.hass.localize(
"ui.panel.config.apps.my.add_repository_description",
{ repository: repositoryUrl }
),
confirmText: this.hass.localize("ui.common.add"),
dismissText: this.hass.localize("ui.common.cancel"),
}))
) {
this._error = this.hass.localize(
"ui.panel.config.apps.my.error_repository_not_found"
);
return;
}
await addStoreRepository(this.hass, repositoryUrl);
this._addon = await fetchHassioAddonInfo(this.hass, slug);
}
private async _apiCalled(ev): Promise<void> {
if (!ev.detail.success) {
return;

View File

@@ -2,12 +2,14 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { stringCompare } from "../../../../../common/string/compare";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type {
LocalizeFunc,
LocalizeKeys,
} from "../../../../../common/translations/localize";
import { CONDITION_ICONS } from "../../../../../components/ha-condition-icon";
import "../../../../../components/ha-dropdown-item";
import type { PickerComboBoxItem } from "../../../../../components/ha-picker-combo-box";
import "../../../../../components/ha-select";
import type { HaSelectSelectEvent } from "../../../../../components/ha-select";
import {
DYNAMIC_PREFIX,
getValueFromDynamic,
@@ -21,8 +23,9 @@ import {
getConditionObjectId,
subscribeConditions,
} from "../../../../../data/condition";
import { domainToName } from "../../../../../data/integration";
import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../../types";
import type { HomeAssistant, ValueChangedEvent } from "../../../../../types";
import "../../condition/ha-automation-condition-editor";
import type HaAutomationConditionEditor from "../../condition/ha-automation-condition-editor";
import "../../condition/types/ha-automation-condition-and";
@@ -89,43 +92,24 @@ export class HaConditionAction
? `${DYNAMIC_PREFIX}${this.action.condition}`
: this.action.condition;
let valueLabel = value;
const items = html`${this._processedTypes(
this._conditionDescriptions,
this.hass.localize
).map(([opt, label, condition]) => {
const selected = value === opt;
if (selected) {
valueLabel = label;
}
return html`
<ha-dropdown-item .value=${opt} .selected=${selected}>
<ha-condition-icon
.hass=${this.hass}
slot="icon"
.condition=${condition}
></ha-condition-icon>
${label}
</ha-dropdown-item>
`;
})}`;
return html`
${this.inSidebar || (!this.inSidebar && !this.indent)
? html`
<ha-select
<ha-generic-picker
.hass=${this.hass}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.conditions.type_select"
)}
.disabled=${this.disabled}
.value=${valueLabel}
@selected=${this._typeChanged}
>
${items}
</ha-select>
.value=${value}
.getItems=${this._processedTypes(
this._conditionDescriptions,
this.hass.localize
)}
.rowRenderer=${this._rowRenderer}
.valueRenderer=${this._valueRenderer}
@value-changed=${this._typeChanged}
></ha-generic-picker>
`
: nothing}
${(this.indent && buildingBlock) ||
@@ -150,36 +134,83 @@ export class HaConditionAction
`;
}
private _valueRenderer = (value: string) => {
const isDynamicValue = isDynamic(value);
const condition = isDynamicValue ? getValueFromDynamic(value) : value;
let label = condition;
if (isDynamicValue) {
const domain = getConditionDomain(condition);
const conditionObjId = getConditionObjectId(condition);
label =
this.hass.localize(
`component.${domain}.conditions.${conditionObjId}.name`
) || condition;
} else {
label =
this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.${condition}.label` as LocalizeKeys
) || condition;
}
return html`<ha-condition-icon
slot="start"
.hass=${this.hass}
.condition=${condition}
></ha-condition-icon
><span slot="headline">${label}</span>`;
};
private _rowRenderer = (item: PickerComboBoxItem) => html`
<ha-combo-box-item type="button">
<ha-condition-icon
slot="start"
.hass=${this.hass}
.condition=${item.search_labels!.condition || undefined}
></ha-condition-icon>
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
</ha-combo-box-item>
`;
private _processedTypes = memoizeOne(
(
conditionDescriptions: ConditionDescriptions,
localize: LocalizeFunc
): [string, string, string][] => {
(conditionDescriptions: ConditionDescriptions, localize: LocalizeFunc) => {
const legacy = (
Object.keys(CONDITION_ICONS) as (keyof typeof CONDITION_ICONS)[]
).map(
(condition) =>
[
).map((condition) => {
const primary = localize(
`ui.panel.config.automation.editor.conditions.type.${condition}.label`
);
return {
id: condition,
primary,
sorting_label: primary,
search_labels: {
condition,
localize(
`ui.panel.config.automation.editor.conditions.type.${condition}.label`
),
condition,
] as [string, string, string]
);
},
};
});
const platform = Object.keys(conditionDescriptions).map((condition) => {
const domain = getConditionDomain(condition);
const conditionObjId = getConditionObjectId(condition);
return [
`${DYNAMIC_PREFIX}${condition}`,
const primary =
localize(`component.${domain}.conditions.${conditionObjId}.name`) ||
condition;
return {
id: `${DYNAMIC_PREFIX}${condition}`,
primary,
secondary: domainToName(this.hass.localize, domain),
sorting_label: primary,
search_labels: {
condition,
condition,
] as [string, string, string];
domain,
},
};
});
return [...legacy, ...platform].sort((a, b) =>
stringCompare(a[1], b[1], this.hass.locale.language)
);
return () => [...legacy, ...platform];
}
);
@@ -201,7 +232,8 @@ export class HaConditionAction
});
}
private _typeChanged(ev: HaSelectSelectEvent) {
private _typeChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const type = ev.detail.value;
if (!type) {
@@ -249,7 +281,7 @@ export class HaConditionAction
}
static styles = css`
ha-select {
ha-generic-picker {
margin-bottom: 24px;
display: block;
}

View File

@@ -37,6 +37,7 @@ import "../../../components/ha-button";
import "../../../components/ha-button-toggle-group";
import "../../../components/ha-combo-box-item";
import { CONDITION_ICONS } from "../../../components/ha-condition-icon";
import "../../../components/ha-dialog";
import "../../../components/ha-dialog-header";
import "../../../components/ha-domain-icon";
import "../../../components/ha-floor-icon";
@@ -51,7 +52,6 @@ import "../../../components/ha-section-title";
import "../../../components/ha-service-icon";
import "../../../components/ha-tooltip";
import { TRIGGER_ICONS } from "../../../components/ha-trigger-icon";
import "../../../components/ha-dialog";
import "../../../components/search-input";
import {
ACTION_BUILDING_BLOCKS_GROUP,
@@ -756,19 +756,16 @@ class DialogAddAutomationElement
${this._renderDialogSubtitle()}
${!this._narrow || (!this._selectedGroup && !this._selectedTarget)
? html`
<a
<ha-icon-button
.path=${mdiHelpCircleOutline}
.label=${this.hass.localize(
`ui.panel.config.automation.editor.${this._params!.type}s.learn_more`
)}
slot="actionItems"
href=${docUrl}
target="_blank"
rel="noreferrer"
>
<ha-icon-button
.path=${mdiHelpCircleOutline}
.label=${this.hass.localize(
`ui.panel.config.automation.editor.${this._params!.type}s.learn_more`
)}
></ha-icon-button>
</a>
></ha-icon-button>
`
: nothing}
${this._narrow && (this._selectedGroup || this._selectedTarget)
@@ -2100,7 +2097,7 @@ class DialogAddAutomationElement
--ha-dialog-max-height: var(--ha-dialog-min-height);
}
ha-dialog a[slot="actionItems"] {
ha-dialog ha-icon-button[slot="actionItems"] {
color: var(--secondary-text-color);
}

View File

@@ -3,13 +3,13 @@ import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-radio";
import "../../../../components/ha-button";
import "../../../../components/ha-textfield";
import {
@@ -73,19 +73,16 @@ class DialogAutomationMode extends LitElement implements HassDialog {
header-title=${title}
@closed=${this._dialogClosed}
>
<a
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.automation.editor.modes.learn_more"
)}
.path=${mdiHelpCircleOutline}
href=${documentationUrl(this.hass, "/docs/automation/modes/")}
slot="headerActionItems"
target="_blank"
rel="noopener noreferrer"
>
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.automation.editor.modes.learn_more"
)}
.path=${mdiHelpCircleOutline}
></ha-icon-button>
</a>
></ha-icon-button>
<ha-md-list
role="listbox"
tabindex="0"
@@ -213,6 +210,9 @@ class DialogAutomationMode extends LitElement implements HassDialog {
.options {
padding: 0 24px 24px 24px;
}
ha-wa-dialog ha-icon-button[slot="headerActionItems"] {
color: var(--secondary-text-color);
}
`,
];
}

View File

@@ -221,12 +221,6 @@ class DialogAutomationSave extends LitElement implements HassDialog {
)
)
: nothing}
${this._renderOptionalChip(
"area",
this.hass.localize(
"ui.panel.config.automation.editor.dialog.add_area"
)
)}
${this._renderOptionalChip(
"category",
this.hass.localize(
@@ -239,6 +233,12 @@ class DialogAutomationSave extends LitElement implements HassDialog {
"ui.panel.config.automation.editor.dialog.add_labels"
)
)}
${this._renderOptionalChip(
"area",
this.hass.localize(
"ui.panel.config.automation.editor.dialog.add_area"
)
)}
</ha-chip-set>
`;
}

View File

@@ -345,12 +345,11 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
`,
},
actions: {
lastFixed: true,
title: "",
label: this.hass.localize("ui.panel.config.generic.headers.actions"),
type: "icon-button",
showNarrow: true,
moveable: false,
hideable: false,
template: (automation) => html`
<ha-icon-button
.automation=${automation}

View File

@@ -3,9 +3,9 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { ensureArray } from "../../../../../common/array/ensure-array";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-textfield";
import type { HaTextField } from "../../../../../components/ha-textfield";
import "../../../../../components/ha-icon-button";
import type { ConversationTrigger } from "../../../../../data/automation";
import { showConfirmationDialog } from "../../../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../../../types";
@@ -163,7 +163,7 @@ export class HaConversationTrigger
ha-textfield > ha-icon-button {
position: relative;
right: -8px;
--mdc-icon-button-size: 36px;
--ha-icon-button-size: 36px;
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
inset-inline-start: initial;

View File

@@ -27,7 +27,6 @@ export class HaNumericStateTrigger extends LitElement {
private _schema = memoizeOne(
(
localize: LocalizeFunc,
entityId: string | string[],
inputAboveIsEntity?: boolean,
inputBelowIsEntity?: boolean
) =>
@@ -39,9 +38,9 @@ export class HaNumericStateTrigger extends LitElement {
},
{
name: "attribute",
context: { filter_entity: "entity_id" },
selector: {
attribute: {
entity_id: entityId ? entityId[0] : undefined,
hide_attributes: [
"access_token",
"auto_update",
@@ -275,7 +274,6 @@ export class HaNumericStateTrigger extends LitElement {
public render() {
const schema = this._schema(
this.hass.localize,
this.trigger.entity_id,
this._inputAboveIsEntity,
this._inputBelowIsEntity
);

View File

@@ -8,6 +8,7 @@ import { fireEvent } from "../../../../../common/dom/fire_event";
import { slugify } from "../../../../../common/string/slugify";
import { copyToClipboard } from "../../../../../common/util/copy-clipboard";
import "../../../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../../../components/ha-dropdown";
import "../../../../../components/ha-dropdown-item";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-textfield";
@@ -19,7 +20,6 @@ import type {
import type { HomeAssistant } from "../../../../../types";
import { showToast } from "../../../../../util/toast";
import { handleChangeEvent } from "../ha-automation-trigger-row";
import type { HaDropdownSelectEvent } from "../../../../../components/ha-dropdown";
const SUPPORTED_METHODS = ["GET", "HEAD", "POST", "PUT"];
const DEFAULT_METHODS = ["POST", "PUT"];
@@ -234,7 +234,7 @@ export class HaWebhookTrigger extends LitElement {
}
ha-textfield > ha-icon-button {
--mdc-icon-button-size: 24px;
--ha-icon-button-size: 24px;
--mdc-icon-size: 18px;
color: var(--secondary-text-color);
}

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