Compare commits

...

102 Commits

Author SHA1 Message Date
Petar Petrov
3b688a6dbb Remove unnecessary eslint-disable for generated gallery imports
The import-pages file is generated as .ts, so import-x/extensions
doesn't require an extension (ts: "never" in config).
2026-04-02 09:45:09 +03:00
Petar Petrov
02d709f9d1 Update eslint to v10.1.0
Migrate from eslint-plugin-import to eslint-plugin-import-x since the
original plugin uses removed ESLint 10 APIs (context.parserOptions,
context.parserPath). Airbnb-base non-import rules preserved via
FlatCompat with import rules stripped and replaced by import-x.

- eslint 9.39.4 → 10.1.0
- Add eslint-plugin-import-x 4.16.2
- Add @eslint/eslintrc and @eslint/js as explicit deps
- Remove --flag v10_config_lookup_from_file (now default)
- Replace /* eslint-env */ comment with config-based globals
- Update all import/ rule references to import-x/
2026-04-02 09:34:00 +03:00
renovate[bot]
b4824cc0a7 Update dependency typescript to v6 (#30363)
* Update dependency typescript to v6

* Fix deprecation

* Fix cast

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-04-02 08:59:32 +03:00
Paulus Schoutsen
28f375c0d4 Allow enabling/disabling summaries (#51319)
* Allow customizing home page summaries and adding quick links

Add ability to hide built-in summaries (light, climate, security,
media players, weather, energy) and add custom quick links to
dashboards, sidebar items, or other pages from the edit overview dialog.

https://claude.ai/code/session_01AqgbQULH5vfETibiba5RXH

* Remove quick links, focus on summary enable/disable only

https://claude.ai/code/session_01AqgbQULH5vfETibiba5RXH

* Match summary editor rows to dashboard order with icon, color, and toggle on right

Each summary row now shows its colored icon and title matching the
dashboard appearance, with the toggle switch moved to the right side.
Order matches the dashboard: light, climate, security, media players,
weather, energy, persons.

https://claude.ai/code/session_01AqgbQULH5vfETibiba5RXH

* Lint

* Apply suggestion from @balloob

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-02 08:42:04 +03:00
Simon Lamon
da7ccac811 No longer take the first action when no action is selected (#51341)
No longer take the first action when no action is selected
2026-04-02 08:21:02 +03:00
Aidan Timson
a8ad921efd Create shared select card feature base class (#51333)
* Create shared select card feature base class

* Add sound mode and source features

* Remove serviceValueKey as its the same as attribute

* Migrate more

* Migrate select options

* Add fan direction

* Remove default usages
2026-04-01 17:58:11 +03:00
Petar Petrov
3b8f219800 Add customizable dismiss label to ha-alert component (#51337)
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-01 15:20:05 +01:00
Paul Bottein
e36a2e1c70 Use view columns visibility condition in strategies (#51323) 2026-04-01 15:05:52 +03:00
Wendelin
e06ea1047c Fix generic picker filter section padding (#51334)
Fix padding in picker section for improved layout
2026-04-01 15:04:24 +03:00
Petar Petrov
99cb997d08 Use localized string for empty logbook entries in trace view (#51324) 2026-04-01 15:03:42 +03:00
Wendelin
ac3edd20f8 Fix picker search padding (#51331) 2026-04-01 09:30:49 +00:00
Wendelin
0d88d139f0 Fix date input field shrink (#51330) 2026-04-01 09:22:37 +00:00
Petar Petrov
b8d08ccb05 Use ha-card-border-color for integration cards instead of divider-color (#51321) 2026-04-01 11:13:24 +02:00
Petar Petrov
7c20316ba5 Fix layout of compare card in water/gas views (#51329) 2026-04-01 08:57:25 +00:00
Aidan Timson
fa633efc87 Fix target item loading error (#51326) 2026-04-01 10:43:42 +02:00
Petar Petrov
85d461f0fd Await energy translation fragment before generating dashboard strategy (#51327) 2026-04-01 10:41:27 +02:00
Wendelin
b55e1c9988 Improve dialog open logic (#51328) 2026-04-01 10:40:40 +02:00
Petar Petrov
1da349a36d Fix ZHA device count not including devices without entities (#51322) 2026-04-01 09:17:39 +01:00
Paul Bottein
74f7139a09 Add view columns visibility condition (#51288)
* Add view columns visibility condition

* Use max column, not column count

* Rename

* Remove editor
2026-04-01 10:11:53 +02:00
Petar Petrov
2911cc77fa Fix Fill example data inserting incorrect datetime format (#51320) 2026-04-01 06:43:09 +00:00
Wendelin
ab20383a3a Migrate all from ha-textfield to ha-input (#30349) 2026-04-01 08:37:49 +02:00
karwosts
514cb9da9d Add due_date_period to todo UI, create period selector (#51263)
* Add due_date_period to todo UI, create period selector

* updates

* Apply suggestions from code review

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

* ??

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-04-01 09:10:15 +03:00
Wendelin
7c52ac8ca7 Remove target description (#51315) 2026-03-31 17:42:02 +02:00
Aidan Timson
07b4a44228 Fix tile secondary info pop in (#51308)
* Add support for skeleton on tile info secondary text

* Show loading state for users of tile info

* Update src/components/tile/ha-tile-info.ts

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

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-03-31 17:38:03 +03:00
Bram Kragten
2b28a6c3f2 Update download-translations.js 2026-03-31 15:50:59 +02:00
Bram Kragten
84f2e304cf Make translation downloading async (#51314) 2026-03-31 13:35:05 +00:00
Aidan Timson
18cd40ab01 Add select source card feature for supported media players (#51283)
* Add select source card feature for supported media players

* Show label

* Apply suggestions from code review

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

* Use shouldUpdate

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-03-31 15:44:18 +03:00
Bram Kragten
8e3b1dc6ac Triggers/conditions Add usage and grouping to new multi domains (#51287) 2026-03-31 12:42:15 +00:00
Bram Kragten
5cc223a582 Fix has target check for actions (#51309) 2026-03-31 14:37:46 +02:00
Wendelin
9a62a9217c Fix automation add TCA dialog sometimes not opening (#51306) 2026-03-31 13:36:56 +02:00
Simon Lamon
70be747e9d Gauge improvements (#30368)
* Gauge last improvements

* Change needle

* Fixup

* Feedback comments

* Update src/components/ha-gauge.ts

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-03-31 14:27:22 +03:00
Aidan Timson
bb57a91494 Add select sound mode card feature for supported media players (#51282)
* Add sound mode card feature for supported media players

* Show label

* Use shouldUpdate
2026-03-31 11:25:00 +00:00
Paul Bottein
7e22e6c0e2 Rename "People at home" summary tile to "Presence" (#51305)
* Rename "People at home" summary tile to "Presence"

* Improve person translation
2026-03-31 13:59:52 +03:00
Wendelin
c93f910e56 Fix system hardware caption translation (#51303) 2026-03-31 12:50:17 +02:00
Aidan Timson
8bf4ff5d25 Fix above/below numeric state entity formatting (#51298) 2026-03-31 12:49:37 +02:00
Wendelin
debc3adf19 Remove hass property in ha-data-table (#51304) 2026-03-31 12:47:17 +02:00
Petar Petrov
ae21017de8 Use boundaryFilter data zoom mode only for line charts (#51307) 2026-03-31 12:44:52 +02:00
Wendelin
f15f518cc2 Improve date-range-picker mobile presets (#51285) 2026-03-31 12:39:54 +02:00
Paulus Schoutsen
0e44417051 Fix My link for apps (#51258)
* Clean up My link matching

* Fix My link for apps to include repository_url

* Apply suggestion from @MindFreeze

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

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-03-31 13:33:23 +03:00
Timothy
3581b43336 Add delay before revoking URL on Android (#51299) 2026-03-31 12:23:00 +02:00
Norbert Rittel
32b9676f97 Change picker descriptions of triggers to match new style (#51294) 2026-03-31 10:06:47 +02:00
Petar Petrov
7876642f35 Fix x-axis labels for statistics graph month/year periods (#51295) 2026-03-31 10:01:52 +02:00
Paul Bottein
0e3bcfad5e Hide section when all cards are hidden (#51281) 2026-03-31 08:38:09 +02:00
Florent L.
cd1c273d5a Add people at home summary tile to home overview dashboard (#30408)
* Add persons summary tile to home overview dashboard

Show how many people are currently home in the Summary section
of the default home dashboard. Only persons with at least one
tracking device are included. The tile only appears when the map
panel is loaded and at least one tracked person entity exists.
Tapping navigates to the map panel. Displays a count of persons
home or "Nobody" when all are away.

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

* Remove persons tile from home overview strategy

* Translation tweak

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-03-31 07:56:44 +03:00
Louis Sautier
d92ac4b4b7 Add solo-select gesture to chart legend (#30395)
* Add solo-select gesture to chart legend

Ctrl+click (Cmd+click on Mac) or long-press (touch, 500ms) a legend
item to solo-select it:
- Solo-click any item → hide everything else, show only that item
- Solo-click the only visible item → restore all

There is no special "solo mode" — the gesture simply sets which items
are hidden. Normal click/tap continues to toggle individual series,
including after a solo action (e.g. solo a, then click b to add it).

Closes https://github.com/orgs/home-assistant/discussions/1492

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Deduplicate legend parsing in _renderLegend and _getAllLegendIds

Both methods parsed options.legend and filtered datasets identically.
Extract the shared logic into a new _getLegendItems method.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Update src/components/chart/ha-chart-base.ts

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-03-30 15:08:16 +00:00
Petar Petrov
bfecb1d4a9 Disable physics by default for large networks (#51277) 2026-03-30 14:22:11 +00:00
Wendelin
69a8db00fa Fix ha-dropdown z-index for legacy browsers (#51276) 2026-03-30 13:41:44 +00:00
Maarten Lakerveld
bbda7affdc Add ability to duplicate a section (#30265)
* Add ability to duplicate a section

* Move section edit mode buttons to overflow menu

* Fix typing for concat and push parameters

* Fix incorrect clipboard typing for badges
2026-03-30 14:59:27 +02:00
Aidan Timson
10c90d222d Limit ha-toast width to window, refactor CSS (#51272)
* Limit `ha-toast` width to window and use safe width

* Query assigned slots to stop actions display

* Constrain max-width

* Increase start/end padding
2026-03-30 15:31:03 +03:00
Bram Kragten
072f70b49f Numeric threshold selector: remove duplicate uom from input (#51275) 2026-03-30 14:12:04 +02:00
Wendelin
7f2a5ecc27 Remove mobile-specific styles for date-range-picker (#51273)
Remove mobile-specific styles for date-picker component
2026-03-30 13:03:21 +02:00
Paul Bottein
a42f6f864a Reduce heading button badge font size and fix alignement (#51274)
Title: Reduce heading button badge font size and fix alignement
2026-03-30 13:02:06 +02:00
Paulus Schoutsen
a07772c514 Reload the app info after an update completes (#51261) 2026-03-30 10:42:16 +01:00
Tom Carpenter
a6ab6e218f Fix new date-range-picker rendering on small screens (#51257) 2026-03-30 11:10:06 +02:00
ildar170975
ed96657085 Add getContrastedColorHex() to be used for contrasted text & background (or vice versa) (#29032)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-03-30 10:57:28 +02:00
dependabot[bot]
50ca39722e Bump github/codeql-action from 4.34.1 to 4.35.1 (#51268)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-30 08:43:59 +01:00
Wendelin
7026e5b375 Fix date-range-picker preset selection (#51269) 2026-03-30 09:40:40 +02:00
Wendelin
37e8e1b728 Fix time input background (#51270)
Fix input color tokens
2026-03-30 09:39:57 +02:00
Paulus Schoutsen
48369854af link to supervisor logs on app install error (#51259) 2026-03-30 09:00:21 +02:00
Tom Carpenter
7715e01126 Add date range picker time validation (#51267)
* Fix base time inputs reportValidity() function

The queryAll selector returns a NodeList not not an array. Need to spread it to an array before we can use every().

* Validate the date range picker time inputs

Enable auto validation to get the nice red underline on invalid values, and then check validity before accepting the input.

* Fix automatic 24hr value conversion in AM/PM format

When using AM/PM, entering a 24 hour value will automatically convert the first time. For example 15 will become 3. However if you then enter 15 again it will stay as 15 and not update.
To fix this, make sure we trigger an update of the input field once the current update cycle is complete.

* Validate time inputs on save not value update

In the value changed callback, the update 24->12hr input correction will not have been updated and therefore they will report invalid.
2026-03-30 09:13:51 +03:00
renovate[bot]
e4ee108e14 Update vitest monorepo to v4.1.2 (#51265)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-29 17:25:23 +00:00
renovate[bot]
407609c118 Update dependency @swc/helpers to v0.5.20 (#51264)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-29 17:25:18 +00:00
dependabot[bot]
68cbaf6481 Bump node-forge from 1.3.3 to 1.4.0 (#30398)
Bumps [node-forge](https://github.com/digitalbazaar/forge) from 1.3.3 to 1.4.0.
- [Changelog](https://github.com/digitalbazaar/forge/blob/main/CHANGELOG.md)
- [Commits](https://github.com/digitalbazaar/forge/compare/v1.3.3...v1.4.0)

---
updated-dependencies:
- dependency-name: node-forge
  dependency-version: 1.4.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-03-28 13:26:07 +01:00
renovate[bot]
b9b249a317 Update dependency @codemirror/language to v6.12.3 (#30396)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-28 12:04:55 +01:00
Petar Petrov
df6578dfdf Refactor climate HVAC mode handling to be data-driven (#30356)
Refactor climate HVAC mode handling in state-history-chart-line to be data-driven

Replace repetitive per-mode detection, dataset creation, and series population
blocks with a single CLIMATE_MODE_CONFIGS array and loops over active modes.
2026-03-28 10:15:45 +01:00
ildar170975
b4a07f504c Map card editor: use context in attribute selector (#30393)
use context in attribute selector
2026-03-28 10:13:44 +01:00
Norbert Rittel
24b8078a9c Clarify multiple choice of Chart types in Device energy graph card (#30389) 2026-03-28 09:31:19 +02:00
Paul Bottein
2bd8a657b8 Revert entity naming change (#30384) 2026-03-27 21:45:54 +01:00
Wendelin
61724a52ba Fix picker field disabled background (#30385) 2026-03-27 16:32:49 +01:00
Aidan Timson
67d73261a4 Fix floating ha-toast (#30344) 2026-03-27 14:06:47 +01:00
Marcin Bauer
419ba64bed Use dedicated tab copy in automation add dialogs (#30378)
Co-authored-by: Wendelin <w@pe8.at>
2026-03-27 12:46:11 +00:00
Norbert Rittel
e87155bba3 Drop "entities" from "Group by floor / area" in Sankey diagrams (#30382) 2026-03-27 14:16:46 +02:00
Petar Petrov
d8a3939e22 Fix negative currency display on sensor card (#30359) 2026-03-27 12:29:58 +01:00
Paul Bottein
5141b6aea9 Revert "Show device name prefix in entity name field" (#30381)
Revert "Show device name prefix in entity name field (#30358)"

This reverts commit e1ac7e98b0.
2026-03-27 12:20:00 +01:00
Wendelin
9aaf1bdd07 Automation add TCA: Fix classMap usage (#30380) 2026-03-27 12:14:51 +01:00
Stefan Agner
a0e7c8e9bb Use goBack with fallback for app uninstall navigation (#30377)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:31:19 +00:00
Bram Kragten
8f482776b9 Numeric threshold selector fixes (#30350)
* Update numeric threshold

* Update ha-selector-numeric-threshold.ts
2026-03-27 09:50:21 +02:00
Norbert Rittel
2c80183ded Shorten collection_key_description to fit available space (#30376) 2026-03-27 09:41:58 +02:00
Paul Bottein
9bd5e25ee3 Only display entity name instead of friendly name in state info (#30365) 2026-03-27 09:41:11 +02:00
dependabot[bot]
994193465f Bump picomatch from 2.3.1 to 2.3.2 (#30375)
Bumps [picomatch](https://github.com/micromatch/picomatch) from 2.3.1 to 2.3.2.
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-27 08:37:15 +02:00
renovate[bot]
530b7ed457 Update dependency @rspack/core to v1.7.10 (#30374)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-27 08:17:55 +02:00
renovate[bot]
24332e8288 Update vitest monorepo to v4.1.1 (#30357)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-27 08:17:23 +02:00
renovate[bot]
980aaa2bac Update dependency typescript-eslint to v8.57.2 (#30367)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-27 08:16:47 +02:00
renovate[bot]
344856cbc4 Update dependency tar to v7.5.13 (#30371)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-27 08:16:19 +02:00
Bram Kragten
06877cbaaa Replace softprops/action-gh-release with gh release upload (#30362)
`replace softprops/action-gh-release` with `gh release upload`
2026-03-26 17:39:38 +01:00
Paul Bottein
e1ac7e98b0 Show device name prefix in entity name field (#30358) 2026-03-26 17:13:24 +01:00
Paul Bottein
4b2426ab77 Add composed/text mode toggle to entity name picker (#30337) 2026-03-26 17:05:05 +01:00
Paul Bottein
f9471d6b4c Replace computeLovelaceEntityName with hass.formatEntityName (#30351) 2026-03-26 17:01:26 +01:00
Bram Kragten
0645484258 Tighten action permissions (#30361) 2026-03-26 16:45:34 +01:00
Bram Kragten
2da3efb812 Add target error badge if target is missing (#30352)
* Add target error badge if target is missing

* Don't show for newly added items
2026-03-26 16:58:22 +02:00
Bram Kragten
e3674e550f Replace ua-parser-js with simple regexs (#30355) 2026-03-26 14:48:05 +00:00
karwosts
cff038fea4 Stabilize map colors (#30354) 2026-03-26 14:47:57 +00:00
Paulus Schoutsen
bed39fd8e9 Hide features layout option until feature added (#30353) 2026-03-26 14:39:00 +00:00
arcsur
b8346d0286 Support additional climate hvac_mode in state-history-chart-line (#30310)
* Support additional climate hvac_mode in state-history-chart-line

* Reverted changes to the chart legend to keep change focussed on the additonal hvac_mode support

* Remove the tests since they don't add value.
2026-03-26 14:36:08 +00:00
Petar Petrov
5f80b74322 Fix energy pie chart legend showing raw data instead of formatted values (#30339) 2026-03-26 16:29:16 +02:00
Paul Bottein
73e0f1e18d Use hardcoded label for temperature and humidity sensor in climate dashboard (#30348)
* Only use entity name for climate view sensors

* Use hardcoded text
2026-03-26 15:06:25 +01:00
Paul Bottein
0b16b735e8 Fix stack card scrollbar clipping box-shadows (#30346)
* Fix stack card scrollbar clipping box-shadows

* Remove grid options

* Remove scrollbar
2026-03-26 13:16:57 +01:00
Darren Griffin
c88dcf10b9 Update Figma DesignKit link (#30342) 2026-03-26 09:47:31 +00:00
Timothy
08bc23e2e7 Set tap highlight color to transparent for button (#30340) 2026-03-26 08:55:27 +00:00
pefia
97d49f06b1 Fix number formatting fallback when Number.isNaN is unavailable (#30306)
Remove unnecessary Number.isNaN polyfill from number formatting
2026-03-26 10:33:29 +02:00
Wendelin
50b727393d Add all hardware table (#30312)
* add all hardware table

* copilot review

* Updated tab names

* update localize keys

* Fix translations

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2026-03-26 09:10:09 +02:00
sir-Unknown
5c6dd2a697 Fix calendar event description not preserving line breaks (#30329)
Add `white-space: pre-line` to the event description style so that
newlines in the calendar event description are rendered correctly
instead of being collapsed into a single line.
2026-03-26 07:01:02 +01:00
311 changed files with 6434 additions and 5289 deletions

View File

@@ -5,6 +5,9 @@ updates:
schedule:
interval: weekly
time: "06:00"
cooldown:
default-days-before-reopen: 30
default-days: 7
open-pull-requests-limit: 10
labels:
- Dependencies

View File

@@ -8,6 +8,9 @@ on:
branches:
- master
permissions:
contents: read
env:
NODE_OPTIONS: --max_old_space_size=6144
@@ -24,6 +27,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: dev
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -59,6 +63,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: master
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0

View File

@@ -18,6 +18,9 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
lint:
name: Lint and check format
@@ -25,6 +28,8 @@ jobs:
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -59,6 +64,8 @@ jobs:
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -77,6 +84,8 @@ jobs:
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:

View File

@@ -7,6 +7,10 @@ on:
# The branches below must be a subset of the branches above
branches: [dev]
permissions:
contents: read
security-events: write
jobs:
analyze:
name: Analyze
@@ -28,6 +32,7 @@ jobs:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
fetch-depth: 2
persist-credentials: false
# If this run was triggered by a pull request event, then checkout
# the head of the pull request instead of the merge commit.
@@ -36,14 +41,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
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@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
uses: github/codeql-action/autobuild@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -57,4 +62,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1

View File

@@ -9,6 +9,9 @@ on:
- dev
- master
permissions:
contents: read
env:
NODE_OPTIONS: --max_old_space_size=6144
@@ -25,6 +28,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: dev
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -60,6 +64,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: master
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0

View File

@@ -5,6 +5,9 @@ on:
schedule:
- cron: "0 0 * * *"
permissions:
contents: read
env:
NODE_OPTIONS: --max_old_space_size=6144
@@ -17,6 +20,8 @@ jobs:
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0

View File

@@ -10,6 +10,9 @@ on:
branches:
- dev
permissions:
contents: read
env:
NODE_OPTIONS: --max_old_space_size=6144
@@ -22,6 +25,8 @@ jobs:
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0

View File

@@ -1,6 +1,6 @@
name: "Pull Request Labeler"
on: pull_request_target
on: pull_request_target # zizmor: ignore[dangerous-triggers] -- safe: only runs actions/labeler, no PR code checkout
jobs:
triage:

View File

@@ -5,6 +5,10 @@ on:
schedule:
- cron: "0 * * * *"
permissions:
issues: write
pull-requests: write
jobs:
lock:
runs-on: ubuntu-latest

View File

@@ -21,6 +21,8 @@ jobs:
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6

View File

@@ -1,25 +1,39 @@
name: RelativeCI
on:
# zizmor: ignore[dangerous-triggers] -- safe: only downloads artifacts, no PR code checkout
workflow_run:
workflows: [CI]
types:
- completed
permissions:
contents: read
actions: read
jobs:
upload:
name: Upload stats
upload-frontend-modern:
name: Upload stats (frontend/modern)
if: ${{ github.event.workflow_run.conclusion == 'success' }}
strategy:
matrix:
bundle: [frontend]
build: [modern, legacy]
runs-on: ubuntu-latest
steps:
- name: Send bundle stats and build information to RelativeCI
uses: relative-ci/agent-action@3c681926017930047fc03acaa35cd6a44efcbfc3 # v3.2.2
with:
key: ${{ secrets[format('RELATIVE_CI_KEY_{0}_{1}', matrix.bundle, matrix.build)] }}
key: ${{ secrets.RELATIVE_CI_KEY_frontend_modern }}
token: ${{ github.token }}
artifactName: ${{ format('{0}-bundle-stats', matrix.bundle) }}
webpackStatsFile: ${{ format('{0}-{1}.json', matrix.bundle, matrix.build) }}
artifactName: frontend-bundle-stats
webpackStatsFile: frontend-modern.json
upload-frontend-legacy:
name: Upload stats (frontend/legacy)
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
steps:
- name: Send bundle stats and build information to RelativeCI
uses: relative-ci/agent-action@3c681926017930047fc03acaa35cd6a44efcbfc3 # v3.2.2
with:
key: ${{ secrets.RELATIVE_CI_KEY_frontend_legacy }}
token: ${{ github.token }}
artifactName: frontend-bundle-stats
webpackStatsFile: frontend-legacy.json

View File

@@ -27,6 +27,8 @@ jobs:
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
@@ -34,13 +36,12 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@master
uses: home-assistant/actions/helpers/verify-version@d56d093b9ab8d2105bc0cb6ee9bcc0ef4ec8b96d # master
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install
@@ -62,11 +63,10 @@ jobs:
skip-existing: true
- name: Upload release assets
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
with:
files: |
dist/*.whl
dist/*.tar.gz
env:
GH_TOKEN: ${{ github.token }}
TAG_NAME: ${{ github.event.release.tag_name }}
run: gh release upload "$TAG_NAME" dist/*.whl dist/*.tar.gz --clobber
wheels-init:
name: Init wheels build
@@ -74,15 +74,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Generate requirements.txt
env:
GITHUB_REF: ${{ github.ref }}
run: |
# Sleep to give pypi time to populate the new version across mirrors
sleep 240
version=$(echo "${{ github.ref }}" | awk -F"/" '{print $NF}' )
version=$(echo "$GITHUB_REF" | awk -F"/" '{print $NF}' )
echo "home-assistant-frontend==$version" > ./requirements.txt
# home-assistant/wheels doesn't support SHA pinning
- name: Build wheels
uses: home-assistant/wheels@2025.12.0
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
with:
abi: cp314
tag: musllinux_1_2
@@ -99,11 +101,12 @@ jobs:
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install
- name: Download Translations
@@ -113,8 +116,11 @@ jobs:
- name: Build landing-page
run: landing-page/script/build_landing_page
- name: Tar folder
run: tar -czf landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz -C landing-page/dist .
env:
TAG_NAME: ${{ github.event.release.tag_name }}
run: tar -czf "landing-page/home_assistant_frontend_landingpage-${TAG_NAME}.tar.gz" -C landing-page/dist .
- name: Upload release asset
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
with:
files: landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz
env:
GH_TOKEN: ${{ github.token }}
TAG_NAME: ${{ github.event.release.tag_name }}
run: gh release upload "$TAG_NAME" "landing-page/home_assistant_frontend_landingpage-${TAG_NAME}.tar.gz" --clobber

View File

@@ -5,6 +5,10 @@ on:
schedule:
- cron: "0 * * * *"
permissions:
issues: write
pull-requests: write
jobs:
stale:
runs-on: ubuntu-latest

View File

@@ -8,6 +8,9 @@ on:
paths:
- src/translations/en.json
permissions:
contents: read
jobs:
upload:
name: Upload
@@ -15,6 +18,8 @@ jobs:
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Upload Translations
run: |

View File

@@ -6,9 +6,9 @@ import rootConfig from "../eslint.config.mjs";
export default tseslint.config(...rootConfig, {
rules: {
"no-console": "off",
"import/no-extraneous-dependencies": "off",
"import/extensions": "off",
"import/no-dynamic-require": "off",
"import-x/no-extraneous-dependencies": "off",
"import-x/extensions": "off",
"import-x/no-dynamic-require": "off",
"global-require": "off",
"@typescript-eslint/no-require-imports": "off",
"prefer-arrow-callback": "off",

View File

@@ -99,6 +99,44 @@ const lokaliseProjects = {
frontend: "3420425759f6d6d241f598.13594006",
};
const POLL_INTERVAL_MS = 1000;
/* eslint-disable no-await-in-loop */
async function pollProcess(lokaliseApi, projectId, processId) {
while (true) {
const process = await lokaliseApi
.queuedProcesses()
.get(processId, { project_id: projectId });
const project =
projectId === lokaliseProjects.backend ? "backend" : "frontend";
if (process.status === "finished") {
console.log(`Lokalise export process for ${project} finished`);
return process;
}
if (process.status === "failed" || process.status === "cancelled") {
throw new Error(
`Lokalise export process for ${project} ${process.status}: ${process.message}`
);
}
console.log(
`Lokalise export process for ${project} in progress...`,
process.status,
process.details?.items_to_process
? `${Math.round(((process.details.items_processed || 0) / process.details.items_to_process) * 100)}% (${process.details.items_processed}/${process.details.items_to_process})`
: ""
);
await new Promise((resolve) => {
setTimeout(resolve, POLL_INTERVAL_MS);
});
}
}
/* eslint-enable no-await-in-loop */
gulp.task("fetch-lokalise", async function () {
let apiKey;
try {
@@ -118,55 +156,60 @@ gulp.task("fetch-lokalise", async function () {
]);
await Promise.all(
Object.entries(lokaliseProjects).map(([project, projectId]) =>
lokaliseApi
.files()
.download(projectId, {
format: "json",
original_filenames: false,
replace_breaks: false,
json_unescaped_slashes: true,
export_empty_as: "skip",
filter_data: ["verified"],
})
.then((download) => fetch(download.bundle_url))
.then((response) => {
if (response.status === 200 || response.status === 0) {
return response.arrayBuffer();
}
Object.entries(lokaliseProjects).map(async ([project, projectId]) => {
try {
const exportProcess = await lokaliseApi
.files()
.async_download(projectId, {
format: "json",
original_filenames: false,
replace_breaks: false,
json_unescaped_slashes: true,
export_empty_as: "skip",
filter_data: ["verified"],
});
const finishedProcess = await pollProcess(
lokaliseApi,
projectId,
exportProcess.process_id
);
const bundleUrl = finishedProcess.details.download_url;
console.log(`Downloading translations from: ${bundleUrl}`);
const response = await fetch(bundleUrl);
if (response.status !== 200 && response.status !== 0) {
throw new Error(response.statusText);
})
.then(JSZip.loadAsync)
.then(async (contents) => {
await mkdirPromise;
return Promise.all(
Object.keys(contents.files).map(async (filename) => {
const file = contents.file(filename);
if (!file) {
// no file, probably a directory
return Promise.resolve();
}
return file
.async("nodebuffer")
.then((content) =>
fs.writeFile(
path.join(
inDir,
project,
filename.split("/").splice(-1)[0]
),
content,
{ flag: "w", encoding }
)
);
})
);
})
.catch((err) => {
console.error(err);
throw err;
})
)
}
console.log(`Extracting translations...`);
const contents = await JSZip.loadAsync(await response.arrayBuffer());
await mkdirPromise;
await Promise.all(
Object.keys(contents.files).map(async (filename) => {
const file = contents.file(filename);
if (!file) {
// no file, probably a directory
return;
}
const content = await file.async("nodebuffer");
await fs.writeFile(
path.join(inDir, project, filename.split("/").splice(-1)[0]),
content,
{ flag: "w", encoding }
);
})
);
} catch (err) {
console.error(err);
throw err;
}
})
);
});

View File

@@ -6,7 +6,6 @@ import presetEnv from "@babel/preset-env";
import compilationTargets from "@babel/helper-compilation-targets";
import coreJSCompat from "core-js-compat";
import { logPlugin } from "@babel/preset-env/lib/debug.js";
// eslint-disable-next-line import/no-relative-packages
import shippedPolyfills from "../node_modules/babel-plugin-polyfill-corejs3/lib/shipped-proposals.js";
import { babelOptions } from "./bundle.cjs";

View File

@@ -26,7 +26,7 @@ import "../../../../src/components/ha-svg-icon";
import "../../../../src/layouts/hass-loading-screen";
import { registerServiceWorker } from "../../../../src/util/register-service-worker";
import "./hc-layout";
import "../../../../src/components/ha-textfield";
import "../../../../src/components/input/ha-input";
import "../../../../src/components/ha-button";
const seeFAQ = (qid) => html`
@@ -123,11 +123,11 @@ export class HcConnect extends LitElement {
To get started, enter your Home Assistant URL and click authorize.
If you want a preview instead, click the show demo button.
</p>
<ha-textfield
<ha-input
label="Home Assistant URL"
placeholder="https://abcdefghijklmnop.ui.nabu.casa"
@keydown=${this._handleInputKeyDown}
></ha-textfield>
></ha-input>
${this.error ? html` <p class="error">${this.error}</p> ` : ""}
</div>
<div class="card-actions">
@@ -204,7 +204,7 @@ export class HcConnect extends LitElement {
}
private async _handleConnect() {
const inputEl = this.shadowRoot!.querySelector("ha-textfield")!;
const inputEl = this.shadowRoot!.querySelector("ha-input")!;
const value = inputEl.value || "";
this.error = undefined;
@@ -319,7 +319,7 @@ export class HcConnect extends LitElement {
flex: 1;
}
ha-textfield {
ha-input {
width: 100%;
}
`;

View File

@@ -11,9 +11,9 @@ export const demoConfigs: (() => Promise<DemoConfig>)[] = [
() => import("./jimpower").then((mod) => mod.demoJimpower),
];
// eslint-disable-next-line import/no-mutable-exports
// eslint-disable-next-line import-x/no-mutable-exports
export let selectedDemoConfigIndex = 0;
// eslint-disable-next-line import/no-mutable-exports
// eslint-disable-next-line import-x/no-mutable-exports
export let selectedDemoConfig: Promise<DemoConfig> =
demoConfigs[selectedDemoConfigIndex]();

View File

@@ -1,3 +1,4 @@
/// <reference types="chromecast-caf-sender" />
import { mdiTelevision } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";

View File

@@ -1,6 +1,5 @@
// @ts-check
/* eslint-disable import/no-extraneous-dependencies */
import unusedImports from "eslint-plugin-unused-imports";
import globals from "globals";
import path from "node:path";
@@ -13,6 +12,7 @@ import { configs as litConfigs } from "eslint-plugin-lit";
import { configs as wcConfigs } from "eslint-plugin-wc";
import { configs as a11yConfigs } from "eslint-plugin-lit-a11y";
import html from "@html-eslint/eslint-plugin";
import importX from "eslint-plugin-import-x";
const _filename = fileURLToPath(import.meta.url);
const _dirname = path.dirname(_filename);
@@ -22,8 +22,27 @@ const compat = new FlatCompat({
allConfig: js.configs.all,
});
// Load airbnb-base via FlatCompat for non-import rules only.
// eslint-plugin-import is incompatible with ESLint 10 (uses removed APIs),
// so we strip its plugin/rules/settings and use eslint-plugin-import-x instead.
const airbnbConfigs = compat.extends("airbnb-base").map((config) => {
const { plugins = {}, rules = {}, settings = {}, ...rest } = config;
return {
...rest,
plugins: Object.fromEntries(
Object.entries(plugins).filter(([key]) => key !== "import")
),
rules: Object.fromEntries(
Object.entries(rules).filter(([key]) => !key.startsWith("import/"))
),
settings: Object.fromEntries(
Object.entries(settings).filter(([key]) => !key.startsWith("import/"))
),
};
});
export default tseslint.config(
...compat.extends("airbnb-base"),
...airbnbConfigs,
eslintConfigPrettier,
litConfigs["flat/all"],
tseslint.configs.recommended,
@@ -31,6 +50,7 @@ export default tseslint.config(
tseslint.configs.stylistic,
wcConfigs["flat/recommended"],
a11yConfigs.recommended,
importX.flatConfigs.recommended,
{
plugins: {
"unused-imports": unusedImports,
@@ -58,7 +78,7 @@ export default tseslint.config(
},
settings: {
"import/resolver": {
"import-x/resolver": {
webpack: {
config: "./rspack.config.cjs",
},
@@ -87,12 +107,20 @@ export default tseslint.config(
"prefer-destructuring": "off",
"no-restricted-globals": [2, "event"],
"prefer-promise-reject-errors": "off",
"import/prefer-default-export": "off",
"import/no-default-export": "off",
"import/no-unresolved": "off",
"import/no-cycle": "off",
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
"object-curly-newline": "off",
"default-case": "off",
"wc/no-self-class": "off",
"no-shadow": "off",
"no-use-before-define": "off",
"import/extensions": [
// import-x rules (migrated from eslint-plugin-import / airbnb-base)
"import-x/named": "off",
"import-x/prefer-default-export": "off",
"import-x/no-default-export": "off",
"import-x/no-unresolved": "off",
"import-x/no-cycle": "off",
"import-x/extensions": [
"error",
"ignorePackages",
{
@@ -100,12 +128,24 @@ export default tseslint.config(
js: "never",
},
],
"import-x/no-mutable-exports": "error",
"import-x/no-amd": "error",
"import-x/first": "error",
"import-x/order": [
"error",
{ groups: [["builtin", "external", "internal"]] },
],
"import-x/newline-after-import": "error",
"import-x/no-absolute-path": "error",
"import-x/no-dynamic-require": "error",
"import-x/no-webpack-loader-syntax": "error",
"import-x/no-named-default": "error",
"import-x/no-self-import": "error",
"import-x/no-useless-path-segments": ["error", { commonjs: true }],
"import-x/no-import-module-exports": ["error", { exceptions: [] }],
"import-x/no-relative-packages": "error",
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
"object-curly-newline": "off",
"default-case": "off",
"wc/no-self-class": "off",
"no-shadow": "off",
// TypeScript rules
"@typescript-eslint/camelcase": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-use-before-define": "off",
@@ -185,7 +225,6 @@ export default tseslint.config(
allowObjectTypes: "always",
},
],
"no-use-before-define": "off",
},
},
{
@@ -194,6 +233,12 @@ export default tseslint.config(
globals: globals.audioWorklet,
},
},
{
files: ["src/entrypoints/service-worker.ts"],
languageOptions: {
globals: globals.serviceworker,
},
},
{
plugins: {
html,

View File

@@ -1,5 +1,5 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
@@ -692,7 +692,11 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
([key, value]) => html`
<ha-settings-row narrow slot=${slot}>
<span slot="heading">${value?.name || key}</span>
<span slot="description">${value?.description}</span>
${value?.description
? html`<span slot="description"
>${value?.description}</span
>`
: nothing}
<ha-selector
.hass=${this.hass}
.selector=${value!.selector}

View File

@@ -19,7 +19,7 @@ The Home Assistant interface is based on Material Design. It's a design system c
We want to make it as easy for designers to contribute as it is for developers. Theres a lot a designer can contribute to:
- Meet us at <a href="https://www.home-assistant.io/join-chat-design" rel="noopener noreferrer" target="_blank">Discord #designers channel</a>. If you can't see the channel, make sure you set the correct role in Channels & Roles.
- Start designing with our <a href="https://www.figma.com/community/file/967153512097289521/Home-Assistant-DesignKit" rel="noopener noreferrer" target="_blank">Figma DesignKit</a>.
- Start designing with our <a href="https://www.figma.com/design/2WGI8IDGyxINjSV6NRvPur/Home-Assistant-Design-Kit" rel="noopener noreferrer" target="_blank">Figma DesignKit</a>.
- Find the latest UX <a href="https://github.com/home-assistant/frontend/discussions?discussions_q=label%3Aux" rel="noopener noreferrer" target="_blank">discussions</a> and <a href="https://github.com/home-assistant/frontend/labels/ux" rel="noopener noreferrer" target="_blank">issues</a> on GitHub. Everyone can start a new issue or discussion!
## Developers

View File

@@ -134,6 +134,21 @@ const CONFIGS = [
entity: sensor.not_working
`,
},
{
heading: "Lower minimum",
config: `
- type: gauge
entity: sensor.brightness_high
needle: true
severity:
green: 0
yellow: 0.45
red: 0.9
min: -0.05
name: " "
max: 1.9
unit: GBP/h`,
},
];
@customElement("demo-lovelace-gauge-card")

View File

@@ -422,7 +422,6 @@ export class DemoEntityState extends LitElement {
return html`
<ha-data-table
.hass=${this.hass}
.columns=${this._columns(this.hass)}
.data=${this._rows()}
auto-height

View File

@@ -1,6 +1,5 @@
import "@material/mwc-linear-progress/mwc-linear-progress";
import { mdiArrowCollapseDown, mdiDownload } from "@mdi/js";
// eslint-disable-next-line import/extensions
import { IntersectionController } from "@lit-labs/observers/intersection-controller.js";
import { LitElement, type PropertyValues, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";

View File

@@ -1,6 +1,6 @@
export default {
"*.?(c|m){js,ts}": [
"eslint --flag v10_config_lookup_from_file --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --fix",
"eslint --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --fix",
"prettier --cache --write",
"lit-analyzer --quiet",
],

View File

@@ -8,8 +8,8 @@
"version": "1.0.0",
"scripts": {
"build": "script/build_frontend",
"lint:eslint": "eslint --flag v10_config_lookup_from_file \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --max-warnings=0",
"format:eslint": "eslint --flag v10_config_lookup_from_file \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
"lint:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --max-warnings=0",
"format:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
"lint:prettier": "prettier . --cache --check",
"format:prettier": "prettier . --cache --write",
"lint:types": "tsc",
@@ -30,7 +30,7 @@
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.1",
"@codemirror/commands": "6.10.3",
"@codemirror/language": "6.12.2",
"@codemirror/language": "6.12.3",
"@codemirror/legacy-modes": "6.5.2",
"@codemirror/search": "6.6.0",
"@codemirror/state": "6.6.0",
@@ -82,7 +82,7 @@
"@mdi/js": "7.4.47",
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.19",
"@swc/helpers": "0.5.20",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "3.9.1",
"@tsparticles/preset-links": "3.2.0",
@@ -129,7 +129,6 @@
"stacktrace-js": "2.0.2",
"superstruct": "2.0.2",
"tinykeys": "3.0.0",
"ua-parser-js": "2.0.9",
"weekstart": "2.0.0",
"workbox-cacheable-response": "7.4.0",
"workbox-core": "7.4.0",
@@ -145,13 +144,15 @@
"@babel/plugin-transform-runtime": "7.29.0",
"@babel/preset-env": "7.29.2",
"@bundle-stats/plugin-webpack-filter": "4.22.0",
"@eslint/eslintrc": "3.3.4",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.58.1",
"@lokalise/node-api": "15.6.1",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.5",
"@rspack/core": "1.7.9",
"@rspack/core": "1.7.10",
"@rspack/dev-server": "1.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.25",
@@ -169,18 +170,18 @@
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.1.0",
"@vitest/coverage-v8": "4.1.2",
"babel-loader": "10.1.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
"del": "8.0.1",
"eslint": "9.39.4",
"eslint": "10.1.0",
"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-import-x": "4.16.2",
"eslint-plugin-lit": "2.2.1",
"eslint-plugin-lit-a11y": "5.1.1",
"eslint-plugin-unused-imports": "4.4.1",
@@ -188,6 +189,7 @@
"fancy-log": "2.0.0",
"fs-extra": "11.3.4",
"glob": "13.0.6",
"globals": "17.4.0",
"gulp": "5.0.1",
"gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0",
@@ -206,13 +208,13 @@
"rspack-manifest-plugin": "5.2.1",
"serve": "14.2.6",
"sinon": "21.0.3",
"tar": "7.5.12",
"tar": "7.5.13",
"terser-webpack-plugin": "5.4.0",
"ts-lit-plugin": "2.0.2",
"typescript": "5.9.3",
"typescript-eslint": "8.57.1",
"typescript": "6.0.2",
"typescript-eslint": "8.57.2",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.0",
"vitest": "4.1.2",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.0#~/.yarn/patches/workbox-build-npm-7.4.0-c84561662c.patch"

View File

@@ -1,4 +1,5 @@
/* eslint-disable no-console */
/// <reference types="chromecast-caf-sender" />
import type { Auth } from "home-assistant-js-websocket";
import { castApiAvailable } from "./cast_framework";

View File

@@ -32,6 +32,12 @@ const YAML_ONLY_THEMES_COLORS = new Set([
"disabled",
]);
/**
* Compose a CSS variable out of a theme color
* @param color - Theme color (examples: `red`, `primary-text`)
* @returns CSS variable in `--xxx-color` format;
* initial color if not found in theme colors
*/
export function computeCssVariableName(color: string): string {
if (THEME_COLORS.has(color) || YAML_ONLY_THEMES_COLORS.has(color)) {
return `--${color}-color`;
@@ -39,6 +45,12 @@ export function computeCssVariableName(color: string): string {
return color;
}
/**
* Compose a CSS variable out of a theme color & then resolve it
* @param color - Theme color (examples: `red`, `primary-text`)
* @returns Resolved CSS variable in `var(--xxx-color)` format;
* initial color if not found in theme colors
*/
export function computeCssColor(color: string): string {
const cssVarName = computeCssVariableName(color);
if (cssVarName !== color) {
@@ -47,6 +59,22 @@ export function computeCssColor(color: string): string {
return color;
}
/**
* Get a color from document's styles
* @param color - Named theme color (examples: `red`, `primary-text`)
* @returns Resolved color; initial color if not found in document's styles
*/
export function resolveThemeColor(color: string): string {
const cssColor = computeCssVariableName(color);
if (cssColor.startsWith("--")) {
const resolved = getComputedStyle(document.body)
.getPropertyValue(cssColor)
.trim();
return resolved || color;
}
return cssColor;
}
/**
* Validates if a string is a valid color.
* Accepts: hex colors (#xxx, #xxxxxx), theme colors, and valid CSS color names.

View File

@@ -1,5 +1,6 @@
import colors from "color-name";
import { expandHex } from "./hex";
import { resolveThemeColor } from "./compute-color";
const rgb_hex = (component: number): string => {
const hex = Math.round(Math.min(Math.max(component, 0), 255)).toString(16);
@@ -130,26 +131,43 @@ export const rgb2hs = (rgb: [number, number, number]): [number, number] =>
export const hs2rgb = (hs: [number, number]): [number, number, number] =>
hsv2rgb([hs[0], hs[1], 255]);
export function theme2hex(themeColor: string): string {
if (themeColor.startsWith("#")) {
if (themeColor.length === 4 || themeColor.length === 5) {
const c = themeColor;
/**
* Attempt to get a HEX color from a color defined in different formats:
* HEX, rgb/rgba, named color
* @param color - Color (HEX, rgb/rgba, named color) to be converted to HEX
* @returns HEX color
*/
export function theme2hex(color: string): string {
// Attempting to find a HEX pattern in the input string
if (color.startsWith("#")) {
if (color.length === 4 || color.length === 5) {
const c = color;
// Convert short-form hex (#abc) to 6 digit (#aabbcc). Ignore alpha channel.
return `#${c[1]}${c[1]}${c[2]}${c[2]}${c[3]}${c[3]}`;
}
if (themeColor.length === 9) {
if (color.length === 9) {
// Ignore alpha channel.
return themeColor.substring(0, 7);
return color.substring(0, 7);
}
return themeColor;
return color;
}
const rgbFromColorName = colors[themeColor.toLowerCase()];
if (rgbFromColorName) {
return rgb2hex(rgbFromColorName);
// Attempting to find a match in a HA Frontend theme colors
const themeColor = resolveThemeColor(color.toLowerCase());
if (themeColor !== color.toLowerCase()) {
// theme color is recognized, now re-attempt
return theme2hex(themeColor);
}
const rgbMatch = themeColor.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
// Attempting to find a match in a web colors array
const rgbFromWebColor = colors[color.toLowerCase()];
if (rgbFromWebColor) {
// HEX color is recognized for the input named color
return rgb2hex(rgbFromWebColor);
}
// Attempting to find an RGB pattern in the input string
const rgbMatch = color.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (rgbMatch) {
const [, r, g, b] = rgbMatch.map(Number);
return rgb2hex([r, g, b]);
@@ -158,5 +176,5 @@ export function theme2hex(themeColor: string): string {
// We have a named color, and there's nothing in the table,
// so nothing further we can do with it.
// Compare/border/background color will all be the same.
return themeColor;
return color;
}

View File

@@ -1,4 +1,5 @@
import { wcagLuminance, wcagContrast } from "culori";
import { theme2hex } from "./convert-color";
/**
* Calculates the luminosity of an RGB color.
@@ -48,3 +49,13 @@ export const getRGBContrastRatio = (
rgb1: [number, number, number],
rgb2: [number, number, number]
) => Math.round((rgbContrast(rgb1, rgb2) + Number.EPSILON) * 100) / 100;
/**
* Returns a contrasted color (black or white) based on the luminance of another color
* @param color - Color (HEX, rgb/rgba, named color) to calculate a contrasted color
* @returns HEX color ("#000000" for dark backgrounds, "#ffffff" for light backgrounds)
*/
export const getContrastedColorHex = (color: string): string => {
const lum = wcagLuminance(theme2hex(color));
return lum > 0.5 ? "#000000" : "#ffffff";
};

View File

@@ -1,6 +1,9 @@
import { listenMediaQuery } from "../dom/media_query";
import type { HomeAssistant } from "../../types";
import type { Condition } from "../../panels/lovelace/common/validate-condition";
import type {
Condition,
ConditionContext,
} from "../../panels/lovelace/common/validate-condition";
import { checkConditionsMet } from "../../panels/lovelace/common/validate-condition";
import { extractMediaQueries, extractTimeConditions } from "./extract";
import { calculateNextTimeUpdate } from "./time-calculator";
@@ -19,7 +22,8 @@ export function setupMediaQueryListeners(
conditions: Condition[],
hass: HomeAssistant,
addListener: (unsub: () => void) => void,
onUpdate: (conditionsMet: boolean) => void
onUpdate: (conditionsMet: boolean) => void,
getContext?: () => ConditionContext
): void {
const mediaQueries = extractMediaQueries(conditions);
@@ -36,7 +40,8 @@ export function setupMediaQueryListeners(
if (hasOnlyMediaQuery) {
onUpdate(matches);
} else {
const conditionsMet = checkConditionsMet(conditions, hass);
const context = getContext?.() ?? {};
const conditionsMet = checkConditionsMet(conditions, hass, context);
onUpdate(conditionsMet);
}
});
@@ -51,7 +56,8 @@ export function setupTimeListeners(
conditions: Condition[],
hass: HomeAssistant,
addListener: (unsub: () => void) => void,
onUpdate: (conditionsMet: boolean) => void
onUpdate: (conditionsMet: boolean) => void,
getContext?: () => ConditionContext
): void {
const timeConditions = extractTimeConditions(conditions);
@@ -70,7 +76,8 @@ export function setupTimeListeners(
timeoutId = setTimeout(() => {
if (delay <= MAX_TIMEOUT_DELAY) {
const conditionsMet = checkConditionsMet(conditions, hass);
const context = getContext?.() ?? {};
const conditionsMet = checkConditionsMet(conditions, hass, context);
onUpdate(conditionsMet);
}
scheduleUpdate();
@@ -87,3 +94,17 @@ export function setupTimeListeners(
scheduleUpdate();
});
}
/**
* Sets up all condition listeners (media query, time) for conditional visibility.
*/
export function setupConditionListeners(
conditions: Condition[],
hass: HomeAssistant,
addListener: (unsub: () => void) => void,
onUpdate: (conditionsMet: boolean) => void,
getContext?: () => ConditionContext
): void {
setupMediaQueryListeners(conditions, hass, addListener, onUpdate, getContext);
setupTimeListeners(conditions, hass, addListener, onUpdate, getContext);
}

View File

@@ -4,11 +4,14 @@ import type {
EntityRegistryEntry,
} from "../../data/entity/entity_registry";
import type { HomeAssistant } from "../../types";
import { computeDeviceName } from "./compute_device_name";
import { computeStateName } from "./compute_state_name";
import { stripPrefixFromEntityName } from "./strip_prefix_from_entity_name";
export const computeEntityName = (
stateObj: HassEntity,
entities: HomeAssistant["entities"]
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"]
): string | undefined => {
const entry = entities[stateObj.entity_id] as
| EntityRegistryDisplayEntry
@@ -18,22 +21,49 @@ export const computeEntityName = (
// Fall back to state name if not in the entity registry (friendly name)
return computeStateName(stateObj);
}
return computeEntityEntryName(entry);
return computeEntityEntryName(entry, devices);
};
export const computeEntityEntryName = (
entry: EntityRegistryDisplayEntry | EntityRegistryEntry
entry: EntityRegistryDisplayEntry | EntityRegistryEntry,
devices: HomeAssistant["devices"],
fallbackStateObj?: HassEntity
): string | undefined => {
if (entry.name != null) {
return entry.name;
const name =
entry.name ||
("original_name" in entry && entry.original_name != null
? String(entry.original_name)
: undefined);
const device = entry.device_id ? devices[entry.device_id] : undefined;
if (!device) {
if (name) {
return name;
}
if (fallbackStateObj) {
return computeStateName(fallbackStateObj);
}
return undefined;
}
if ("original_name" in entry && entry.original_name != null) {
return String(entry.original_name);
const deviceName = computeDeviceName(device);
// If the device name is the same as the entity name, consider empty entity name
if (deviceName === name) {
return undefined;
}
return undefined;
// Remove the device name from the entity name if it starts with it
if (deviceName && name) {
return stripPrefixFromEntityName(name, deviceName) || name;
}
return name;
};
export const entityUseDeviceName = (
stateObj: HassEntity,
entities: HomeAssistant["entities"]
): boolean => !computeEntityName(stateObj, entities);
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"]
): boolean => !computeEntityName(stateObj, entities, devices);

View File

@@ -5,6 +5,7 @@ import { computeAreaName } from "./compute_area_name";
import { computeDeviceName } from "./compute_device_name";
import { computeEntityName, entityUseDeviceName } from "./compute_entity_name";
import { computeFloorName } from "./compute_floor_name";
import { computeStateName } from "./compute_state_name";
import { getEntityContext } from "./context/get_entity_context";
const DEFAULT_SEPARATOR = " ";
@@ -29,14 +30,23 @@ export interface EntityNameOptions {
export const computeEntityNameDisplay = (
stateObj: HassEntity,
name: EntityNameItem | EntityNameItem[] | undefined,
name: string | EntityNameItem | EntityNameItem[] | undefined,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"],
options?: EntityNameOptions
) => {
let items = ensureArray(name || DEFAULT_ENTITY_NAME);
if (typeof name === "string") {
return name;
}
// If no name config is provided, fall back to the friendly name
if (!name) {
return computeStateName(stateObj);
}
let items = ensureArray(name);
const separator = options?.separator ?? DEFAULT_SEPARATOR;
@@ -45,7 +55,7 @@ export const computeEntityNameDisplay = (
return items.map((item) => item.text).join(separator);
}
const useDeviceName = entityUseDeviceName(stateObj, entities);
const useDeviceName = entityUseDeviceName(stateObj, entities, devices);
// If entity uses device name, and device is not already included, replace it with device name
if (useDeviceName) {
@@ -91,7 +101,7 @@ export const computeEntityNameList = (
const names = name.map((item) => {
switch (item.type) {
case "entity":
return computeEntityName(stateObj, entities);
return computeEntityName(stateObj, entities, devices);
case "device":
return device ? computeDeviceName(device) : undefined;
case "area":

View File

@@ -142,9 +142,10 @@ const computeStateToPartsFromEntityAttributes = (
group: "value",
decimal: "value",
fraction: "value",
minusSign: "value",
plusSign: "value",
literal: "literal",
currency: "unit",
minusSign: "value",
};
const valueParts: ValuePart[] = [];
@@ -153,7 +154,7 @@ const computeStateToPartsFromEntityAttributes = (
const type = TYPE_MAP[part.type];
if (!type) continue;
const last = valueParts[valueParts.length - 1];
// Merge consecutive numeric parts (e.g. "1" + "," + "234" + "." + "56" → "1,234.56")
// Merge consecutive value parts (e.g. "-" + "12" + "." + "00" → "-12.00")
if (type === "value" && last?.type === "value") {
last.value += part.value;
} else {

View File

@@ -0,0 +1,11 @@
/**
* Indicates whether the current browser supports the Popover API.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Popover_API
*/
export const popoverSupported = globalThis?.HTMLElement?.prototype
? Object.prototype.hasOwnProperty.call(
globalThis.HTMLElement.prototype,
"popover"
)
: false;

View File

@@ -10,13 +10,10 @@
*
* @see https://github.com/home-assistant/frontend/issues/28732
*/
// eslint-disable-next-line import/extensions
import { directive, Directive } from "lit-html/directive.js";
// eslint-disable-next-line import/extensions
import { setCommittedValue } from "lit-html/directive-helpers.js";
// eslint-disable-next-line lit/no-legacy-imports
import { nothing } from "lit-html";
// eslint-disable-next-line import/extensions
import type { Part } from "lit-html/directive.js";
class KeyedES5 extends Directive {

View File

@@ -71,13 +71,6 @@ export const formatNumberToParts = (
? numberFormatToLocale(localeOptions)
: undefined;
// Polyfill for Number.isNaN, which is more reliable than the global isNaN()
Number.isNaN =
Number.isNaN ||
function isNaN(input) {
return typeof input === "number" && isNaN(input);
};
if (
localeOptions?.number_format !== NumberFormat.none &&
!Number.isNaN(Number(num))

View File

@@ -5,12 +5,41 @@ import {
formatDateMonthYear,
formatDateVeryShort,
formatDateWeekdayShort,
formatDateYear,
} from "../../common/datetime/format_date";
import {
formatTime,
formatTimeWithSeconds,
} from "../../common/datetime/format_time";
export function getPeriodicAxisLabelConfig(
period: string,
locale: FrontendLocaleData,
config: HassConfig
):
| {
formatter: (value: number) => string;
}
| undefined {
if (period === "month") {
return {
formatter: (value: number) => {
const date = new Date(value);
return date.getMonth() === 0
? `{bold|${formatDateMonthYear(date, locale, config)}}`
: formatDateMonth(date, locale, config);
},
};
}
if (period === "year") {
return {
formatter: (value: number) =>
formatDateYear(new Date(value), locale, config),
};
}
return undefined;
}
export function formatTimeLabel(
value: number | Date,
locale: FrontendLocaleData,

View File

@@ -91,6 +91,10 @@ export class HaChartBase extends LitElement {
private _lastTapTime?: number;
private _longPressTimer?: ReturnType<typeof setTimeout>;
private _longPressTriggered = false;
private _shouldResizeChart = false;
private _resizeAnimationDuration?: number;
@@ -128,6 +132,7 @@ export class HaChartBase extends LitElement {
public disconnectedCallback() {
super.disconnectedCallback();
this._legendPointerCancel();
this._pendingSetup = false;
while (this._listeners.length) {
this._listeners.pop()!();
@@ -302,22 +307,31 @@ export class HaChartBase extends LitElement {
`;
}
private _renderLegend() {
private _getLegendItems() {
if (!this.options?.legend || !this.data) {
return nothing;
return undefined;
}
const legend = ensureArray(this.options.legend).find(
(l) => l.show && l.type === "custom"
) as CustomLegendOption | undefined;
if (!legend) {
return nothing;
return undefined;
}
const datasets = ensureArray(this.data);
const items =
return (
legend.data ||
datasets
.filter((d) => (d.data as any[])?.length && (d.id || d.name))
.map((d) => ({ id: d.id, name: d.name }));
.map((d) => ({ id: d.id, name: d.name }))
);
}
private _renderLegend() {
const items = this._getLegendItems();
if (!items) {
return nothing;
}
const datasets = ensureArray(this.data!);
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
@@ -362,6 +376,11 @@ export class HaChartBase extends LitElement {
return html`<li
.id=${id}
@click=${this._legendClick}
@pointerdown=${this._legendPointerDown}
@pointerup=${this._legendPointerCancel}
@pointerleave=${this._legendPointerCancel}
@pointercancel=${this._legendPointerCancel}
@contextmenu=${this._legendContextMenu}
class=${classMap({ hidden: this._hiddenDatasets.has(id) })}
.title=${name}
>
@@ -590,7 +609,10 @@ export class HaChartBase extends LitElement {
// "boundaryFilter" is a custom mode added via axis-proxy-patch.ts.
// It rescales the Y-axis to the visible data while keeping one point
// just outside each boundary to avoid line gaps at the zoom edges.
filterMode: "boundaryFilter" as any,
// Only use it for line charts — it causes issues with bar charts.
filterMode: (ensureArray(this.data).every((s) => s.type === "line")
? "boundaryFilter"
: "filter") as any,
xAxisIndex: 0,
moveOnMouseMove: !this._isTouchDevice || this._isZoomed,
preventDefaultMouseMove: !this._isTouchDevice || this._isZoomed,
@@ -632,7 +654,7 @@ export class HaChartBase extends LitElement {
hideOverlap: true,
...axis.axisLabel,
},
minInterval,
minInterval: axis.minInterval ?? minInterval,
} as XAXisOption;
});
}
@@ -1022,11 +1044,52 @@ export class HaChartBase extends LitElement {
fireEvent(this, "chart-zoom", { start, end });
}
private _legendClick(ev: any) {
// Long-press to solo on touch/pen devices (500ms, consistent with action-handler-directive)
private _legendPointerDown(ev: PointerEvent) {
// Mouse uses Ctrl/Cmd+click instead
if (ev.pointerType === "mouse") {
return;
}
const id = (ev.currentTarget as HTMLElement)?.id;
if (!id) {
return;
}
this._longPressTriggered = false;
this._longPressTimer = setTimeout(() => {
this._longPressTriggered = true;
this._longPressTimer = undefined;
this._soloLegend(id);
}, 500);
}
private _legendPointerCancel() {
if (this._longPressTimer) {
clearTimeout(this._longPressTimer);
this._longPressTimer = undefined;
}
}
private _legendContextMenu(ev: Event) {
if (this._longPressTimer || this._longPressTriggered) {
ev.preventDefault();
}
}
private _legendClick(ev: MouseEvent) {
if (!this.chart) {
return;
}
const id = ev.currentTarget?.id;
if (this._longPressTriggered) {
this._longPressTriggered = false;
return;
}
const id = (ev.currentTarget as HTMLElement)?.id;
// Cmd+click on Mac (Ctrl+click is right-click there), Ctrl+click elsewhere
const soloModifier = isMac ? ev.metaKey : ev.ctrlKey;
if (soloModifier) {
this._soloLegend(id);
return;
}
if (this._hiddenDatasets.has(id)) {
this._getAllIdsFromLegend(this.options, id).forEach((i) =>
this._hiddenDatasets.delete(i)
@@ -1041,6 +1104,60 @@ export class HaChartBase extends LitElement {
this.requestUpdate("_hiddenDatasets");
}
private _soloLegend(id: string) {
const allIds = this._getAllLegendIds();
const clickedIds = this._getAllIdsFromLegend(this.options, id);
const otherIds = allIds.filter((i) => !clickedIds.includes(i));
const clickedIsOnlyVisible =
clickedIds.every((i) => !this._hiddenDatasets.has(i)) &&
otherIds.every((i) => this._hiddenDatasets.has(i));
if (clickedIsOnlyVisible) {
// Already solo'd on this item — restore all series to visible
for (const hiddenId of [...this._hiddenDatasets]) {
this._hiddenDatasets.delete(hiddenId);
fireEvent(this, "dataset-unhidden", { id: hiddenId });
}
} else {
// Solo: hide every other series, unhide clicked if it was hidden
for (const otherId of otherIds) {
if (!this._hiddenDatasets.has(otherId)) {
this._hiddenDatasets.add(otherId);
fireEvent(this, "dataset-hidden", { id: otherId });
}
}
for (const clickedId of clickedIds) {
if (this._hiddenDatasets.has(clickedId)) {
this._hiddenDatasets.delete(clickedId);
fireEvent(this, "dataset-unhidden", { id: clickedId });
}
}
}
this.requestUpdate("_hiddenDatasets");
}
private _getAllLegendIds(): string[] {
const items = this._getLegendItems();
if (!items) {
return [];
}
const allIds = new Set<string>();
for (const item of items) {
const primaryId =
typeof item === "string"
? item
: ((item.id as string) ?? (item.name as string) ?? "");
for (const expandedId of this._getAllIdsFromLegend(
this.options,
primaryId
)) {
allIds.add(expandedId);
}
}
return [...allIds];
}
private _toggleExpandedLegend() {
this.expandLegend = !this.expandLegend;
setTimeout(() => {

View File

@@ -65,6 +65,8 @@ export interface NetworkData {
categories?: { name: string; symbol: string }[];
}
const PHYSICS_DISABLE_THRESHOLD = 512;
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports
let GraphChart: typeof import("echarts/lib/chart/graph/install");
@@ -94,7 +96,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
@state() private _reducedMotion = false;
@state() private _physicsEnabled = true;
@state() private _physicsEnabled?: boolean;
@state() private _showLabels = true;
@@ -122,6 +124,14 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
];
}
protected willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties);
if (this._physicsEnabled === undefined && this.data?.nodes?.length > 1) {
this._physicsEnabled =
this.data.nodes.length <= PHYSICS_DISABLE_THRESHOLD;
}
}
protected render() {
if (!GraphChart || !this.data.nodes?.length) {
return nothing;
@@ -138,7 +148,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
.hass=${this.hass}
.data=${this._getSeries(
this.data,
this._physicsEnabled,
this._physicsEnabled ?? false,
this._reducedMotion,
this._showLabels,
isMobile,

View File

@@ -28,6 +28,13 @@ const safeParseFloat = (value) => {
return isFinite(parsed) ? parsed : null;
};
const CLIMATE_MODE_CONFIGS = [
{ mode: "heat", action: "heating", cssVar: "--state-climate-heat-color" },
{ mode: "cool", action: "cooling", cssVar: "--state-climate-cool-color" },
{ mode: "dry", action: "drying", cssVar: "--state-climate-dry-color" },
{ mode: "fan_only", action: "fan", cssVar: "--state-climate-fan_only-color" },
] as const;
@customElement("state-history-chart-line")
export class StateHistoryChartLine extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -429,23 +436,18 @@ export class StateHistoryChartLine extends LitElement {
(entityState) => entityState.attributes?.hvac_action
);
const isHeating =
domain === "climate" && hasHvacAction
? (entityState: LineChartState) =>
CLIMATE_HVAC_ACTION_TO_MODE[
entityState.attributes?.hvac_action
] === "heat"
: (entityState: LineChartState) => entityState.state === "heat";
const isCooling =
domain === "climate" && hasHvacAction
? (entityState: LineChartState) =>
CLIMATE_HVAC_ACTION_TO_MODE[
entityState.attributes?.hvac_action
] === "cool"
: (entityState: LineChartState) => entityState.state === "cool";
const hasHeat = states.states.some(isHeating);
const hasCool = states.states.some(isCooling);
const activeModes = CLIMATE_MODE_CONFIGS.map(
({ mode, action, cssVar }) => {
const isActive =
domain === "climate" && hasHvacAction
? (entityState: LineChartState) =>
CLIMATE_HVAC_ACTION_TO_MODE[
entityState.attributes?.hvac_action
] === mode
: (entityState: LineChartState) => entityState.state === mode;
return { action, cssVar, isActive };
}
).filter(({ isActive }) => states.states.some(isActive));
// We differentiate between thermostats that have a target temperature
// range versus ones that have just a target temperature
@@ -466,33 +468,19 @@ export class StateHistoryChartLine extends LitElement {
"component.climate.entity_component._.state_attributes.current_temperature.name"
)
);
if (hasHeat) {
for (const { action, cssVar } of activeModes) {
addDataSet(
states.entity_id + "-heating",
`${states.entity_id}-${action}`,
this.showNames
? this.hass.localize("ui.card.climate.heating", { name: name })
? this.hass.localize(`ui.card.climate.${action}`, {
name: name,
})
: this.hass.localize(
"component.climate.entity_component._.state_attributes.hvac_action.state.heating"
`component.climate.entity_component._.state_attributes.hvac_action.state.${action}`
),
computedStyles.getPropertyValue("--state-climate-heat-color"),
computedStyles.getPropertyValue(cssVar),
true
);
// The "heating" series uses steppedArea to shade the area below the current
// temperature when the thermostat is calling for heat.
}
if (hasCool) {
addDataSet(
states.entity_id + "-cooling",
this.showNames
? this.hass.localize("ui.card.climate.cooling", { name: name })
: this.hass.localize(
"component.climate.entity_component._.state_attributes.hvac_action.state.cooling"
),
computedStyles.getPropertyValue("--state-climate-cool-color"),
true
);
// The "cooling" series uses steppedArea to shade the area below the current
// temperature when the thermostat is calling for heat.
}
if (hasTargetRange) {
@@ -540,11 +528,8 @@ export class StateHistoryChartLine extends LitElement {
entityState.attributes.current_temperature
);
const series = [curTemp];
if (hasHeat) {
series.push(isHeating(entityState) ? curTemp : null);
}
if (hasCool) {
series.push(isCooling(entityState) ? curTemp : null);
for (const { isActive } of activeModes) {
series.push(isActive(entityState) ? curTemp : null);
}
if (hasTargetRange) {
const targetHigh = safeParseFloat(

View File

@@ -32,6 +32,7 @@ import {
} from "../../data/recorder";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant } from "../../types";
import { getPeriodicAxisLabelConfig } from "./axis-label";
import type { CustomLegendOption } from "./ha-chart-base";
import "./ha-chart-base";
@@ -293,6 +294,22 @@ export class StatisticsChart extends LitElement {
type: "time",
min: startTime,
max: this.endTime,
...(this.period === "month" && {
minInterval: 28 * 24 * 3600 * 1000,
axisLabel: getPeriodicAxisLabelConfig(
"month",
this.hass.locale,
this.hass.config
),
}),
...(this.period === "year" && {
minInterval: 365 * 24 * 3600 * 1000,
axisLabel: getPeriodicAxisLabelConfig(
"year",
this.hass.locale,
this.hass.config
),
}),
},
{
id: "hiddenAxis",

View File

@@ -2,7 +2,6 @@ import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { computeCssColor } from "../../common/color/compute-color";
import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { stringCompare } from "../../common/string/compare";
@@ -53,16 +52,15 @@ class HaDataTableLabels extends LitElement {
}
private _renderLabel(label: LabelRegistryEntry, clickAction: boolean) {
const color = label?.color ? computeCssColor(label.color) : undefined;
return html`
<ha-label
dense
role="button"
tabindex="0"
.color=${label.color}
.item=${label}
@click=${clickAction ? this._labelClicked : undefined}
@keydown=${clickAction ? this._labelClicked : undefined}
style=${color ? `--color: ${color}` : ""}
.description=${label.description}
>
${label?.icon
@@ -102,10 +100,6 @@ class HaDataTableLabels extends LitElement {
position: fixed;
flex-wrap: nowrap;
}
ha-label {
--ha-label-background-color: var(--color, var(--grey-color));
--ha-label-background-opacity: 0.5;
}
.plus {
--ha-label-background-color: transparent;
border: 1px solid var(--divider-color);

View File

@@ -1,3 +1,4 @@
import { consume, type ContextType } from "@lit/context";
import { mdiArrowDown, mdiArrowUp, mdiChevronUp } from "@mdi/js";
import deepClone from "deep-clone-simple";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
@@ -21,9 +22,10 @@ import type { LocalizeFunc } from "../../common/translations/localize";
import { debounce } from "../../common/util/debounce";
import { groupBy } from "../../common/util/group-by";
import { nextRender } from "../../common/util/render-status";
import { localeContext, localizeContext } from "../../data/context";
import type { FrontendLocaleData } from "../../data/translation";
import { haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types";
import "../ha-checkbox";
import type { HaCheckbox } from "../ha-checkbox";
import "../ha-svg-icon";
@@ -104,9 +106,13 @@ const UNDEFINED_GROUP_KEY = "zzzzz_undefined";
@customElement("ha-data-table")
export class HaDataTable extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consume({ context: localizeContext, subscribe: true })
private _localize?: ContextType<typeof localizeContext>;
@property({ attribute: false }) public localizeFunc?: LocalizeFunc;
@state()
@consume({ context: localeContext, subscribe: true })
private _locale?: ContextType<typeof localeContext>;
@property({ type: Boolean }) public narrow = false;
@@ -378,8 +384,6 @@ export class HaDataTable extends LitElement {
);
protected render() {
const localize = this.localizeFunc || this.hass.localize;
const columns = this._sortedColumns(this.columns, this.columnOrder);
const renderRow = (row: DataTableRowData, index: number) =>
@@ -503,7 +507,8 @@ export class HaDataTable extends LitElement {
<div class="mdc-data-table__row" role="row">
<div class="mdc-data-table__cell grows center" role="cell">
${this.noDataText ||
localize("ui.components.data-table.no-data")}
this._localize?.("ui.components.data-table.no-data") ||
"No data"}
</div>
</div>
</div>
@@ -515,7 +520,8 @@ export class HaDataTable extends LitElement {
@scroll=${this._saveScrollPos}
.items=${this._groupData(
this._filteredData,
localize,
this._localize,
this._locale,
this.appendRow,
this.groupColumn,
this.groupOrder,
@@ -685,7 +691,7 @@ export class HaDataTable extends LitElement {
this._sortColumns[this.sortColumn],
this.sortDirection,
this.sortColumn,
this.hass.locale.language
this._locale?.language
)
: filteredData;
@@ -711,7 +717,8 @@ export class HaDataTable extends LitElement {
private _groupData = memoizeOne(
(
data: DataTableRowData[],
localize: LocalizeFunc,
localize: LocalizeFunc | undefined,
locale: FrontendLocaleData | undefined,
appendRow,
groupColumn: string | undefined,
groupOrder: string[] | undefined,
@@ -735,11 +742,7 @@ export class HaDataTable extends LitElement {
)
.sort((a, b) => {
if (!groupOrder && isGroupSortColumn) {
const comparison = stringCompare(
a,
b,
this.hass.locale.language
);
const comparison = stringCompare(a, b, locale?.language);
if (sortDirection === "asc") {
return comparison;
}
@@ -760,7 +763,7 @@ export class HaDataTable extends LitElement {
return stringCompare(
["", "-", "—"].includes(a) ? "zzz" : a,
["", "-", "—"].includes(b) ? "zzz" : b,
this.hass.locale.language
locale?.language
);
})
.reduce(
@@ -787,14 +790,15 @@ export class HaDataTable extends LitElement {
>
<ha-icon-button
.path=${mdiChevronUp}
.label=${this.hass.localize(
.label=${localize?.(
`ui.components.data-table.${collapsed ? "expand" : "collapse"}`
)}
) || (collapsed ? "Expand" : "Collapse")}
class=${collapsed ? "collapsed" : ""}
>
</ha-icon-button>
${groupName === UNDEFINED_GROUP_KEY
? localize("ui.components.data-table.ungrouped")
? localize?.("ui.components.data-table.ungrouped") ||
"Ungrouped"
: groupName || ""}
</div>`,
});
@@ -863,7 +867,8 @@ export class HaDataTable extends LitElement {
const groupedData = this._groupData(
this._filteredData,
this.localizeFunc || this.hass.localize,
this._localize,
this._locale,
this.appendRow,
this.groupColumn,
this.groupOrder,

View File

@@ -4,7 +4,7 @@ import type { ActionDetail } from "@material/mwc-list";
import { mdiCalendarToday } from "@mdi/js";
import "cally";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, queryAll, state } from "lit/decorators";
import { firstWeekdayIndex } from "../../common/datetime/first_weekday";
import {
formatCallyDateRange,
@@ -19,7 +19,12 @@ import {
localizeContext,
} from "../../data/context";
import { TimeZone } from "../../data/translation";
import { MobileAwareMixin } from "../../mixins/mobile-aware-mixin";
import { haStyleScrollbar } from "../../resources/styles";
import type { ValueChangedEvent } from "../../types";
import "../chips/ha-chip-set";
import "../chips/ha-filter-chip";
import type { HaFilterChip } from "../chips/ha-filter-chip";
import type { HaBaseTimeInput } from "../ha-base-time-input";
import "../ha-icon-button";
import "../ha-icon-button-next";
@@ -27,11 +32,12 @@ import "../ha-icon-button-prev";
import "../ha-list";
import "../ha-list-item";
import "../ha-time-input";
import type { HaTimeInput } from "../ha-time-input";
import type { DateRangePickerRanges } from "./ha-date-range-picker";
import { datePickerStyles, dateRangePickerStyles } from "./styles";
@customElement("date-range-picker")
export class DateRangePicker extends LitElement {
export class DateRangePicker extends MobileAwareMixin(LitElement) {
@property({ attribute: false }) public ranges?: DateRangePickerRanges | false;
@property({ attribute: false }) public startDate?: Date;
@@ -69,6 +75,8 @@ export class DateRangePicker extends LitElement {
to: { hours: 23, minutes: 59 },
};
@queryAll("ha-time-input") private _timeInputs?: NodeListOf<HaTimeInput>;
public connectedCallback() {
super.connectedCallback();
@@ -100,16 +108,38 @@ export class DateRangePicker extends LitElement {
}
}
private _renderRanges() {
if (this._isMobileSize) {
return html`
<ha-chip-set class="ha-scrollbar">
${Object.entries(this.ranges!).map(
([name, range], index) => html`
<ha-filter-chip
.index=${index}
.range=${range}
@click=${this._clickDateRangeChip}
>
${name}
</ha-filter-chip>
`
)}
</ha-chip-set>
`;
}
return html`
<ha-list @action=${this._setDateRange} activatable>
${Object.keys(this.ranges!).map(
(name) => html`<ha-list-item>${name}</ha-list-item>`
)}
</ha-list>
`;
}
render() {
return html`<div class="picker">
${this.ranges !== false && this.ranges
? html`<div class="date-range-ranges">
<ha-list @action=${this._setDateRange} activatable>
${Object.keys(this.ranges).map(
(name) => html`<ha-list-item>${name}</ha-list-item>`
)}
</ha-list>
</div>`
? html`<div class="date-range-ranges">${this._renderRanges()}</div>`
: nothing}
<div class="range">
<calendar-range
@@ -153,6 +183,7 @@ export class DateRangePicker extends LitElement {
)}
id="from"
placeholder-labels
auto-validate
></ha-time-input>
<ha-time-input
.value=${`${this._timeValue.to.hours}:${this._timeValue.to.minutes}`}
@@ -163,6 +194,7 @@ export class DateRangePicker extends LitElement {
)}
id="to"
placeholder-labels
auto-validate
></ha-time-input>
</div>
`
@@ -200,6 +232,14 @@ export class DateRangePicker extends LitElement {
let endDate = new Date(`${dates[1]}T23:59:00`);
if (this.timePicker) {
const timeInputs = this._timeInputs;
if (
timeInputs &&
![...timeInputs].every((input) => input.reportValidity())
) {
// If we have time inputs, and they don't all report valid, don't save
return;
}
startDate.setHours(this._timeValue.from.hours);
startDate.setMinutes(this._timeValue.from.minutes);
endDate.setHours(this._timeValue.to.hours);
@@ -257,31 +297,38 @@ export class DateRangePicker extends LitElement {
this._focusDate = undefined;
}
private _clickDateRangeChip(ev: Event) {
const chip = ev.target as HaFilterChip & {
index: number;
range: [Date, Date];
};
this._saveDateRangePreset(chip.range, chip.index);
}
private _setDateRange(ev: CustomEvent<ActionDetail>) {
const dateRange: [Date, Date] = Object.values(this.ranges!)[
ev.detail.index
];
this._dateValue = formatCallyDateRange(
dateRange[0],
dateRange[1],
this.locale,
this.hassConfig
);
this._saveDateRangePreset(dateRange, ev.detail.index);
}
private _saveDateRangePreset(range: [Date, Date], index: number) {
fireEvent(this, "value-changed", {
value: {
startDate: dateRange[0],
endDate: dateRange[1],
startDate: range[0],
endDate: range[1],
},
});
fireEvent(this, "preset-selected", {
index: ev.detail.index,
index,
});
}
private _handleChangeTime(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const time = ev.detail.value;
const type = (ev.target as HaBaseTimeInput).id;
const target = ev.target as HaBaseTimeInput;
const type = target.id;
if (time) {
if (!this._timeValue) {
this._timeValue = {
@@ -298,20 +345,48 @@ export class DateRangePicker extends LitElement {
static styles = [
datePickerStyles,
dateRangePickerStyles,
haStyleScrollbar,
css`
.picker {
display: flex;
flex-direction: row;
}
.date-range-ranges {
border-right: 1px solid var(--divider-color);
border-right: var(--ha-border-width-sm) solid var(--divider-color);
min-width: 140px;
flex: 0 1 30%;
}
.range {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
padding: var(--ha-space-3);
overflow-x: hidden;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
.picker {
flex-direction: column;
}
.date-range-ranges {
border-bottom: 1px solid var(--divider-color);
margin-top: var(--ha-space-5);
overflow: visible;
}
ha-chip-set {
padding: var(--ha-space-3);
flex-wrap: nowrap;
overflow-x: auto;
}
.range {
flex-basis: fit-content;
}
}
.times {
@@ -326,12 +401,6 @@ export class DateRangePicker extends LitElement {
padding: var(--ha-space-2);
border-top: 1px solid var(--divider-color);
}
@media only screen and (max-width: 500px) {
.date-range-ranges {
max-width: 30%;
}
}
`,
];
}

View File

@@ -80,33 +80,6 @@ export const datePickerStyles = css`
text-align: center;
margin-left: 48px;
}
@media only screen and (max-width: 500px) {
calendar-month {
min-height: calc(34px * 7);
}
calendar-month::part(day) {
font-size: var(--ha-font-size-s);
}
calendar-month::part(button) {
height: 26px;
width: 26px;
}
calendar-month::part(range-inner),
calendar-month::part(range-start),
calendar-month::part(range-end),
calendar-month::part(selected),
calendar-month::part(selected):hover {
height: 34px;
width: 34px;
}
.heading {
font-size: var(--ha-font-size-s);
}
.month-year {
margin-left: 40px;
}
}
`;
export const dateRangePickerStyles = css`

View File

@@ -1,15 +1,13 @@
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import {
DEFAULT_ENTITY_NAME,
type EntityNameItem,
} from "../../common/entity/compute_entity_name_display";
import type { EntityNameItem } from "../../common/entity/compute_entity_name_display";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import type { EntityNameType } from "../../common/translations/entity-state";
import type { LocalizeKeys } from "../../common/translations/localize";
@@ -17,12 +15,14 @@ import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../chips/ha-assist-chip";
import "../chips/ha-chip-set";
import "../chips/ha-input-chip";
import "../ha-button-toggle-group";
import "../ha-combo-box-item";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-input-helper-text";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
import "../ha-sortable";
import "../input/ha-input";
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
<ha-combo-box-item type="button" compact>
@@ -73,10 +73,291 @@ export class HaEntityNamePicker extends LitElement {
@property({ type: Boolean, reflect: true }) public disabled = false;
@query("ha-generic-picker", true) private _picker?: HaGenericPicker;
@state() private _mode?: "composed" | "custom";
@query("ha-generic-picker") private _picker?: HaGenericPicker;
private _editIndex?: number;
connectedCallback(): void {
super.connectedCallback();
if (this.hasUpdated) {
const items = this._toItems(this.value);
this._mode =
items.length === 1 && items[0].type === "text" ? "custom" : "composed";
}
}
protected willUpdate(_changedProperties: PropertyValues): void {
if (this._mode === undefined) {
const items = this._toItems(this.value);
this._mode =
items.length === 1 && items[0].type === "text" ? "custom" : "composed";
}
}
protected render() {
const modeButtons = [
{
label: this.hass.localize(
"ui.components.entity.entity-name-picker.mode_composed"
),
value: "composed",
},
{
label: this.hass.localize(
"ui.components.entity.entity-name-picker.mode_custom"
),
value: "custom",
},
];
return html`
<div class="container">
<div class="header">
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-button-toggle-group
size="small"
.buttons=${modeButtons}
.active=${this._mode}
.disabled=${this.disabled}
@value-changed=${this._modeChanged}
></ha-button-toggle-group>
</div>
<div class="content">
${this._mode === "custom"
? this._renderTextInput()
: this._renderPicker()}
</div>
</div>
${this.helper
? html`
<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>
`
: nothing}
`;
}
private _renderTextInput() {
const items = this._items;
const value =
items.length === 1 && items[0].type === "text" ? items[0].text || "" : "";
return html`
<ha-input
.disabled=${this.disabled}
.required=${this.required}
.value=${value}
@input=${this._textInputChanged}
></ha-input>
`;
}
private _renderPicker() {
const value = this._items;
const validTypes = this._validTypes(this.entityId);
return html`
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.getItems=${this._getFilteredItems}
.rowRenderer=${rowRenderer}
.value=${this._getPickerValue()}
allow-custom-value
.customValueLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.custom_name"
)}
@value-changed=${this._pickerValueChanged}
.searchFn=${this._searchFn}
.searchLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.search"
)}
>
<div slot="field" class="field">
<ha-sortable
no-style
@item-moved=${this._moveItem}
.disabled=${this.disabled}
handle-selector="button.primary.action"
filter=".add"
>
<ha-chip-set>
${repeat(
this._items,
(item) => item,
(item: EntityNameItem, idx) => {
const label = this._formatItem(item);
const isValid = validTypes.has(item.type);
return html`
<ha-input-chip
data-idx=${idx}
@remove=${this._removeItem}
@click=${this._editItem}
.label=${label}
.selected=${!this.disabled}
.disabled=${this.disabled}
class=${!isValid ? "invalid" : ""}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
<span>${label}</span>
</ha-input-chip>
`;
}
)}
${this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this.hass.localize(
"ui.components.entity.entity-name-picker.add"
)}
class="add"
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-assist-chip>
`}
</ha-chip-set>
</ha-sortable>
</div>
</ha-generic-picker>
`;
}
private _modeChanged(ev: CustomEvent) {
ev.stopPropagation();
this._mode = ev.detail.value as "composed" | "custom";
}
private _textInputChanged(ev: Event) {
const value = (ev.target as HTMLInputElement).value;
const newValue: EntityNameItem[] = value
? [{ type: "text", text: value }]
: [];
this._setValue(newValue);
}
private async _addItem(ev: Event) {
ev.stopPropagation();
this._editIndex = undefined;
await this.updateComplete;
await this._picker?.open();
}
private async _editItem(ev: Event) {
ev.stopPropagation();
const idx = parseInt(
(ev.currentTarget as HTMLElement).dataset.idx || "",
10
);
this._editIndex = idx;
await this.updateComplete;
await this._picker?.open();
const value = this._items[idx];
// Pre-fill the field value when editing a text item
if (value.type === "text" && value.text) {
this._picker?.setFieldValue(value.text);
}
}
private async _moveItem(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const value = this._items;
const newValue = value.concat();
const element = newValue.splice(oldIndex, 1)[0];
newValue.splice(newIndex, 0, element);
this._setValue(newValue);
}
private async _removeItem(ev: Event) {
ev.stopPropagation();
const value = [...this._items];
const idx = parseInt((ev.target as HTMLElement).dataset.idx || "", 10);
value.splice(idx, 1);
this._setValue(value);
}
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
ev.stopPropagation();
const value = ev.detail.value;
if (this.disabled || !value) {
return;
}
const item: EntityNameItem = parseOptionValue(value);
const newValue = [...this._items];
if (this._editIndex != null) {
newValue[this._editIndex] = item;
this._editIndex = undefined;
} else {
newValue.push(item);
}
this._setValue(newValue);
if (this._picker) {
this._picker.value = undefined;
}
}
private _setValue(value: EntityNameItem[]) {
const newValue = this._toValue(value);
this.value = newValue;
fireEvent(this, "value-changed", {
value: newValue,
});
}
private get _items(): EntityNameItem[] {
return this._toItems(this.value);
}
private _toItems = memoizeOne((value?: typeof this.value) => {
if (typeof value === "string") {
if (value === "") {
return [];
}
return [{ type: "text", text: value } satisfies EntityNameItem];
}
return value ? ensureArray(value) : [];
});
private _toValue = memoizeOne(
(items: EntityNameItem[]): typeof this.value => {
if (items.length === 0) {
return undefined;
}
if (items.length === 1) {
const item = items[0];
return item.type === "text" ? item.text : item;
}
return items;
}
);
private _formatItem = (item: EntityNameItem) => {
if (item.type === "text") {
return `"${item.text}"`;
}
if (KNOWN_TYPES.has(item.type)) {
return this.hass.localize(
`ui.components.entity.entity-name-picker.types.${item.type as EntityNameType}`
);
}
return item.type;
};
private _validTypes = memoizeOne((entityId?: string) => {
const options = new Set<string>(["text"]);
if (!entityId) {
@@ -161,157 +442,6 @@ export class HaEntityNamePicker extends LitElement {
})
);
private _formatItem = (item: EntityNameItem) => {
if (item.type === "text") {
return `"${item.text}"`;
}
if (KNOWN_TYPES.has(item.type)) {
return this.hass.localize(
`ui.components.entity.entity-name-picker.types.${item.type as EntityNameType}`
);
}
return item.type;
};
protected render() {
const value = this._items;
const validTypes = this._validTypes(this.entityId);
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.getItems=${this._getFilteredItems}
.rowRenderer=${rowRenderer}
.value=${this._getPickerValue()}
allow-custom-value
.customValueLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.custom_name"
)}
@value-changed=${this._pickerValueChanged}
.searchFn=${this._searchFn}
.searchLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.search"
)}
>
<div slot="field" class="container">
<ha-sortable
no-style
@item-moved=${this._moveItem}
.disabled=${this.disabled}
handle-selector="button.primary.action"
filter=".add"
>
<ha-chip-set>
${repeat(
this._items,
(item) => item,
(item: EntityNameItem, idx) => {
const label = this._formatItem(item);
const isValid = validTypes.has(item.type);
return html`
<ha-input-chip
data-idx=${idx}
@remove=${this._removeItem}
@click=${this._editItem}
.label=${label}
.selected=${!this.disabled}
.disabled=${this.disabled}
class=${!isValid ? "invalid" : ""}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
<span>${label}</span>
</ha-input-chip>
`;
}
)}
${this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this.hass.localize(
"ui.components.entity.entity-name-picker.add"
)}
class="add"
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-assist-chip>
`}
</ha-chip-set>
</ha-sortable>
</div>
</ha-generic-picker>
${this._renderHelper()}
`;
}
private _renderHelper() {
return this.helper
? html`
<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>
`
: nothing;
}
private async _addItem(ev: Event) {
ev.stopPropagation();
this._editIndex = undefined;
await this.updateComplete;
await this._picker?.open();
}
private async _editItem(ev: Event) {
ev.stopPropagation();
const idx = parseInt(
(ev.currentTarget as HTMLElement).dataset.idx || "",
10
);
this._editIndex = idx;
await this.updateComplete;
await this._picker?.open();
const value = this._items[idx];
// Pre-fill the field value when editing a text item
if (value.type === "text" && value.text) {
this._picker?.setFieldValue(value.text);
}
}
private get _items(): EntityNameItem[] {
return this._toItems(this.value);
}
private _toItems = memoizeOne((value?: typeof this.value) => {
if (typeof value === "string") {
if (value === "") {
return [];
}
return [{ type: "text", text: value } satisfies EntityNameItem];
}
return value ? ensureArray(value) : [...DEFAULT_ENTITY_NAME];
});
private _toValue = memoizeOne(
(items: EntityNameItem[]): typeof this.value => {
if (items.length === 0) {
return "";
}
if (items.length === 1) {
const item = items[0];
return item.type === "text" ? item.text : item;
}
return items;
}
);
private _getPickerValue(): string | undefined {
if (this._editIndex != null) {
const item = this._items[this._editIndex];
@@ -362,58 +492,6 @@ export class HaEntityNamePicker extends LitElement {
return filteredItems;
};
private async _moveItem(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const value = this._items;
const newValue = value.concat();
const element = newValue.splice(oldIndex, 1)[0];
newValue.splice(newIndex, 0, element);
this._setValue(newValue);
}
private async _removeItem(ev: Event) {
ev.stopPropagation();
const value = [...this._items];
const idx = parseInt((ev.target as HTMLElement).dataset.idx || "", 10);
value.splice(idx, 1);
this._setValue(value);
}
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
ev.stopPropagation();
const value = ev.detail.value;
if (this.disabled || !value) {
return;
}
const item: EntityNameItem = parseOptionValue(value);
const newValue = [...this._items];
if (this._editIndex != null) {
newValue[this._editIndex] = item;
this._editIndex = undefined;
} else {
newValue.push(item);
}
this._setValue(newValue);
if (this._picker) {
this._picker.value = undefined;
}
}
private _setValue(value: EntityNameItem[]) {
const newValue = this._toValue(value);
this.value = newValue;
fireEvent(this, "value-changed", {
value: newValue,
});
}
static styles = css`
:host {
position: relative;
@@ -421,13 +499,42 @@ export class HaEntityNamePicker extends LitElement {
}
.container {
--ha-input-padding-bottom: 0;
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
label {
display: block;
font-weight: 500;
}
.content {
display: flex;
gap: var(--ha-space-2);
align-items: flex-end;
}
ha-generic-picker,
ha-input {
width: 100%;
}
.field {
position: relative;
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-radius: var(--ha-border-radius-sm);
border-end-end-radius: var(--ha-border-radius-square);
border-end-start-radius: var(--ha-border-radius-square);
}
.container:after {
.field:after {
display: block;
content: "";
position: absolute;
@@ -445,30 +552,25 @@ export class HaEntityNamePicker extends LitElement {
height 180ms ease-in-out,
background-color 180ms ease-in-out;
}
:host([disabled]) .container:after {
:host([disabled]) .field:after {
background-color: var(
--mdc-text-field-disabled-line-color,
rgba(0, 0, 0, 0.42)
);
}
.container:focus-within:after {
.field:focus-within:after {
height: 2px;
background-color: var(--mdc-theme-primary);
}
label {
display: block;
margin: 0 0 var(--ha-space-2);
ha-chip-set {
padding: var(--ha-space-3);
}
.add {
order: 1;
}
ha-chip-set {
padding: var(--ha-space-2) var(--ha-space-2);
}
.invalid {
text-decoration: line-through;
}

View File

@@ -1,11 +1,10 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { computeStateName } from "../../common/entity/compute_state_name";
import type { HomeAssistant } from "../../types";
import "../ha-relative-time";
import "./state-badge";
import "../ha-tooltip";
import "./state-badge";
@customElement("state-info")
class StateInfo extends LitElement {
@@ -22,7 +21,7 @@ class StateInfo extends LitElement {
return nothing;
}
const name = computeStateName(this.stateObj);
const name = this.hass.formatEntityName(this.stateObj, { type: "entity" });
return html`<state-badge
.hass=${this.hass}

View File

@@ -8,6 +8,7 @@ import {
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { LocalizeFunc } from "../common/translations/localize";
import { fireEvent } from "../common/dom/fire_event";
import "./ha-icon-button";
import "./ha-svg-icon";
@@ -38,6 +39,8 @@ class HaAlert extends LitElement {
@property({ type: Boolean }) public dismissable = false;
@property({ attribute: false }) public localize?: LocalizeFunc;
@property({ type: Boolean }) public narrow = false;
public render() {
@@ -65,7 +68,7 @@ class HaAlert extends LitElement {
${this.dismissable
? html`<ha-icon-button
@click=${this._dismissClicked}
label="Dismiss alert"
.label=${this.localize!("ui.common.dismiss_alert")}
.path=${mdiClose}
></ha-icon-button>`
: nothing}

View File

@@ -56,7 +56,10 @@ class HaAttributeValue extends LitElement {
this.stateObj!,
this.attribute
);
return parts.find((part) => part.type === "value")?.value;
return parts
.filter((part) => part.type === "value")
.map((part) => part.value)
.join("");
}
return this.hass.formatEntityAttributeValue(this.stateObj!, this.attribute);

View File

@@ -137,7 +137,7 @@ export class HaBaseTimeInput extends LitElement {
@property({ attribute: "placeholder-labels", type: Boolean })
public placeholderLabels = false;
@queryAll("ha-input") private _inputs?: HaInput[];
@queryAll("ha-input") private _inputs?: NodeListOf<HaInput>;
static shadowRootOptions = {
...LitElement.shadowRootOptions,
@@ -145,7 +145,9 @@ export class HaBaseTimeInput extends LitElement {
};
public reportValidity(): boolean {
return this._inputs?.every((input) => input.reportValidity()) ?? true;
const inputs = this._inputs;
if (!inputs) return true;
return [...inputs].every((input) => input.reportValidity());
}
protected render(): TemplateResult {
@@ -399,7 +401,7 @@ export class HaBaseTimeInput extends LitElement {
.time-separator,
ha-icon-button {
background-color: var(--ha-color-fill-neutral-quiet-resting);
background-color: var(--ha-color-form-background);
color: var(--ha-color-text-secondary);
border-bottom: 1px solid var(--ha-color-border-neutral-loud);
box-sizing: border-box;

View File

@@ -57,6 +57,7 @@ export class HaButton extends Button {
.button {
font-size: var(--ha-font-size-m);
line-height: 1;
-webkit-tap-highlight-color: transparent;
transition: background-color var(--ha-animation-duration-fast)
ease-out;

View File

@@ -123,6 +123,9 @@ export class HaDateInput extends LitElement {
}
static styles = css`
:host {
min-width: 0px;
}
ha-svg-icon {
color: var(--secondary-text-color);
}

View File

@@ -100,6 +100,9 @@ export class HaDropdown extends Dropdown {
#menu {
padding: var(--ha-space-1);
}
wa-popup::part(popup) {
z-index: 200;
}
`,
];
}

View File

@@ -6,7 +6,6 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../common/color/compute-color";
import { fireEvent } from "../common/dom/fire_event";
import { navigate } from "../common/navigate";
import { stringCompare } from "../common/string/compare";
@@ -98,17 +97,14 @@ export class HaFilterLabels extends LitElement {
this.value
),
(label) => label.label_id,
(label) => {
const color = label.color
? computeCssColor(label.color)
: undefined;
return html`<ha-check-list-item
(label) =>
html`<ha-check-list-item
.value=${label.label_id}
.selected=${(this.value || []).includes(label.label_id)}
hasMeta
>
<ha-label
style=${color ? `--color: ${color}` : ""}
.color=${label.color}
.description=${label.description}
>
${label.icon
@@ -119,8 +115,7 @@ export class HaFilterLabels extends LitElement {
: nothing}
${label.name}
</ha-label>
</ha-check-list-item>`;
}
</ha-check-list-item>`
)}
</ha-list> `
: nothing}
@@ -253,10 +248,6 @@ export class HaFilterLabels extends LitElement {
.warning {
color: var(--error-color);
}
ha-label {
--ha-label-background-color: var(--color, var(--grey-color));
--ha-label-background-opacity: 0.5;
}
.add {
position: absolute;
bottom: 0;

View File

@@ -142,6 +142,19 @@ export const computeInitialHaFormData = (
])[firstChoice],
};
}
} else if ("numeric_threshold" in selector) {
const mode = selector.numeric_threshold?.mode ?? "crossed";
const type = mode === "changed" ? "any" : "above";
data[field.name] =
type === "any"
? { type }
: {
type,
value: {
number: selector.numeric_threshold?.number?.min ?? 0,
active_choice: "number",
},
};
} else {
throw new Error(
`Selector ${Object.keys(selector)[0]} not supported in initial form data`

View File

@@ -1,4 +1,4 @@
import type { PropertyValues, TemplateResult } from "lit";
import type { PropertyValues } from "lit";
import { css, LitElement, svg } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
@@ -54,6 +54,7 @@ export class HaGauge extends LitElement {
this._angle = getAngle(this.value, this.min, this.max);
}
this._segment_label = this._getSegmentLabel();
this._rescaleSvg();
});
}
@@ -70,6 +71,7 @@ export class HaGauge extends LitElement {
}
this._angle = getAngle(this.value, this.min, this.max);
this._segment_label = this._getSegmentLabel();
this._rescaleSvg();
}
protected render() {
@@ -88,87 +90,91 @@ export class HaGauge extends LitElement {
/>
${
this.levels
? [...this.levels]
.sort((a, b) => a.level - b.level)
.map((level, i, arr) => {
const startLevel = i === 0 ? this.min : arr[i].level;
const endLevel = i + 1 < arr.length ? arr[i + 1].level : this.max;
${
this.levels
? (() => {
const sortedLevels = [...this.levels].sort(
(a, b) => a.level - b.level
);
const startAngle = getAngle(startLevel, this.min, this.max);
const endAngle = getAngle(endLevel, this.min, this.max);
const largeArc = endAngle - startAngle > 180 ? 1 : 0;
if (
sortedLevels.length > 0 &&
sortedLevels[0].level !== this.min
) {
sortedLevels.unshift({
level: this.min,
stroke: "var(--info-color)",
});
}
const x1 = -arcRadius * Math.cos((startAngle * Math.PI) / 180);
const y1 = -arcRadius * Math.sin((startAngle * Math.PI) / 180);
const x2 = -arcRadius * Math.cos((endAngle * Math.PI) / 180);
const y2 = -arcRadius * Math.sin((endAngle * Math.PI) / 180);
return sortedLevels.map((level, i, arr) => {
const startLevel = level.level;
const endLevel =
i + 1 < arr.length ? arr[i + 1].level : this.max;
const firstSegment = i === 0;
const lastSegment = i === arr.length - 1;
const startAngle = getAngle(startLevel, this.min, this.max);
const endAngle = getAngle(endLevel, this.min, this.max);
const largeArc = endAngle - startAngle > 180 ? 1 : 0;
const paths: TemplateResult[] = [];
const x1 =
-arcRadius * Math.cos((startAngle * Math.PI) / 180);
const y1 =
-arcRadius * Math.sin((startAngle * Math.PI) / 180);
const x2 = -arcRadius * Math.cos((endAngle * Math.PI) / 180);
const y2 = -arcRadius * Math.sin((endAngle * Math.PI) / 180);
if (firstSegment) {
paths.push(svg`
<path
class="level"
stroke="${level.stroke}"
style="stroke-linecap: round"
d="M ${x1} ${y1} A ${arcRadius} ${arcRadius} 0 ${largeArc} 1 ${x2} ${y2}"
/>
`);
} else if (lastSegment) {
const offsetAngle = 0.5;
const midAngle = endAngle - offsetAngle;
const xm = -arcRadius * Math.cos((midAngle * Math.PI) / 180);
const ym = -arcRadius * Math.sin((midAngle * Math.PI) / 180);
const isFirst = i === 0;
const isLast = i === arr.length - 1;
paths.push(svg`
<path
class="level"
stroke="${level.stroke}"
style="stroke-linecap: butt"
d="M ${x1} ${y1} A ${arcRadius} ${arcRadius} 0 ${largeArc} 1 ${xm} ${ym}"
/>
`);
if (isFirst) {
return svg`
<path
class="level"
stroke="${level.stroke}"
style="stroke-linecap: butt"
d="M ${x1} ${y1} A ${arcRadius} ${arcRadius} 0 ${largeArc} 1 ${x2} ${y2}"
/>
`;
}
paths.push(svg`
<path
class="level"
stroke="${level.stroke}"
style="stroke-linecap: round"
d="M ${xm} ${ym} A ${arcRadius} ${arcRadius} 0 0 1 ${x2} ${y2}"
/>
`);
} else {
paths.push(svg`
<path
class="level"
stroke="${level.stroke}"
style="stroke-linecap: butt"
d="M ${x1} ${y1} A ${arcRadius} ${arcRadius} 0 ${largeArc} 1 ${x2} ${y2}"
/>
`);
}
if (isLast) {
const offsetAngle = 0.5;
const midAngle = endAngle - offsetAngle;
const xm =
-arcRadius * Math.cos((midAngle * Math.PI) / 180);
const ym =
-arcRadius * Math.sin((midAngle * Math.PI) / 180);
return paths;
})
: ""
}
return svg`
<path class="level" stroke="${level.stroke}" style="stroke-linecap: butt"
d="M ${x1} ${y1} A ${arcRadius} ${arcRadius} 0 ${largeArc} 1 ${xm} ${ym}" />
<path class="level" stroke="${level.stroke}" style="stroke-linecap: butt"
d="M ${xm} ${ym} A ${arcRadius} ${arcRadius} 0 0 1 ${x2} ${y2}" />
`;
}
return svg`
<path
class="level"
stroke="${level.stroke}"
style="stroke-linecap: butt"
d="M ${x1} ${y1} A ${arcRadius} ${arcRadius} 0 ${largeArc} 1 ${x2} ${y2}"
></path>
`;
});
})()
: ""
}
${
this.needle
? svg`
<line
class="needle"
x1="-35.0"
y1="0"
x2="-45.0"
y2="0"
style=${styleMap({ transform: `rotate(${this._angle}deg)` })}
/>
<path
class="needle"
d="M -36,-2 L -44,-1 A 1,1,0,0,0,-44,1 L -36,2 A 2,2,0,0,0,-36,-2 Z"
style=${styleMap({ transform: `rotate(${this._angle}deg)` })}
/>
`
: svg`
<path
@@ -179,7 +185,8 @@ export class HaGauge extends LitElement {
/>
`
}
</svg>
<svg class="text">
<text
class="value-text"
x="0"
@@ -204,6 +211,18 @@ export class HaGauge extends LitElement {
`;
}
private _rescaleSvg() {
// Set the viewbox of the SVG containing the value to perfectly
// fit the text
// That way it will auto-scale correctly
const svgRoot = this.shadowRoot!.querySelector(".text")!;
const box = svgRoot.querySelector("text")!.getBBox()!;
svgRoot.setAttribute(
"viewBox",
`${box.x} ${box.y} ${box.width} ${box.height}`
);
}
private _getSegmentLabel() {
if (this.levels) {
[...this.levels].sort((a, b) => a.level - b.level);
@@ -224,32 +243,43 @@ export class HaGauge extends LitElement {
.levels-base {
fill: none;
stroke: var(--primary-background-color);
stroke-width: 8;
stroke-linecap: round;
stroke-width: 6;
stroke-linecap: butt;
}
.level {
fill: none;
stroke-width: 8;
stroke-width: 6;
stroke-linecap: butt;
}
.value {
fill: none;
stroke-width: 8;
stroke-width: 6;
stroke: var(--gauge-color);
stroke-linecap: round;
stroke-linecap: butt;
transition: stroke-dashoffset 1s ease 0s;
}
.needle {
stroke: var(--primary-text-color);
stroke-width: 2;
fill: var(--primary-text-color);
stroke: var(--card-background-color);
color: var(--primary-text-color);
stroke-width: 1;
stroke-linecap: round;
transform-origin: 0 0;
transition: all 1s ease 0s;
}
.text {
position: absolute;
max-height: 40%;
max-width: 55%;
left: 50%;
bottom: 10%;
transform: translate(-50%, 0%);
}
.value-text {
font-size: var(--ha-font-size-l);
fill: var(--primary-text-color);

View File

@@ -1,18 +1,44 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { computeCssColor } from "../common/color/compute-color";
import { getContrastedColorHex } from "../common/color/rgb";
import { uid } from "../common/util/uid";
import "./ha-tooltip";
/**
* Returns CSS styles for a label's background & icon/text
* @param color Label color defined in HEX format
* @returns CSS styles
*/
export const getLabelColorStyle = (labelColor: string | undefined | null) => {
const color = labelColor ? computeCssColor(labelColor) : undefined;
return color
? `--ha-label-background-color: ${color};
--primary-text-color: ${getContrastedColorHex(labelColor!)};`
: `--ha-label-background-color: rgba(var(--rgb-primary-text-color), 0.15);`;
};
@customElement("ha-label")
class HaLabel extends LitElement {
@property({ type: Boolean, reflect: true }) dense = false;
@property()
public color?: string;
@property({ attribute: "description" })
public description?: string;
private _elementId = "label-" + uid();
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (!changedProps.has("color")) {
return;
}
this.style.cssText = getLabelColorStyle(this.color);
}
protected render(): TemplateResult {
return html`
<ha-tooltip
@@ -36,10 +62,6 @@ class HaLabel extends LitElement {
:host {
--ha-label-text-color: var(--primary-text-color);
--ha-label-icon-color: var(--primary-text-color);
--ha-label-background-color: rgba(
var(--rgb-primary-text-color),
0.15
);
--ha-label-background-opacity: 1;
border: 1px solid var(--outline-color);
position: relative;

View File

@@ -6,7 +6,6 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../common/color/compute-color";
import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
import { labelsContext } from "../data/context";
@@ -17,6 +16,7 @@ import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./chips/ha-chip-set";
import "./chips/ha-input-chip";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import { getLabelColorStyle } from "./ha-label";
import "./ha-label-picker";
import type { HaLabelPicker } from "./ha-label-picker";
import "./ha-tooltip";
@@ -106,9 +106,14 @@ export class HaLabelsPicker extends LitElement {
labels?.find((label) => label.label_id === id) || {
label_id: id,
name: id,
color: "rgba(var(--rgb-primary-text-color), 0.15)",
}
)
.sort((a, b) => stringCompare(a?.name || "", b?.name || "", language))
.map((label) => ({
...label,
style: getLabelColorStyle(label.color),
}))
);
protected render(): TemplateResult {
@@ -135,9 +140,6 @@ export class HaLabelsPicker extends LitElement {
(label) => label?.label_id,
(label) => {
if (!label) return nothing;
const color = label.color
? computeCssColor(label.color)
: undefined;
const elementId = "label-" + label.label_id;
return html`
<ha-tooltip
@@ -154,7 +156,7 @@ export class HaLabelsPicker extends LitElement {
.disabled=${this.disabled}
.label=${label.name}
selected
style=${color ? `--color: ${color}` : ""}
style=${label.style}
>
${label.icon
? html`<ha-icon
@@ -239,8 +241,10 @@ export class HaLabelsPicker extends LitElement {
height: var(--ha-space-8);
}
ha-input-chip {
--md-input-chip-selected-container-color: var(--color, var(--grey-color));
--ha-input-chip-selected-container-opacity: 0.5;
--md-input-chip-selected-container-color: var(
--ha-label-background-color,
var(--grey-color)
);
--md-input-chip-selected-outline-width: 1px;
}
label {

View File

@@ -1,7 +1,6 @@
import type { PropertyValues } from "lit";
import { ReactiveElement, render, html } from "lit";
import { customElement, property } from "lit/decorators";
// eslint-disable-next-line import/extensions
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import hash from "object-hash";
import { fireEvent } from "../common/dom/fire_event";

View File

@@ -798,11 +798,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
}
ha-input-search {
padding: 0 var(--ha-space-3);
padding: 0 var(--ha-space-3) var(--ha-space-3);
}
:host([mode="dialog"]) ha-input-search {
padding: 0 var(--ha-space-4);
padding: 0 var(--ha-space-4) var(--ha-space-3);
}
ha-combo-box-item {
@@ -873,12 +873,12 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
display: flex;
flex-wrap: nowrap;
gap: var(--ha-space-2);
padding: var(--ha-space-3) var(--ha-space-3);
padding: 0 var(--ha-space-3) var(--ha-space-3);
overflow: auto;
}
:host([mode="dialog"]) .sections {
padding: var(--ha-space-3) var(--ha-space-4);
padding: 0 var(--ha-space-4) var(--ha-space-3);
}
.sections ha-filter-chip {
@@ -915,10 +915,6 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
padding: var(--ha-space-1) var(--ha-space-4);
}
:host([mode="dialog"]) ha-input-search {
padding: 0 var(--ha-space-4);
}
.section-title-wrapper {
height: 0;
position: relative;

View File

@@ -121,6 +121,8 @@ export class HaPickerField extends PickerMixin(LitElement) {
css`
ha-combo-box-item[disabled] {
background-color: var(--ha-color-form-background-disabled);
--md-list-item-disabled-opacity: 0.5;
opacity: 0.5;
cursor: not-allowed;
}
ha-combo-box-item {
@@ -141,13 +143,6 @@ export class HaPickerField extends PickerMixin(LitElement) {
--md-focus-ring-duration: 0s;
}
/* Add Similar focus style as the text field */
ha-combo-box-item[disabled]:after {
background-color: var(
--mdc-text-field-disabled-line-color,
rgba(0, 0, 0, 0.42)
);
}
ha-combo-box-item:after {
display: block;
content: "";
@@ -158,10 +153,7 @@ export class HaPickerField extends PickerMixin(LitElement) {
right: 0;
height: 1px;
width: 100%;
background-color: var(
--mdc-text-field-idle-line-color,
rgba(0, 0, 0, 0.42)
);
background-color: var(--ha-color-border-neutral-loud);
transform:
height 180ms ease-in-out,
background-color 180ms ease-in-out;

View File

@@ -2,6 +2,7 @@ import memoizeOne from "memoize-one";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { mdiChartBellCurveCumulative } from "@mdi/js";
import { fireEvent } from "../../common/dom/fire_event";
import type {
NumericThresholdSelector,
@@ -76,6 +77,18 @@ export class HaNumericThresholdSelector extends LitElement {
}
}
protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (
(changedProperties.has("value") || changedProperties.has("selector")) &&
!this.value
) {
const mode = this._getMode();
const type = DEFAULT_TYPE[mode];
fireEvent(this, "value-changed", { value: { type } });
}
}
private _getUnitOptions() {
return this.selector.numeric_threshold?.unit_of_measurement;
}
@@ -220,6 +233,7 @@ export class HaNumericThresholdSelector extends LitElement {
return [
{
value: "any",
iconPath: mdiChartBellCurveCumulative,
label: localize(
"ui.components.selectors.numeric_threshold.changed.any"
),
@@ -273,7 +287,9 @@ export class HaNumericThresholdSelector extends LitElement {
const numberSelector = {
number: {
...this.selector.numeric_threshold?.number,
...(effectiveUnit ? { unit_of_measurement: effectiveUnit } : {}),
...(!showUnit && effectiveUnit
? { unit_of_measurement: effectiveUnit }
: {}),
},
};
const entitySelector = {
@@ -481,7 +497,7 @@ export class HaNumericThresholdSelector extends LitElement {
.value-inputs {
display: flex;
gap: var(--ha-space-2);
align-items: flex-end;
align-items: flex-start;
}
.value-selector {

View File

@@ -0,0 +1,144 @@
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import type { PeriodKey, PeriodSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import { deepEqual } from "../../common/util/deep-equal";
import type { LocalizeFunc } from "../../common/translations/localize";
import "../ha-form/ha-form";
const PERIODS = {
none: undefined,
today: { calendar: { period: "day" } },
yesterday: { calendar: { period: "day", offset: -1 } },
tomorrow: { calendar: { period: "day", offset: 1 } },
this_week: { calendar: { period: "week" } },
last_week: { calendar: { period: "week", offset: -1 } },
next_week: { calendar: { period: "week", offset: 1 } },
this_month: { calendar: { period: "month" } },
last_month: { calendar: { period: "month", offset: -1 } },
next_month: { calendar: { period: "month", offset: 1 } },
this_year: { calendar: { period: "year" } },
last_year: { calendar: { period: "year", offset: -1 } },
next_7d: { calendar: { period: "day", offset: 7 } },
next_30d: { calendar: { period: "day", offset: 30 } },
} as const;
@customElement("ha-selector-period")
export class HaPeriodSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: PeriodSelector;
@property({ attribute: false }) public value?: unknown;
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
private _schema = memoizeOne(
(
selectedPeriodKey: PeriodKey | undefined,
selector: PeriodSelector,
localize: LocalizeFunc
) =>
[
{
name: "period",
required: this.required,
selector:
selectedPeriodKey && selectedPeriodKey in this._periods(selector)
? {
select: {
multiple: false,
options: Object.keys(this._periods(selector)).map(
(periodKey) => ({
value: periodKey,
label:
localize(
`ui.components.selectors.period.periods.${periodKey as PeriodKey}`
) || periodKey,
})
),
},
}
: { object: {} },
},
] as const
);
protected render() {
const data = this._data(this.value, this.selector);
const schema = this._schema(
typeof data.period === "string" ? (data.period as PeriodKey) : undefined,
this.selector,
this.hass.localize
);
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${schema}
.computeHelper=${this._computeHelperCallback}
.computeLabel=${this._computeLabelCallback}
.disabled=${this.disabled}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _periods = memoizeOne((selector: PeriodSelector) =>
Object.fromEntries(
Object.entries(PERIODS).filter(([key]) =>
selector.period?.options?.includes(key as any)
)
)
);
private _data = memoizeOne((value: unknown, selector: PeriodSelector) => {
for (const [periodKey, period] of Object.entries(this._periods(selector))) {
if (deepEqual(period, value)) {
return { period: periodKey };
}
}
return { period: value };
});
private _valueChanged(ev: CustomEvent) {
ev.stopPropagation();
const newValue = ev.detail.value;
if (typeof newValue.period === "string") {
const periods = this._periods(this.selector);
if (newValue.period in periods) {
const period = this._periods(this.selector)[newValue.period];
fireEvent(this, "value-changed", { value: period });
}
} else {
fireEvent(this, "value-changed", { value: newValue.period });
}
}
private _computeHelperCallback = () => this.helper;
private _computeLabelCallback = () => this.label;
static styles = css`
:host {
position: relative;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-period": HaPeriodSelector;
}
}

View File

@@ -41,6 +41,7 @@ const LOAD_ELEMENTS = {
number: () => import("./ha-selector-number"),
numeric_threshold: () => import("./ha-selector-numeric-threshold"),
object: () => import("./ha-selector-object"),
period: () => import("./ha-selector-period"),
qr_code: () => import("./ha-selector-qr-code"),
select: () => import("./ha-selector-select"),
selector: () => import("./ha-selector-selector"),

View File

@@ -516,17 +516,10 @@ export class HaServiceControl extends LitElement {
`}
${serviceData && "target" in serviceData
? html`<ha-settings-row .narrow=${this.narrow}>
${hasOptional
? html`<div slot="prefix" class="checkbox-spacer"></div>`
: ""}
<span slot="heading"
>${this.hass.localize("ui.components.service-control.target")}</span
>
<span slot="description"
>${this.hass.localize(
"ui.components.service-control.target_secondary"
)}</span
><ha-selector
<ha-selector
.hass=${this.hass}
.selector=${this._targetSelector(
serviceData.target as TargetSelector,

View File

@@ -1,5 +1,6 @@
import { HasSlotController } from "@home-assistant/webawesome/dist/internal/slot";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
@customElement("ha-settings-row")
@@ -16,17 +17,28 @@ export class HaSettingsRow extends LitElement {
@property({ type: Boolean, reflect: true }) public empty = false;
private readonly _hasSlotController = new HasSlotController(
this,
"description"
);
protected render(): TemplateResult {
const hasDescription = this._hasSlotController.test("description");
return html`
<div class="prefix-wrap">
<slot name="prefix"></slot>
<div
class="body"
?two-line=${!this.threeLine}
?two-line=${!this.threeLine && hasDescription}
?three-line=${this.threeLine}
>
<slot name="heading"></slot>
<div class="secondary"><slot name="description"></slot></div>
${hasDescription
? html`<span class="secondary"
><slot name="description"></slot
></span>`
: nothing}
</div>
</div>
<div class="content">

View File

@@ -1,294 +0,0 @@
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import "./input/ha-input";
import type { HaInput } from "./input/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 LitElement {
@property({ type: String })
public value = "";
@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
public icon = false;
@property({ type: Boolean })
// @ts-ignore
// eslint-disable-next-line lit/attribute-names
public iconTrailing = false;
@property({ type: Boolean })
public disabled = false;
@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("ha-input")
private _haInput?: HaInput;
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") && this._haInput) {
if (
this.invalid ||
(changedProperties.get("invalid") !== undefined && !this.invalid)
) {
this.reportValidity();
}
}
}
protected override render(): TemplateResult {
const errorMsg = this.errorMessage || this.validationMessage;
return html`
<ha-input
.type=${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.inputMode}
.autocapitalize=${this.autocapitalize || ""}
.invalid=${this.invalid}
.validationMessage=${errorMsg || ""}
.autoValidate=${this.autoValidate}
.hint=${this.helper}
.withoutSpinButtons=${this.type === "number"}
.insetLabel=${this.prefix}
@input=${this._onInput}
@change=${this._onChange}
>
${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>
`;
}
private _onInput(): void {
this.value = this._haInput?.value ?? "";
}
private _onChange(): void {
this.value = this._haInput?.value ?? "";
}
static override styles = css`
:host {
display: inline-flex;
flex-direction: column;
outline: none;
}
ha-input {
--ha-input-padding-bottom: 0;
width: 100%;
}
.prefix,
.suffix {
color: var(--secondary-text-color);
}
.prefix {
padding-top: var(--ha-space-3);
margin-inline-end: var(--text-field-prefix-padding-right);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-textfield": HaTextField;
}
}

View File

@@ -21,6 +21,8 @@ export class HaTimeInput extends LitElement {
@property({ type: Boolean }) public required = false;
@property({ attribute: "auto-validate", type: Boolean }) autoValidate = false;
@property({ type: Boolean, attribute: "enable-second" })
public enableSecond = false;
@@ -71,6 +73,7 @@ export class HaTimeInput extends LitElement {
.clearable=${this.clearable && this.value !== undefined}
.helper=${this.helper}
.placeholderLabels=${this.placeholderLabels}
.autoValidate=${this.autoValidate}
day-label="dd"
hour-label="hh"
min-label="mm"
@@ -86,6 +89,7 @@ export class HaTimeInput extends LitElement {
const useAMPM = useAmPm(this.locale);
let value: string | undefined;
let updateHours = 0;
// An undefined eventValue means the time selector is being cleared,
// the `value` variable will (intentionally) be left undefined.
@@ -97,6 +101,8 @@ export class HaTimeInput extends LitElement {
) {
let hours = eventValue.hours || 0;
if (eventValue && useAMPM) {
updateHours =
hours >= 12 && hours < 24 ? hours - 12 : hours === 0 ? 12 : 0;
if (eventValue.amPm === "PM" && hours < 12) {
hours += 12;
}
@@ -115,6 +121,17 @@ export class HaTimeInput extends LitElement {
}`;
}
if (updateHours) {
// If the user entered a 24hr time in a 12hr input, we need to refresh the
// input to ensure it resets back to the 12hr equivalent.
this.updateComplete.then(() => {
const input = this._input;
if (input) {
input.hours = updateHours;
}
});
}
if (value === this.value) {
return;
}

View File

@@ -1,9 +1,15 @@
import "@home-assistant/webawesome/dist/components/popup/popup";
import type WaPopup from "@home-assistant/webawesome/dist/components/popup/popup";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import {
customElement,
property,
query,
queryAssignedElements,
state,
} from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event";
import { popoverSupported } from "../common/feature-detect/support-popover";
import { nextRender } from "../common/util/render-status";
export type ToastCloseReason =
@@ -22,12 +28,15 @@ export class HaToast extends LitElement {
@property({ type: Number, attribute: "timeout-ms" }) public timeoutMs = 4000;
@query("wa-popup")
private _popup?: WaPopup;
@query(".toast")
private _toast?: HTMLDivElement;
@queryAssignedElements({ slot: "action", flatten: true })
private _actionElements?: Element[];
@queryAssignedElements({ slot: "dismiss", flatten: true })
private _dismissElements?: Element[];
@state() private _active = false;
@state() private _visible = false;
@@ -48,7 +57,6 @@ export class HaToast extends LitElement {
clearTimeout(this._dismissTimer);
if (this._active && this._visible) {
this._popup?.reposition();
this._setDismissTimer();
return;
}
@@ -62,7 +70,7 @@ export class HaToast extends LitElement {
return;
}
this._popup?.reposition();
this._showToastPopover();
await nextRender();
if (transitionId !== this._transitionId) {
@@ -102,6 +110,7 @@ export class HaToast extends LitElement {
return;
}
this._hideToastPopover();
this._active = false;
await this.updateComplete;
@@ -123,6 +132,34 @@ export class HaToast extends LitElement {
}
}
private _isPopoverOpen(): boolean {
if (!this._toast || !popoverSupported) {
return false;
}
try {
return this._toast.matches(":popover-open");
} catch {
return false;
}
}
private _showToastPopover(): void {
if (!this._toast || !popoverSupported || this._isPopoverOpen()) {
return;
}
this._toast.showPopover?.();
}
private _hideToastPopover(): void {
if (!this._toast || !popoverSupported || !this._isPopoverOpen()) {
return;
}
this._toast.hidePopover?.();
}
private async _waitForTransitionEnd(): Promise<void> {
const toastEl = this._toast;
if (!toastEl) {
@@ -138,82 +175,70 @@ export class HaToast extends LitElement {
}
protected render() {
const hasAction =
(this._actionElements?.length ?? 0) > 0 ||
(this._dismissElements?.length ?? 0) > 0;
return html`
<wa-popup
placement="top"
.active=${this._active}
.distance=${16}
skidding="0"
flip
shift
<div
class=${classMap({
toast: true,
active: this._active,
visible: this._visible,
})}
role="status"
aria-live="polite"
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
>
<div id="toast-anchor" slot="anchor" aria-hidden="true"></div>
<div
class=${classMap({
toast: true,
visible: this._visible,
})}
role="status"
aria-live="polite"
>
<span class="message">${this.labelText}</span>
<div class="actions">
<slot name="action"></slot>
<slot name="dismiss"></slot>
</div>
<span class="message">${this.labelText}</span>
<div class=${classMap({ actions: true, "has-action": hasAction })}>
<slot name="action"></slot>
<slot name="dismiss"></slot>
</div>
</wa-popup>
</div>
`;
}
static override styles = css`
#toast-anchor {
position: fixed;
bottom: calc(var(--ha-space-2) + var(--safe-area-inset-bottom));
inset-inline-start: 50%;
transform: translateX(-50%);
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
wa-popup::part(popup) {
padding: 0;
border-radius: var(--ha-border-radius-sm);
box-shadow: var(--wa-shadow-l);
overflow: hidden;
}
.toast {
box-sizing: border-box;
min-width: min(
350px,
calc(
100vw - var(--ha-space-4) - var(--safe-area-inset-left) - var(
--safe-area-inset-right
)
)
position: fixed;
inset-block-start: auto;
inset-inline-end: auto;
inset-block-end: calc(
var(--safe-area-inset-bottom, 0px) + var(--ha-space-4)
);
max-width: 650px;
inset-inline-start: 50%;
margin: 0;
width: max-content;
height: auto;
border: none;
overflow: hidden;
box-sizing: border-box;
min-width: min(350px, calc(var(--safe-width) - var(--ha-space-4)));
max-width: min(650px, var(--safe-width));
min-height: 48px;
display: flex;
align-items: center;
gap: var(--ha-space-2);
padding: var(--ha-space-2) var(--ha-space-3);
padding: var(--ha-space-3) var(--ha-space-4);
color: var(--ha-color-on-neutral-loud);
background-color: var(--ha-color-neutral-10);
border-radius: var(--ha-border-radius-sm);
box-shadow: var(--wa-shadow-l);
opacity: 0;
transform: translateY(var(--ha-space-2));
transform: translate(-50%, var(--ha-space-2));
transition:
opacity var(--ha-animation-duration-fast, 150ms) ease,
transform var(--ha-animation-duration-fast, 150ms) ease;
}
.toast:not(.active) {
display: none;
}
.toast.visible {
opacity: 1;
transform: translateY(0);
transform: translate(-50%, 0);
}
.message {
@@ -228,15 +253,14 @@ export class HaToast extends LitElement {
color: var(--ha-color-on-neutral-loud);
}
@media all and (max-width: 450px), all and (max-height: 500px) {
wa-popup::part(popup) {
border-radius: var(--ha-border-radius-square);
}
.actions:not(.has-action) {
display: none;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
.toast {
min-width: calc(
100vw - var(--safe-area-inset-left) - var(--safe-area-inset-right)
);
min-width: var(--safe-width);
max-width: var(--safe-width);
border-radius: var(--ha-border-radius-square);
}
}

View File

@@ -5,6 +5,7 @@ import {
import { supportsPassiveEventListener } from "@material/mwc-base/utils";
import type { MDCTopAppBarAdapter } from "@material/top-app-bar/adapter";
import { strings } from "@material/top-app-bar/constants";
// eslint-disable-next-line import-x/no-named-as-default
import MDCFixedTopAppBarFoundation from "@material/top-app-bar/fixed/foundation";
import { html, css, nothing } from "lit";
import { property, query, customElement } from "lit/decorators";

View File

@@ -235,7 +235,8 @@ export class HaInput extends LitElement {
this,
"label",
"hint",
"input"
"input",
"start"
);
static shadowRootOptions: ShadowRootInit = {
@@ -318,6 +319,8 @@ export class HaInput extends LitElement {
? false
: this._hasSlotController.test("hint");
const hasStartSlot = this._hasSlotController.test("start");
return html`
<wa-input
.type=${this.type}
@@ -348,7 +351,8 @@ export class HaInput extends LitElement {
invalid: this.invalid || this._invalid,
"label-raised":
(this.value !== undefined && this.value !== "") ||
(this.label && this.placeholder),
(this.label && this.placeholder) ||
(hasStartSlot && this.insetLabel),
"no-label": !this.label,
"hint-hidden":
!this.hint &&
@@ -589,6 +593,7 @@ export class HaInput extends LitElement {
}
:host([type="color"]) wa-input::part(input) {
padding-top: var(--ha-space-6);
padding-bottom: 2px;
cursor: pointer;
}
:host([type="color"]) wa-input.no-label::part(input) {
@@ -620,6 +625,10 @@ export class HaInput extends LitElement {
background-color: var(--ha-color-form-background-disabled);
}
wa-input:disabled::part(label) {
opacity: 0.5;
}
wa-input::part(hint) {
height: var(--ha-space-5);
margin-block-start: 0;

View File

@@ -535,7 +535,7 @@ export class HaTargetPickerItemRow extends LitElement {
const stateObject: HassEntity | undefined = this.hass.states[item];
const entityName = stateObject
? computeEntityName(stateObject, this.hass.entities)
? computeEntityName(stateObject, this.hass.entities, this.hass.devices)
: item;
const { area, device } = stateObject
? getEntityContext(

View File

@@ -1,3 +1,4 @@
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
@@ -12,6 +13,8 @@ import { customElement, property } from "lit/decorators";
* @slot primary - The primary text container.
* @slot secondary - The secondary text container.
*
* @property {boolean} secondaryLoading - Whether the secondary text is loading. Shows a skeleton placeholder.
*
* @cssprop --ha-tile-info-primary-font-size - The font size of the primary text. defaults to `var(--ha-font-size-m)`.
* @cssprop --ha-tile-info-primary-font-weight - The font weight of the primary text. defaults to `var(--ha-font-weight-medium)`.
* @cssprop --ha-tile-info-primary-line-height - The line height of the primary text. defaults to `var(--ha-line-height-normal)`.
@@ -29,21 +32,31 @@ export class HaTileInfo extends LitElement {
@property() public secondary?: string;
@property({ type: Boolean, attribute: "secondary-loading" })
public secondaryLoading = false;
protected render() {
return html`
<div class="info">
<slot name="primary" class="primary">
<span>${this.primary}</span>
</slot>
<slot name="secondary" class="secondary">
<span>${this.secondary}</span>
</slot>
${this.secondaryLoading
? html`<div class="secondary">
<wa-skeleton class="placeholder" effect="pulse"></wa-skeleton>
</div>`
: html`<slot name="secondary" class="secondary">
<span>${this.secondary}</span>
</slot>`}
</div>
`;
}
static styles = css`
:host {
display: block;
width: 100%;
min-width: 0;
--tile-info-primary-font-size: var(
--ha-tile-info-primary-font-size,
var(--ha-font-size-m)
@@ -112,6 +125,15 @@ export class HaTileInfo extends LitElement {
line-height: var(--tile-info-secondary-line-height);
letter-spacing: var(--tile-info-secondary-letter-spacing);
color: var(--tile-info-secondary-color);
width: 100%;
}
.placeholder {
width: 140px;
max-width: 100%;
height: var(--tile-info-secondary-font-size);
--wa-border-radius-pill: var(--ha-border-radius-sm);
--color: var(--ha-color-fill-neutral-normal-resting);
--sheen-color: var(--ha-color-fill-neutral-loud-resting);
}
`;
}

View File

@@ -32,7 +32,9 @@ export class HaTraceLogbook extends LitElement {
></hat-logbook-note>
`
: html`<div class="padded-box">
No Logbook entries found for this step.
${this.hass.localize(
"ui.panel.config.automation.trace.path.no_logbook_entries"
)}
</div>`;
}

View File

@@ -78,6 +78,19 @@ const localizeTimeString = (
}
};
const formatNumericLimitValue = (
hass: HomeAssistant,
value?: number | string
) => {
if (typeof value !== "string" || !isValidEntityId(value)) {
return value;
}
return hass.states[value]
? computeStateName(hass.states[value]) || value
: value;
};
export const describeTrigger = (
trigger: Trigger,
hass: HomeAssistant,
@@ -233,8 +246,8 @@ const describeLegacyTrigger = (
attribute: attribute,
entity: formatListWithOrs(hass.locale, entities),
numberOfEntities: entities.length,
above: trigger.above,
below: trigger.below,
above: formatNumericLimitValue(hass, trigger.above),
below: formatNumericLimitValue(hass, trigger.below),
duration: duration,
}
);
@@ -246,7 +259,7 @@ const describeLegacyTrigger = (
attribute: attribute,
entity: formatListWithOrs(hass.locale, entities),
numberOfEntities: entities.length,
above: trigger.above,
above: formatNumericLimitValue(hass, trigger.above),
duration: duration,
}
);
@@ -258,7 +271,7 @@ const describeLegacyTrigger = (
attribute: attribute,
entity: formatListWithOrs(hass.locale, entities),
numberOfEntities: entities.length,
below: trigger.below,
below: formatNumericLimitValue(hass, trigger.below),
duration: duration,
}
);
@@ -813,16 +826,16 @@ const describeLegacyTrigger = (
: trigger.entity_id;
let offsetChoice = "other";
let offset: string | string[] = "";
let offset = "";
if (trigger.offset) {
offsetChoice = trigger.offset.startsWith("-") ? "before" : "after";
offset = trigger.offset.startsWith("-")
const parts = trigger.offset.startsWith("-")
? trigger.offset.substring(1).split(":")
: trigger.offset.split(":");
const duration = {
hours: offset.length > 0 ? +offset[0] : 0,
minutes: offset.length > 1 ? +offset[1] : 0,
seconds: offset.length > 2 ? +offset[2] : 0,
hours: parts.length > 0 ? +parts[0] : 0,
minutes: parts.length > 1 ? +parts[1] : 0,
seconds: parts.length > 2 ? +parts[2] : 0,
};
offset = formatDurationLong(hass.locale, duration);
if (offset === "") {
@@ -1116,8 +1129,8 @@ const describeLegacyCondition = (
attribute,
entity,
numberOfEntities: entity_ids.length,
above: condition.above,
below: condition.below,
above: formatNumericLimitValue(hass, condition.above),
below: formatNumericLimitValue(hass, condition.below),
}
);
}
@@ -1128,7 +1141,7 @@ const describeLegacyCondition = (
attribute,
entity,
numberOfEntities: entity_ids.length,
above: condition.above,
above: formatNumericLimitValue(hass, condition.above),
}
);
}
@@ -1139,7 +1152,7 @@ const describeLegacyCondition = (
attribute,
entity,
numberOfEntities: entity_ids.length,
below: condition.below,
below: formatNumericLimitValue(hass, condition.below),
}
);
}

View File

@@ -20,6 +20,7 @@ export interface CoreFrontendSystemData {
export interface HomeFrontendSystemData {
favorite_entities?: string[];
welcome_banner_dismissed?: boolean;
hidden_summaries?: string[];
}
declare global {

View File

@@ -15,7 +15,7 @@ interface HassioHardwareAudioList {
};
}
interface HardwareDevice {
export interface HardwareDevice {
attributes: Record<string, string>;
by_id: null | string;
dev_path: string;

View File

@@ -3,8 +3,10 @@ import {
mdiAirFilter,
mdiAlert,
mdiAppleSafari,
mdiBattery,
mdiBell,
mdiBookmark,
mdiBrightness6,
mdiBullhorn,
mdiButtonPointer,
mdiCalendar,
@@ -16,13 +18,18 @@ import {
mdiCog,
mdiCommentAlert,
mdiCounter,
mdiDoorOpen,
mdiEye,
mdiFlash,
mdiFlower,
mdiFormatListBulleted,
mdiFormTextbox,
mdiForumOutline,
mdiGarageOpen,
mdiGate,
mdiGoogleAssistant,
mdiGoogleCirclesCommunities,
mdiHomeAccount,
mdiHomeAutomation,
mdiImage,
mdiImageFilterFrames,
@@ -30,6 +37,7 @@ import {
mdiLightbulb,
mdiMapMarkerRadius,
mdiMicrophoneMessage,
mdiMotionSensor,
mdiPalette,
mdiRayVertex,
mdiRemote,
@@ -41,10 +49,14 @@ import {
mdiSpeakerMessage,
mdiStarFourPoints,
mdiThermostat,
mdiThermometer,
mdiTimerOutline,
mdiToggleSwitch,
mdiWater,
mdiWaterPercent,
mdiWeatherPartlyCloudy,
mdiWhiteBalanceSunny,
mdiWindowClosed,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { isComponentLoaded } from "../common/config/is_component_loaded";
@@ -75,6 +87,7 @@ export const FALLBACK_DOMAIN_ICONS = {
air_quality: mdiAirFilter,
alert: mdiAlert,
automation: mdiRobot,
battery: mdiBattery,
calendar: mdiCalendar,
climate: mdiThermostat,
configurator: mdiCog,
@@ -84,10 +97,15 @@ export const FALLBACK_DOMAIN_ICONS = {
datetime: mdiCalendarClock,
demo: mdiHomeAssistant,
device_tracker: mdiAccount,
door: mdiDoorOpen,
garage_door: mdiGarageOpen,
gate: mdiGate,
google_assistant: mdiGoogleAssistant,
group: mdiGoogleCirclesCommunities,
homeassistant: mdiHomeAssistant,
homekit: mdiHomeAutomation,
humidity: mdiWaterPercent,
illuminance: mdiBrightness6,
image_processing: mdiImageFilterFrames,
image: mdiImage,
infrared: mdiLedOn,
@@ -99,11 +117,15 @@ export const FALLBACK_DOMAIN_ICONS = {
input_text: mdiFormTextbox,
lawn_mower: mdiRobotMower,
light: mdiLightbulb,
moisture: mdiWater,
motion: mdiMotionSensor,
notify: mdiCommentAlert,
number: mdiRayVertex,
occupancy: mdiHomeAccount,
persistent_notification: mdiBell,
person: mdiAccount,
plant: mdiFlower,
power: mdiFlash,
proximity: mdiAppleSafari,
remote: mdiRemote,
scene: mdiPalette,
@@ -115,6 +137,7 @@ export const FALLBACK_DOMAIN_ICONS = {
siren: mdiBullhorn,
stt: mdiMicrophoneMessage,
sun: mdiWhiteBalanceSunny,
temperature: mdiThermometer,
text: mdiFormTextbox,
time: mdiClock,
timer: mdiTimerOutline,
@@ -124,6 +147,7 @@ export const FALLBACK_DOMAIN_ICONS = {
vacuum: mdiRobotVacuum,
wake_word: mdiChatSleep,
weather: mdiWeatherPartlyCloudy,
window: mdiWindowClosed,
zone: mdiMapMarkerRadius,
};

View File

@@ -60,6 +60,7 @@ export type Selector =
| NumberSelector
| NumericThresholdSelector
| ObjectSelector
| PeriodSelector
| AssistPipelineSelector
| QRCodeSelector
| SelectSelector
@@ -392,6 +393,27 @@ export interface ObjectSelector {
} | null;
}
export type PeriodKey =
| "today"
| "yesterday"
| "tomorrow"
| "this_week"
| "last_week"
| "next_week"
| "this_month"
| "last_month"
| "next_month"
| "this_year"
| "last_year"
| "next_7d"
| "next_30d"
| "none";
export interface PeriodSelector {
period: {
options: readonly PeriodKey[];
} | null;
}
export interface AssistPipelineSelector {
assist_pipeline: {
include_last_used?: boolean;

View File

@@ -338,7 +338,7 @@ class EntityPreviewRow extends LitElement {
.autoValidate=${stateObj.attributes.pattern}
.pattern=${stateObj.attributes.pattern}
.type=${stateObj.attributes.mode}
placeholder=${this.hass!.localize("ui.card.text.emtpy_value")}
.placeholder=${this.hass!.localize("ui.card.text.empty_value")}
></ha-input>
`;
}

View File

@@ -11,6 +11,8 @@ export const DialogMixin = <
superClass: T
) =>
class extends superClass implements HassDialogNext<P> {
public dialogNext = true as const;
declare public params?: P;
private _closePromise?: Promise<boolean>;

View File

@@ -73,7 +73,6 @@ class DialogBox extends LitElement {
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
type=${confirmPrompt ? "alert" : "standard"}
?prevent-scrim-close=${confirmPrompt}
@@ -104,6 +103,9 @@ class DialogBox extends LitElement {
: nothing}
${dialogTitle}
</span>
${this._params.subtitle
? html`<span slot="subtitle">${this._params.subtitle}</span>`
: nothing}
</ha-dialog-header>
<div id="dialog-box-description">
${this._params.text ? html` <p>${this._params.text}</p> ` : ""}

View File

@@ -5,6 +5,7 @@ interface BaseDialogBoxParams {
confirmText?: string;
text?: string | TemplateResult;
title?: string;
subtitle?: string;
warning?: boolean;
}

View File

@@ -26,6 +26,7 @@ export interface HassDialog<T = unknown> extends HTMLElement {
}
export interface HassDialogNext<T = unknown> extends HTMLElement {
dialogNext: true;
params?: T;
closeDialog?: (historyState?: any) => Promise<boolean> | boolean;
}
@@ -168,10 +169,12 @@ export const showDialog = async (
dialogElement = await LOADED[dialogTag].element;
}
if ("showDialog" in dialogElement!) {
if ("dialogNext" in dialogElement! && dialogElement.dialogNext) {
dialogElement!.params = dialogParams;
} else if ("showDialog" in dialogElement!) {
dialogElement.showDialog(dialogParams);
} else {
dialogElement!.params = dialogParams;
throw new Error("Unknown dialog type loaded");
}
(parentElement || element).shadowRoot!.appendChild(dialogElement!);

View File

@@ -82,6 +82,11 @@ class MoreInfoInputDatetime extends LitElement {
display: flex;
align-items: center;
justify-content: flex-end;
--ha-input-padding-bottom: 0;
flex-wrap: wrap;
}
ha-date-input {
flex: 1 1 160px;
}
ha-date-input + ha-time-input {
margin-left: var(--ha-space-1);

View File

@@ -536,9 +536,9 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
: undefined;
const entityName = stateObj
? computeEntityName(stateObj, this.hass.entities)
? computeEntityName(stateObj, this.hass.entities, this.hass.devices)
: this._entry
? computeEntityEntryName(this._entry)
? computeEntityEntryName(this._entry, this.hass.devices)
: entityId;
const deviceName = context?.device

View File

@@ -107,7 +107,11 @@ class MoreInfoContent extends LitElement {
if (!stateObj) {
return null;
}
const entityName = computeEntityName(stateObj, hass.entities);
const entityName = computeEntityName(
stateObj,
hass.entities,
hass.devices
);
const { area } = getEntityContext(
stateObj,
hass.entities,

View File

@@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/triple-slash-reference */
/// <reference path="../types/service-worker.d.ts" />
/* eslint-env serviceworker */
import type { RouteHandler } from "workbox-core";
import { cacheNames } from "workbox-core";
import { CacheableResponsePlugin } from "workbox-cacheable-response";

View File

@@ -505,7 +505,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
`
: ""}
<ha-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.columns=${this.columns}
.data=${this.data}

View File

@@ -1,10 +1,13 @@
import type { ReactiveElement } from "lit";
import { consume } from "@lit/context";
import type { PropertyValues, ReactiveElement } from "lit";
import { state } from "lit/decorators";
import type { HomeAssistant } from "../types";
import {
setupMediaQueryListeners,
setupTimeListeners,
} from "../common/condition/listeners";
import type { Condition } from "../panels/lovelace/common/validate-condition";
import { setupConditionListeners } from "../common/condition/listeners";
import { maxColumnsContext } from "../panels/lovelace/common/context";
import type {
Condition,
ConditionContext,
} from "../panels/lovelace/common/validate-condition";
type Constructor<T> = abstract new (...args: any[]) => T;
@@ -32,6 +35,7 @@ export interface ConditionalConfig {
* - Sets up listeners when component connects to DOM
* - Cleans up listeners when component disconnects from DOM
* - Handles conditional visibility based on defined conditions
* - Consumes column count from the view via Lit Context
*/
export const ConditionalListenerMixin = <
TConfig extends ConditionalConfig = ConditionalConfig,
@@ -47,6 +51,12 @@ export const ConditionalListenerMixin = <
public hass?: HomeAssistant;
@state()
@consume({ context: maxColumnsContext, subscribe: true })
protected _maxColumns?: number;
protected _conditionContext: ConditionContext = {};
protected _updateElement?(config: TConfig): void;
protected _updateVisibility?(conditionsMet?: boolean): void;
@@ -61,6 +71,20 @@ export const ConditionalListenerMixin = <
this.clearConditionalListeners();
}
protected willUpdate(changedProperties: PropertyValues) {
super.willUpdate(changedProperties);
if (changedProperties.has("_maxColumns")) {
this._conditionContext = { max_columns: this._maxColumns };
}
}
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (changedProperties.has("_maxColumns")) {
this._updateVisibility?.();
}
}
/**
* Clear conditional listeners
*
@@ -106,26 +130,18 @@ export const ConditionalListenerMixin = <
return;
}
const onUpdate = (conditionsMet: boolean) => {
if (this._updateVisibility) {
this._updateVisibility(conditionsMet);
} else if (this._updateElement && config) {
this._updateElement(config);
}
};
setupMediaQueryListeners(
setupConditionListeners(
finalConditions,
this.hass,
(unsub) => this.addConditionalListener(unsub),
onUpdate
);
setupTimeListeners(
finalConditions,
this.hass,
(unsub) => this.addConditionalListener(unsub),
onUpdate
(conditionsMet) => {
if (this._updateVisibility) {
this._updateVisibility(conditionsMet);
} else if (this._updateElement && config) {
this._updateElement(config);
}
},
() => this._conditionContext
);
}
}

View File

@@ -271,6 +271,7 @@ class DialogCalendarEventDetail extends LitElement {
color: var(--secondary-text-color);
max-width: 300px;
overflow-wrap: break-word;
white-space: pre-line;
}
`,
];

View File

@@ -65,6 +65,9 @@ const processAreasForClimate = (
if (temperatureEntityId && hass.states[temperatureEntityId]) {
areaCards.push({
...computeTileCard(temperatureEntityId),
name:
hass.localize("component.sensor.entity_component.temperature.name") ||
"Temperature",
features: [{ type: "trend-graph" }],
});
}
@@ -73,6 +76,9 @@ const processAreasForClimate = (
if (humidityEntityId && hass.states[humidityEntityId]) {
areaCards.push({
...computeTileCard(humidityEntityId),
name:
hass.localize("component.sensor.entity_component.humidity.name") ||
"Humidity",
features: [{ type: "trend-graph" }],
});
}

View File

@@ -1038,6 +1038,7 @@ class SupervisorAppInfo extends LitElement {
}
private _updateComplete() {
this._scheduleDataUpdate();
const eventdata = {
success: true,
response: undefined,
@@ -1059,11 +1060,16 @@ class SupervisorAppInfo extends LitElement {
};
fireEvent(this, "hass-api-called", eventdata);
} catch (err: any) {
showAlertDialog(this, {
showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.apps.dashboard.action_error.install"
),
text: extractApiErrorMessage(err),
confirmText: this.hass.localize("ui.common.ok"),
dismissText: this.hass.localize(
"ui.panel.config.apps.dashboard.action_error.view_supervisor_logs"
),
cancel: () => navigate("/config/logs?provider=supervisor"),
});
}
button.progress = false;

View File

@@ -9,7 +9,7 @@ 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 { goBack, navigate } from "../../../common/navigate";
import { extractSearchParam } from "../../../common/url/search-params";
import type { HassioAddonDetails } from "../../../data/hassio/addon";
import { fetchHassioAddonInfo } from "../../../data/hassio/addon";
@@ -234,7 +234,7 @@ class HaConfigAppDashboard extends LitElement {
if (path === "uninstall") {
// Navigate back to installed apps after uninstall
window.history.back();
goBack(this._fromStore ? "/config/apps/available" : "/config/apps");
} else {
// Reload app info
await this._loadAddon();

View File

@@ -108,7 +108,6 @@ export class HaConfigAppsRegistries extends LitElement {
.header=${this.hass.localize("ui.panel.config.apps.store.registries")}
>
<ha-data-table
.hass=${this.hass}
.columns=${this._columns(this.hass.localize)}
.data=${this._registries}
.noDataText=${this.hass.localize(

View File

@@ -187,7 +187,6 @@ export class HaConfigAppsRepositories extends LitElement {
.header=${this.hass.localize("ui.panel.config.apps.store.repositories")}
>
<ha-data-table
.hass=${this.hass}
.columns=${this._columns(this.hass.localize, usedRepositories)}
.data=${this._data(repositories)}
.noDataText=${this.hass.localize(

View File

@@ -95,6 +95,8 @@ import "./types/ha-automation-action-set_conversation_response";
import "./types/ha-automation-action-stop";
import "./types/ha-automation-action-wait_for_trigger";
import "./types/ha-automation-action-wait_template";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { computeObjectId } from "../../../../common/entity/compute_object_id";
export const getAutomationActionType = memoizeOne(
(action: Action | undefined) => {
@@ -185,6 +187,8 @@ export default class HaAutomationActionRow extends LitElement {
@state() private _collapsed = true;
@state() private _isNew = false;
@state() private _warnings?: string[];
@query("ha-automation-action-editor")
@@ -237,12 +241,20 @@ export default class HaAutomationActionRow extends LitElement {
private _renderRow() {
const type = getAutomationActionType(this.action);
const target =
type === "service" && "target" in this.action
? (this.action as ServiceAction).target
: type === "device_id" && (this.action as DeviceAction).device_id
? { device_id: (this.action as DeviceAction).device_id }
: undefined;
const action = type === "service" && (this.action as ServiceAction).action;
const actionHasTarget =
action &&
"target" in
(this.hass.services?.[computeDomain(action)]?.[
computeObjectId(action)
] || {});
const target = actionHasTarget
? (this.action as ServiceAction).target
: type === "device_id" && (this.action as DeviceAction).device_id
? { device_id: (this.action as DeviceAction).device_id }
: undefined;
return html`
${type === "service" && "action" in this.action && this.action.action
@@ -265,7 +277,9 @@ export default class HaAutomationActionRow extends LitElement {
${capitalizeFirstLetter(
describeAction(this.hass, this._entityReg, this.action)
)}
${target ? this._renderTargets(target) : nothing}
${target !== undefined || (actionHasTarget && !this._isNew)
? this._renderTargets(target, actionHasTarget && !this._isNew)
: nothing}
${type !== "condition" &&
(this.action as NonConditionAction).continue_on_error === true
? html`<ha-svg-icon
@@ -545,7 +559,10 @@ export default class HaAutomationActionRow extends LitElement {
>${this._renderRow()}</ha-automation-row
>`
: html`
<ha-expansion-panel left-chevron>
<ha-expansion-panel
left-chevron
@expanded-changed=${this._expansionPanelChanged}
>
${this._renderRow()}
</ha-expansion-panel>
`}
@@ -575,10 +592,11 @@ export default class HaAutomationActionRow extends LitElement {
}
private _renderTargets = memoizeOne(
(target?: HassServiceTarget) =>
(target?: HassServiceTarget, targetRequired = false) =>
html`<ha-automation-row-targets
.hass=${this.hass}
.target=${target}
.targetRequired=${targetRequired}
></ha-automation-row-targets>`
);
@@ -802,6 +820,12 @@ export default class HaAutomationActionRow extends LitElement {
}
}
private _expansionPanelChanged(ev: CustomEvent) {
if (!ev.detail.expanded) {
this._isNew = false;
}
}
private _toggleSidebar(ev: Event) {
ev?.stopPropagation();
@@ -812,6 +836,10 @@ export default class HaAutomationActionRow extends LitElement {
this.openSidebar();
}
public markAsNew(): void {
this._isNew = true;
}
public openSidebar(action?: Action): void {
const sidebarAction = action ?? this.action;
const actionType = getAutomationActionType(sidebarAction);
@@ -822,6 +850,7 @@ export default class HaAutomationActionRow extends LitElement {
},
close: (focus?: boolean) => {
this._selected = false;
this._isNew = false;
fireEvent(this, "close-sidebar");
if (focus) {
this.focus();

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