Compare commits

...
Author SHA1 Message Date
Paul Bottein 5f11dc4580 Replace hide-zoom-controls with a zoom slider mode on charts (#55468) 2026-10-29 17:12:40 +01:00
Simon Lamon 27489f188e date-range-picker migrate list (#55471) 2026-10-29 17:57:24 +02:00
Simon Lamon 97fffc3b43 ha-config-person migrate list (#55473) 2026-10-29 17:56:23 +02:00
renovate[bot] a30193b8ef Update dependency marked to v18.1.2 (#55470)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-29 17:50:42 +02:00
Aidan Timson cf063dc19f Update the PR template for the contributors docs (#55459) 2026-10-29 17:05:36 +02:00
Simon Lamon 5db829b7be pick-auth-provider migrate list (#55462) 2026-10-29 17:04:51 +02:00
Maarten Lakerveld 421f8b1986 Load the legacy Intl polyfills once per page and bundle PluralRules with its data (#55452)
Each legacy entry bundle carries its own copy of intl-polyfill.ts, so the
module-level guard only stopped re-runs within one bundle. With the bundles
loading in parallel, the second bundle could probe Intl.PluralRules after the
first had installed the polyfill but before its locale data chunk arrived. The
FormatJS should-polyfill probe calls select(), which throws on a data-less
polyfill, rejecting the top-level await so the entry never evaluated. On the
companion app this surfaces as ExternalBusTimeout on old WebViews.

Share one promise per page on globalThis so every bundle waits for the same
run, and import the PluralRules polyfill and its English data from one module
so Intl.PluralRules is never installed without data.
2026-10-29 17:02:23 +02:00
Simon Lamon a140eedc6e Clean up ha-radio-list-item (#55454) 2026-10-29 17:00:59 +02:00
renovate[bot] 2933568658 Update dependency home-assistant-js-websocket to v9.8.1 (#55450)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-29 16:59:26 +02:00
Simon Lamon 29faeaf7fa Remove incorrect type usage from date-range-picker (#55460) 2026-10-29 16:58:55 +02:00
Simon Lamon 95ba532ceb Removes incorrect type from cast (#55461) 2026-10-29 16:58:33 +02:00
Guillaume Rischard 352ecb9d6d Simplify OpenStreetMap attribution (#55447)
As per https://osmfoundation.org/wiki/Licence/Attribution_Guidelines:

> Attribution must be to “OpenStreetMap”.
[...]
> The historical forms of attribution “© OpenStreetMap contributors” or “© OpenStreetMap” are acceptable.
2026-10-29 15:27:49 +01:00
Erwin Douna 3f73049a3d Fade out energy period loading spinner (#55431) 2026-10-29 15:06:53 +01:00
Petar Petrov 50ad0c6cd0 Name new disk mounts after the partition's volume label (#55441) 2026-10-29 14:58:56 +01:00
Paul Bottein 78f95bbb06 Show a toast with a link to the dashboard after adding to it (#55444) 2026-10-29 14:36:46 +01:00
WendelinandClaude Opus 5.5 6134056776 Migrate the device, area and integration lists to ha-list-base (#55445)
* Migrate the config device, area and integration lists from ha-list

Moves the device child, via and linked devices cards, the device
entities card, the area page, the Matter fabrics and ping dialogs, the
Thread border routers, the Z-Wave remove node dialog and the Z-Wave
config entry picker from the mwc-based ha-list and ha-list-item to
ha-list-base, ha-list-nav, ha-list-item-button and ha-list-item-base.

- Render links as ha-list-item-button with an href instead of an anchor
  around a list item, so the rows take part in keyboard navigation
- Render automations and scenes that are not editable as
  ha-list-item-base with the existing tooltip, as on the device page
- Wrap the area scripts and the Thread border routers in a list, as
  the other sections already were
- Only render the remove fabric button where mwc showed it, and give it
  a label
- Keep the list padding and row alignment of the previous lists

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ln7qMagr2YDdssKkaBiqf5

* Add aria labels and adjust padding for ha-list components in device and integration dialogs

* Refactor aria labels for related lists in ha-config-area-page and update translations

---------

Co-authored-by: Claude <[email protected]>
2026-10-29 14:09:58 +01:00
Aidan Timson b79c6350ec Add date format presets to the clock and date cards (#55434) 2026-10-29 11:15:42 +00:00
Paul Bottein 8148041ddc Show multi-select chips in a field with an inline add button (#55416) 2026-10-29 09:59:54 +00:00
WendelinandClaude Opus 5.5 4265624c0e Wrap long text in debug tool and network discovery rows (#55438)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-10-29 09:40:29 +00:00
Paul Bottein ed4179de8f Use the state color by default in alert cards and badges (#55436) 2026-10-29 09:29:06 +00:00
WendelinandClaude Opus 5.5 e407a1b2dd Migrate the remaining selection lists to ha-list-selectable (#55437)
* Migrate the remaining selection lists from ha-list to ha-list-selectable

Moves the media management dialog, the calendar panel pane, the expose
entities dialog, the user visibility condition and the select view dialog
from the mwc-based ha-list, ha-check-list-item and ha-radio-list-item to
ha-list-selectable and ha-list-item-option. The read-only ZHA endpoint
list now uses ha-list-base, as its rows already were ha-list-item-base.

- Use ha-list-selectable-virtualized for the expose entities dialog,
  replacing the custom click and keyboard handling around lit-virtualizer
- Render the calendar pane as its own list instead of reusing the
  dropdown items, sharing the selection logic with the dropdown
- Make the select view dialog a single-select list and keep the icon
  column aligned for views without an icon
- Drop the media management workaround for spurious mwc select events
- Render the create calendar row as ha-list-item-button
- Remove unused element imports in the touched files

After this, ha-check-list-item is only used by the todo list card and
ha-radio-list-item is unused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ln7qMagr2YDdssKkaBiqf5

* Refactor ha-list components for improved focusability handling and update list items logic

* Enhance focus handling in ha-list-base and update tests for disabled items

* use helper

---------

Co-authored-by: Claude <[email protected]>
2026-10-29 11:16:09 +02:00
Aidan Timson e851101a93 Fix the Marketplace search field overlapping the top bar icons on narrow screens (#55439)
Let the narrow data table search field shrink to fit
2026-10-29 11:13:19 +02:00
Tristan Cartledge d43e1ef4cc Fix duplicated hidden areas when reordering areas in the areas dashboard editor (#55424) 2026-10-29 09:37:33 +01:00
Petar Petrov f699019994 Ignore YAML dashboard update event on first load (#55430) 2026-10-29 09:28:05 +01:00
Daan Vervacke fe90b0339d Show blueprint usage count as text on narrow screens (#55427)
* Show blueprint usage count as text on narrow screens

* Show the blueprint usage count before the path on narrow screens

* Name the blueprint usage column and skip it for broken blueprints
2026-10-29 08:34:42 +02:00
renovate[bot] 3b5ba802f6 Update dependency @rspack/dev-server to v2.2.2 (#55429)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-29 08:30:43 +02:00
JonathanandXyolyp b0c6e7f564 Preselect the only allowed unit also in an optional unit selector (#55417)
Co-authored-by: Xyolyp <[email protected]>
2026-10-29 08:17:31 +02:00
renovate[bot] 658a23724e Update tsparticles to v4.5.0 (#55428)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-29 08:00:52 +02:00
Maarten Lakerveld f859183628 Name maintenance problem tiles after the device and the entity (#55420)
Problem binary sensors were added to the maintenance view using the
battery tile builder, which names every tile after its device. A device
with several problem sensors (overheating, overvoltage, ...) then showed
identical tiles. Problem tiles now use a device plus entity name; the
entity part is dropped automatically when the sensor has no name of its
own, so single-sensor devices still read as just the device name.
Battery tiles keep the device-only name.

Fixes home-assistant/frontend#55413
2026-10-28 20:42:19 +01:00
Paul Bottein 53dc14f413 Keep the section row divider under the entities card header (#55415) 2026-10-28 19:42:41 +01:00
Aidan Timson f2523095ff Remember logs page search, integrations and raw view (#55399)
* Remember logs page search, integrations and raw view

* Keep stored logs choices from links and loading apps

* Keep logs choices when closing dialogs and while apps load

* Restore stored logs choices when linked to the page again
2026-10-28 16:03:00 +00:00
ef5c04b67f Show stop button in the media player more-info dialog (#54794)
* Don't hide stop action on playing media players

* Show stop as a secondary button when play/pause exists

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Add media player entity for "Playing The Music" and update media control card

* Implement logic to remove secondary stop control when play or pause is available in media player

* Test removing the secondary stop control

Runs real computeMediaControls output through removeSecondaryStopControl,
like its callers do, to protect when stop is removed and when it has to
stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Wendelin <[email protected]>
2026-10-28 18:01:26 +02:00
WendelinandClaude Opus 5.5 10ed6f709e Migrate the data table filters to ha-list-selectable (#55412)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-10-28 16:55:17 +01:00
Bram KragtenandClaude Opus 5.5 c22de3bc7c Keep the device and area page headers fixed while scrolling (#55409)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-28 16:08:12 +01:00
Bram Kragten 37f67a2f45 Bumped version to 20261028.0 2026-10-28 16:04:03 +01:00
Petar Petrov 06df9e3801 Tolerate missing rows and invalid visibility entries (#55408) 2026-10-28 14:58:29 +00:00
renovate[bot] 7f45b38d6f Update dependency home-assistant-js-websocket to v9.8.0 (#55389)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-28 14:42:54 +00:00
renovate[bot] 5c0f9b6501 Update dependency maplibre-gl to v6.16.0 (#55406) 2026-10-28 14:40:21 +00:00
Wendelin 645811dfdf Show entity pictures in the add automation element target tree (#55407) 2026-10-28 15:28:25 +01:00
Paul Bottein 45ec2e805a Rename the MCP card URL labels (#55401) 2026-10-28 15:10:56 +01:00
Paul BotteinandBram Kragten 18c93baa70 Add device to dashboard from the device page (#55381)
Co-authored-by: Bram Kragten <[email protected]>
2026-10-28 14:06:15 +00:00
Petar Petrov 2518c6ea77 Keep fitted neighbors clear of the network graph controls (#55405) 2026-10-28 14:48:56 +01:00
Bram Kragten 702381515a Merge branch 'rc' into dev 2026-10-28 14:43:44 +01:00
Przemysław Szypowicz 93efd84fab Load custom modules after the scoped registry polyfill (#54444) 2026-10-28 14:29:23 +01:00
Pradeep Peddineniandpradeeppeddineni be3841ff0d Fit gauge card to the card height in sections view (#55260)
* Fit gauge card to the card height in sections view

* Address review: flex container for gauge, min_rows 2, drop DOM tests

- Wrap ha-gauge in a container that takes the space left after the name
  (flex: 1, min-height: 0, container-type: size) and size the gauge with
  min(250px, 100cqw, 100cqh * 100 / 55) instead of the calc
- Use typeof rows === "number" like the other cards
- Set min_rows: 2 in getGridOptions, one row leaves almost no room
- Remove the DOM tests

---------

Co-authored-by: pradeeppeddineni <[email protected]>
2026-10-28 14:23:28 +01:00
Paul Bottein bfd6ff56a5 Show the helper under the label in checkbox lists (#55400) 2026-10-28 13:21:23 +00:00
Petar Petrov 41ce82ca01 Keep network graph labels when zooming during their fade in (#55397)
With physics off, graph labels fade in after the physics toggle or a
chart rebuild. A roam updates label layout instantly, and ECharts then
stops every label animation, which froze the fade at its current
opacity. Finish the fade before such an instant update.
2026-10-28 14:14:15 +01:00
Petar Petrov f8826fc821 Highlight the device in the ZHA network visualization (#55394) 2026-10-28 14:11:25 +01:00
Petar Petrov c0a98dba54 Keep entity selector data while it refreshes after a reconnect (#55396) 2026-10-28 13:00:50 +00:00
Erwin Douna 839a910a19 Start logs page filters collapsed (#55398) 2026-10-28 12:54:26 +00:00
Petar Petrov 345745ef47 Call the energy power sensor derived, not a helper (#55395) 2026-10-28 12:29:55 +00:00
João Pedro HickmannandPetar Petrov c4c0ae0dde Animate energy distribution flow dots on the compositor (#54872)
Co-authored-by: Petar Petrov <[email protected]>
2026-10-28 13:05:56 +01:00
6297d3e972 Add Zigbee Green Power support to ZHA (#55322)
* Add ZHADeviceType enum for ZHA device types

* Translate ZHA device types

* Support Green Power devices in ZHA panels

* Reword joining text to reflect device-driven joining, ZHA doesn't search

* Use translations for `Source ID`

* Update src/panels/config/integrations/integration-panels/zha/functions.ts

Co-authored-by: Petar Petrov <[email protected]>

---------

Co-authored-by: Simon Lamon <[email protected]>
Co-authored-by: Petar Petrov <[email protected]>
2026-10-28 14:04:22 +02:00
Petar Petrov 8985b7d08d Offer gas and water sensors from flow rates (#55360)
* Offer to create an energy sensor for power-only energy devices

An individual device needs an energy sensor, so a device with only a
power sensor could not be saved. Saving now asks to create an Integral
helper from the power sensor, or to reuse one that already integrates
it, and uses it as the device's energy sensor.

* Offer energy sensors from power for solar and home sources

Move the Integral helper flow out of the device dialog into a shared
confirmIntegrals helper. The confirmation now creates the helpers itself
and shows its progress. Solar production and the home meter can be saved
with only a power sensor, like individual devices.

* Offer gas and water sensors from flow rates

Gas and water sources and individual water devices can now be saved with
only a flow rate sensor. Saving offers to create an Integral helper with the
gas or water device class, or to reuse one that already integrates the rate.

* Offer energy sensors from power for grid and battery sources

A grid connection or battery with two power sensors can now create Integral
helpers for its missing energy sensors on save, or reuse existing ones. A
battery needs both, while a grid connection can skip them and save as before.

* Keep a device's children out of its upstream choices without an energy sensor

Clearing the energy sensor while editing a device dropped its children from the
check, so a child could be picked as the upstream device. Saving then reuses
the original Integral helper, which made the hierarchy circular.

* Save the submitted device when its dialog closes during helper creation

Snapshot the draft before creating the Integral helper, and only update the
dialog while the same dialog session is open. Always pass the energy device
class, as the helper only infers it from a power device class.

* Save the submitted water device when its dialog closes during helper creation

Mirror the electricity device dialog: snapshot the draft before creating the
Integral helper, and only update the dialog while the same session is open.

* Price gas in the unit of a reused helper

A reused Integral helper can display another unit than a new one would, so
take the price unit from that helper while only a flow rate is set.

* Keep grid and battery helpers apart and wait for all of them

Neither direction of a source may reuse the statistic picked for the other.
When one of several helpers fails, wait for the others to finish, so a retry
reuses them instead of creating duplicates.

* Lock the device form while it saves

The helper is created after the confirmation closes, so edits made meanwhile
would be replaced by the submitted device.

* Lock the water device form while it saves

Mirror the electricity device dialog, so edits can't be replaced by the
submitted device while its helper is created.

* Save the submitted solar and home sources

Like the device dialogs: snapshot the source before asking for the helper,
lock the inputs while saving, and only touch the dialog while the same
session is open.

* Save the submitted grid and battery sources

Like the other energy dialogs: snapshot the source and power settings before
asking for helpers, lock the energy inputs while saving, and only touch the
dialog while the same session is open.

* Save the submitted gas and water sources

Like the other energy dialogs: snapshot the source and cost choice before
asking for the helper, lock the inputs while saving, and only touch the dialog
while the same session is open.

* Keep a device's name and upstream device when its energy sensor changes

A device can now be named and given an upstream device before it has an energy
sensor, so picking or clearing the sensor must not drop them. The upstream
device is only dropped when the new sensor rules it out.

* Keep a water device's name and upstream device when its sensor changes

Mirror the electricity device dialog: picking or clearing the water sensor
keeps the name, and only drops the upstream device when the sensor rules it out.

* Lock the power settings while a grid or battery saves

Changing a power sensor during the save could pair the new sensor with helpers
for the old one after a failed save. Only the new energy sensors are written
back to the form, so other edits are kept.

* Keep solar and home edits when saving fails

Only write the new energy sensor back to the form, so changes made while the
source was saving survive a failed save.

* Keep gas and water edits when saving fails

Only write the new sensor back to the form, so changes made while the source
was saving survive a failed save.

* Lock the grid pricing controls while saving

Pricing edits made while the grid saved were lost, as the save uses the
submitted source.

* Leave gas and water prices in the form when saving fails

The no-cost reset only applies to the saved source, so a failed save no longer
clears the price text while keeping the hidden price.

* Reset the device dialog's saving state per session

A save that finishes after the dialog closed or reopened no longer touches the
new session's saving state.

* Reset the solar and home saving state per session

A save that finishes after the dialog closed or reopened no longer touches the
new session's saving state.

* Ask grid connections to create or cancel, without skipping

The grid confirmation now offers Cancel and Create like the other sources, so
it always has a way back to the editor. Grid and battery also reset their saving
state per session.

* Reset the gas and water saving state per session

A save that finishes after the dialog closed or reopened no longer touches the
new session's saving state.

* Lock the gas and water pricing controls while saving

Pricing edits made while the source saved were lost, as the save uses the
submitted source.

* Lock the solar forecast controls while saving

Forecast changes made while the source saved were lost, as the save uses the
submitted source.

* Lock the battery state of charge and capacity while saving

Like the other inputs, so edits made while the battery saves aren't lost.

* Drop grid export compensation without an export meter

Compensation set while an export was pending could stay hidden in the source
after the power sensors changed, and come back once an export meter is added.

* Explain why a flow rate can't provide the gas or water sensor

When the flow rate can't become a meter, its picker now says so instead of
leaving Save disabled without a reason. The flow rate help text says
"Optionally" again, since it stays optional next to a meter.

* Find existing helpers in the live states

The home dialog passed the hass from when it opened, so a retry after a
failure didn't see a helper created by the first try and made a second one.
The helper now takes the live states and entities from each dialog. The
counting note also fits solar production now.

* Pass the live states to the grid and battery helpers

* Update Save when the power sensor's unit changes
2026-10-28 13:57:36 +02:00
WendelinandClaude Opus 5.5 9f6de3166f Show entity pictures in the target picker (#55387)
Show entity pictures in the target picker rows

Target picker rows for entities with an entity picture now show it, e.g.
the photo of a person, like the automation targets do since the entity
pictures fix.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-28 11:57:30 +00:00
WendelinandClaude Opus 5.5 64c6f2a133 Remove unused ha-list imports and migrate gallery off ha-list (#55393)
* Remove unused ha-list imports and migrate gallery off ha-list

Prepares the removal of the mwc-based ha-list components:

- Drop unused ha-list / ha-list-item element imports
- Drop leftover @material/mwc-list imports, typing the click handlers
  in ha-domain-integrations as MouseEvent
- Replace the ha-list wrapper in the date and time gallery pages with
  a plain div, as it only wrapped layout divs
- Update the ha-control-select-menu gallery page to pass options via
  the options property, as the component no longer renders slotted
  ha-list-item children, and keep the selected value in state

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ln7qMagr2YDdssKkaBiqf5

* Add HASSDomCurrentTargetEvent type to select menu and integration handlers

---------

Co-authored-by: Claude <[email protected]>
2026-10-28 11:52:22 +00:00
Petar Petrov d1c1d9a05d Offer energy sensors from power for grid and battery sources (#55359)
* Offer energy sensors from power for grid and battery sources

A grid connection or battery with two power sensors can now create Integral
helpers for its missing energy sensors on save, or reuse existing ones. A
battery needs both, while a grid connection can skip them and save as before.

* Keep grid and battery helpers apart and wait for all of them

Neither direction of a source may reuse the statistic picked for the other.
When one of several helpers fails, wait for the others to finish, so a retry
reuses them instead of creating duplicates.

* Save the submitted grid and battery sources

Like the other energy dialogs: snapshot the source and power settings before
asking for helpers, lock the energy inputs while saving, and only touch the
dialog while the same session is open.

* Lock the power settings while a grid or battery saves

Changing a power sensor during the save could pair the new sensor with helpers
for the old one after a failed save. Only the new energy sensors are written
back to the form, so other edits are kept.

* Lock the grid pricing controls while saving

Pricing edits made while the grid saved were lost, as the save uses the
submitted source.

* Ask grid connections to create or cancel, without skipping

The grid confirmation now offers Cancel and Create like the other sources, so
it always has a way back to the editor. Grid and battery also reset their saving
state per session.

* Lock the battery state of charge and capacity while saving

Like the other inputs, so edits made while the battery saves aren't lost.

* Drop grid export compensation without an export meter

Compensation set while an export was pending could stay hidden in the source
after the power sensors changed, and come back once an export meter is added.
2026-10-28 12:44:05 +01:00
Petar Petrov ef531bcb84 Offer energy sensors from power for solar and home sources (#55358)
* Offer to create an energy sensor for power-only energy devices

An individual device needs an energy sensor, so a device with only a
power sensor could not be saved. Saving now asks to create an Integral
helper from the power sensor, or to reuse one that already integrates
it, and uses it as the device's energy sensor.

* Offer energy sensors from power for solar and home sources

Move the Integral helper flow out of the device dialog into a shared
confirmIntegrals helper. The confirmation now creates the helpers itself
and shows its progress. Solar production and the home meter can be saved
with only a power sensor, like individual devices.

* Keep a device's children out of its upstream choices without an energy sensor

Clearing the energy sensor while editing a device dropped its children from the
check, so a child could be picked as the upstream device. Saving then reuses
the original Integral helper, which made the hierarchy circular.

* Save the submitted device when its dialog closes during helper creation

Snapshot the draft before creating the Integral helper, and only update the
dialog while the same dialog session is open. Always pass the energy device
class, as the helper only infers it from a power device class.

* Lock the device form while it saves

The helper is created after the confirmation closes, so edits made meanwhile
would be replaced by the submitted device.

* Save the submitted solar and home sources

Like the device dialogs: snapshot the source before asking for the helper,
lock the inputs while saving, and only touch the dialog while the same
session is open.

* Keep a device's name and upstream device when its energy sensor changes

A device can now be named and given an upstream device before it has an energy
sensor, so picking or clearing the sensor must not drop them. The upstream
device is only dropped when the new sensor rules it out.

* Keep solar and home edits when saving fails

Only write the new energy sensor back to the form, so changes made while the
source was saving survive a failed save.

* Reset the device dialog's saving state per session

A save that finishes after the dialog closed or reopened no longer touches the
new session's saving state.

* Reset the solar and home saving state per session

A save that finishes after the dialog closed or reopened no longer touches the
new session's saving state.

* Lock the solar forecast controls while saving

Forecast changes made while the source saved were lost, as the save uses the
submitted source.

* Find existing helpers in the live states

The home dialog passed the hass from when it opened, so a retry after a
failure didn't see a helper created by the first try and made a second one.
The helper now takes the live states and entities from each dialog. The
counting note also fits solar production now.

* Update Save when the power sensor's unit changes
2026-10-28 12:44:04 +01:00
Petar Petrov 14252d09fb Drop hass from the device pickers (#55384)
* Drop hass from the device pickers

getDevices takes an options object and fetchDeviceCompositeSplits takes connection and callWS.

* Refresh the device picker value when the brands token arrives

Key the memoized value renderer on the brands access token so the selected device's integration icon gets its URL.

* Fetch config entries once in the device selector

Restores the one-time fetch with an empty-list fallback so a failed request no longer hides the selector.
2026-10-28 12:37:46 +01:00
Petar Petrov 12e32ce0ce Drop hass from the target picker item rows (#55383)
filterTargetEntries takes states and the registries it reads as separate values.
2026-10-28 12:36:54 +01:00
Petar Petrov 5c88dc99c2 Drop hass from the entity selector (#55380)
* Drop hass from the entity selector

fetchEntitySourcesWithCache takes callWS and states and caches per connection in module scope.

* Refetch entity selector data on reconnect

Fetch config entries once per connection again and keep overlapping cache callers from overwriting newer results.

* Refetch entity selector data when the connection comes back

Reset entity sources and config entries when the websocket reconnects, and drop an unreachable cache guard.
2026-10-28 12:36:15 +01:00
Petar Petrov 47c3b2f57c Drop hass from the media browser and its dialogs (#55385)
* Drop hass from the media browser and its dialogs

The media browse helpers now take callWS, and the browser, its manual entry form and the browse and manage dialogs read contexts instead.

* Refresh manual media entry labels when the language changes

Key the ha-form label and helper callbacks on localize so they update without a hass change.
2026-10-28 11:35:49 +00:00
Petar Petrov d1c2915c4e Drop hass from the category picker (#55378)
* Drop hass from the category picker

Consume the connection, API and internationalization contexts in ha-category-picker and drop SubscribeMixin.

* Keep categories when the scope returns to the subscribed one

Clear the list only when the new scope differs from the active subscription's scope.
2026-10-28 12:35:07 +01:00
Petar Petrov 7f1aedcb95 Speed up the log viewer tests with a smaller line limit (#55392)
The retention and search tests filled the viewer with 5000 lines,
which takes over a second per test in jsdom and pushed the search
tests past the 5 s timeout on loaded CI runners. Run them against a
50-line limit instead and drop the raised timeouts.
2026-10-28 12:33:25 +01:00
Petar Petrov db63231a5d Drop hass from the area controls picker (#55379)
* Drop hass from the area controls picker

getAreaControlEntities takes states and registries as separate values.

* Keep states out of the area controls editor memo key

Restores the editor's previous caching so state changes do not rescan the area's entities.
2026-10-28 12:32:50 +01:00
Petar Petrov 0aabfa89bc Drop hass from the history charts (#55386)
* Drop hass from the history charts

The history line and timeline charts and state-history-charts consume contexts, and generateStateHistoryChartLineData takes localize and states.

* Rebuild line chart options only on chart updates

Entity state changes are compared when the chart updates, as before, instead of triggering their own update.
2026-10-28 11:32:22 +00:00
Simon Lamon 35601830a8 Rename to child devices (#55391)
Child devices
2026-10-28 12:31:03 +01:00
Paul Bottein fb882dacae Add convert to manual section action (#55377)
* Add convert to manual section action

* Keep the section when its strategy fails to generate during conversion

* Drop strategy disabled state when converting a section

* Skip the conversion save when the section changed meanwhile

* Always drop disabled when converting a section

* Add Oxford comma to convert to manual text
2026-10-28 12:10:00 +01:00
Petar Petrov aaad3f0d3d Offer to create an energy sensor for power-only energy devices (#55353)
* Offer to create an energy sensor for power-only energy devices

An individual device needs an energy sensor, so a device with only a
power sensor could not be saved. Saving now asks to create an Integral
helper from the power sensor, or to reuse one that already integrates
it, and uses it as the device's energy sensor.

* Keep a device's children out of its upstream choices without an energy sensor

Clearing the energy sensor while editing a device dropped its children from the
check, so a child could be picked as the upstream device. Saving then reuses
the original Integral helper, which made the hierarchy circular.

* Save the submitted device when its dialog closes during helper creation

Snapshot the draft before creating the Integral helper, and only update the
dialog while the same dialog session is open. Always pass the energy device
class, as the helper only infers it from a power device class.

* Lock the device form while it saves

The helper is created after the confirmation closes, so edits made meanwhile
would be replaced by the submitted device.

* Keep a device's name and upstream device when its energy sensor changes

A device can now be named and given an upstream device before it has an energy
sensor, so picking or clearing the sensor must not drop them. The upstream
device is only dropped when the new sensor rules it out.

* Reset the device dialog's saving state per session

A save that finishes after the dialog closed or reopened no longer touches the
new session's saving state.
2026-10-28 11:31:26 +01:00
Maarten Lakerveld ce2a9577e5 Refresh the todo card at browser midnight and test the subscription controller (#55356) 2026-10-28 10:48:37 +01:00
Petar Petrov 5bafeb875c Remove unused dynamic-to-generic group move from the automation picker (#55376) 2026-10-28 10:35:15 +01:00
Maarten Lakerveld a24881a54f Fetch the index fresh on navigation and harden the service worker update flow (#54790) 2026-10-28 10:27:49 +01:00
Petar Petrov 4decd87fd6 Drop hass from the navigation picker (#55373)
* Drop hass from the navigation picker

computeNavigationPathInfo and subscribeNavigationPathInfo take an options object of separate values.

* Rerender the navigation picker when related items change

The related section is read in render, and hass updates no longer rerender the picker.
2026-10-28 09:08:52 +00:00
Petar Petrov 7fd2a112bf Drop hass from the TTS media browser (#55370)
fetchCloudStatus now takes callWS, and ha-browse-media-tts reads localize, config and the API from contexts.
2026-10-28 09:08:44 +00:00
Petar Petrov fc22a2af50 Drop hass from the floor selector (#55369)
Read registries and the config entries API from contexts instead of hass.
2026-10-28 09:08:37 +00:00
Petar Petrov bcae4feba9 Drop hass from the entity name picker (#55367)
The entity name picker reads states, registries, localize and formatEntityName from contexts; its selector drops the unused SubscribeMixin.
2026-10-28 09:08:29 +00:00
Petar Petrov d0c81d6d5f Drop hass from the display state cards (#55368)
* Drop hass from the display state cards

The remaining display state cards read formatters, localize and the API from contexts, and the lock and update helpers take the separate values they read.

* Call the entity registry directly in callProtectedLockService

Avoid a hass-shaped literal for getExtendedEntityRegistryEntry.
2026-10-28 09:08:21 +00:00
Raphael Hehl 711ff63983 Select and drag picture elements in the card editor preview (#55263)
* Select and drag picture elements in the card editor preview

* Recreate the element editor on preview selection and moves

* Select a preview element activated by a click alone
2026-10-28 10:07:02 +01:00
Petar Petrov 1bc800d042 Drop hass from the category group header menu (#55374)
Turn the category group menu into a context element

The menu stored hass on its dropdown for its handlers; it now consumes localize and the API context.
2026-10-28 09:00:38 +00:00
Niels 96605b8986 Move manual event trigger into the Event category under Generic (#55348) 2026-10-28 11:00:33 +02:00
Petar Petrov 7d374f9798 Drop hass from the action state cards (#55371) 2026-10-28 09:55:40 +01:00
karwosts 920cca10d1 Update behavior of continue_on_timeout toggle (#54964) 2026-10-28 09:54:39 +01:00
Lackmake cc3334dfce Add color setting to sensor card (#55364) 2026-10-28 09:52:25 +01:00
fcd17e3f2a Translate errors when describing triggers, conditions and actions (#55249)
* Translate errors when describing triggers, conditions and actions

* Delete test/data/describe-error.test.ts

---------

Co-authored-by: Pradeep Peddineni <[email protected]>
Co-authored-by: Simon Lamon <[email protected]>
2026-10-28 10:52:02 +02:00
renovate[bot] da0935c904 Update dependency @home-assistant/webawesome to v3.14.0-ha.2 (#55375) 2026-10-28 08:51:28 +00:00
shazilkhanandJan-Philipp Benecke 38bf0a9ac3 Show custom trigger IDs in the "Triggered by" condition (#55086)
* Show custom trigger IDs in the "Triggered by" condition

Since 2026.10 the trigger list in the "Triggered by" condition only shows the
generated description, and a trigger's ID only appears when it is a duplicate.
Automations that relied on descriptive custom IDs can no longer tell similar
triggers apart.

Show the ID in the same end slot whenever it was set by the user, with a
neutral badge. Generated IDs stay hidden and duplicates keep the warning badge.

Fixes #54721

* Process UX feedback

* Fix accessibility issue

---------

Co-authored-by: Jan-Philipp Benecke <[email protected]>
2026-10-28 09:50:20 +01:00
Jan-Philipp Benecke 01bf2a402b Add bulk expose to voice assistants on the entities table (#55204)
* Add bulk expose to voice assistants on the entities table

* Add exposed entities parameter to voice assistant ID retrieval

* Add toast notifications for exposing and unexposing entities to voice assistants

* Fetch current exposure from core before determining changed entities for assistants

* Adapt to core change

* Remove dropdown fix

* Update toast message count to reflect core changed entities

* Update stub

* Return a fresh copy of stub
2026-10-28 08:48:43 +00:00
NielsandWendelin 4b20e52c29 Add Replace for automation triggers, conditions, and actions (#55131)
Co-authored-by: Wendelin <[email protected]>
2026-10-28 08:46:53 +00:00
Petar Petrov cdc08029e0 Drop hass from the statistic pickers (#55372) 2026-10-28 09:30:27 +01:00
Paul Bottein 5bbe135c53 Add formatNumberWithUnit helper (#55346)
* Add formatNumberWithUnit helper

* Extract formatted values into named variables
2026-10-28 06:05:45 +00:00
Paul Bottein 68445a8f94 Use a non-breaking space between values and units (#55345) 2026-10-28 08:00:17 +02:00
karwosts 316043960c Fix helper text spacing in location selector (#55349)
Fix helper text in location selector
2026-10-27 20:45:17 +01:00
Petar Petrov d8dde4a0bb Refit legends when the More button changes width (#55357)
Refit the legend when the More button changes width
2026-10-27 18:31:59 +01:00
Maarten Lakerveld 525f2c23d6 Drop hass from the shortcut card and the navigation path controller (#55334)
NavigationPathInfoController resolves localize, panels, areas, devices,
config and connection from context and only takes a path, so the
shortcut card, badge, editors and home shortcut dialogs no longer pass
hass to it. It skips the host update when the resolved label and icon
did not change. getShortcutCardDefaults and the panel title helpers take
localize instead of hass. The badge and the editors keep hass for their
actions and forms.
2026-10-27 18:29:45 +01:00
Aidan Timson 41d43fe780 Edit categories from their data table group headers (#55236)
* Edit categories from their data table group headers

* Use an edit and delete menu on category group headers

* Drop a deleted category from the filter and pass callWS

* Name the category in its group header menu label

* Key category group header menus by category ID

* Keep group header menus at normal font weight

* Use the rows' icon size for group header actions, keep marketplace at 20px
2026-10-27 18:26:40 +01:00
Petar Petrov 5b4a9a8967 Fit chart legends to two rows (#55351)
* Fit chart legends to two rows

Use the legend fit controller in ha-chart-base instead of hiding
everything after a fixed count, and resize the chart when the legend
changes height within a fixed height.

* Refit the chart legend when its items change
2026-10-27 18:19:15 +02:00
Maarten Lakerveld 3fe979408a Drop hass from the button, gauge, light, plant, picture and markdown cards (#55329)
* Drop hass from the button, gauge, light, plant, picture and markdown cards

Each card consumes the entity state and registry entry it shows plus the
formatters, localize, config and themes it reads. Actions are dispatched
as hass-action events. The picture card resolves media source images
once per image id, and the markdown card resubscribes its template when
the connection or user changes. The plant status card selects its sensor
states from the configured entity directly, so the selection does not
depend on transform ordering.

* Let the picture card retry a media source that failed to resolve

A rejected resolve left the in-progress marker set, so the same image
was never requested again.

* Subscribe the markdown template once per connection and user

The first context values arrive before connectedCallback() subscribes, so
the first update replaced a subscription that was already live. The card
now tracks the connection and user it subscribed with and only
resubscribes when one of them differs.
2026-10-27 17:13:18 +01:00
Petar Petrov 8b0d03cad2 Fit the distribution card legend to two rows (#55350)
* Fit the distribution card legend to two rows

Hide only the legend items that do not fit in two rows, instead of
everything after a fixed count. Each item is fitted at its widest since
the last resize, so changing values cannot make the More button flicker.

* Create the legend observers when the list renders

* Drop the legend mutation observer
2026-10-27 17:12:09 +01:00
Paul Bottein 76ee92807a Rename "Use device name" to "Main entity of the device" (#55320)
* Rename "Use device name" to "Main entity of the device"

* Explain main entities in the switch description

* Rename device name variables to main entity
2026-10-27 17:41:05 +02:00
Maarten Lakerveld 9f4fd39f3f Drop hass from the clock, glance and other simple cards (#54901)
* Drop hass from the clock, glance and other simple cards

The clock cards consume locale and the server time zone from context,
and the date format helper takes those values instead of hass. The
glance card consumes its entity states and registry entries through the
entity helpers, so it only rerenders when a shown entity changes. The
iframe, empty state, starting and toggle group cards consume localize,
config, states and api, and dispatch actions as hass-action events.

* Act from the element that fired hass-action and keep toggle group order

The shared handler ran handleAction on the app root, so fire-dom-event
dispatched ll-custom above the dashboard listeners and broke the Home
panel's Add device button. The toggle group now walks the configured
entity order, because the states record keeps its identity when only the
order changed.
2026-10-27 15:27:45 +00:00
Maarten Lakerveld 1fe09e6dbd Drop hass from the todo list and shopping list cards (#55331)
* Drop hass from the todo list and shopping list cards

The cards consume their entity state, states, entities, formatters,
localize, config, themes, connection and api from context. The todo
helpers take the connection, callService, callWS or states they use, so
the todo panel and item dialog pass those instead of hass.

* Refresh the todo card at midnight or at the next item deadline

Today labels and the overdue styling depend on the time. They used to
refresh with unrelated state updates, which no longer rerender the card.
The timer now fires at midnight or at the earliest upcoming deadline,
whichever is first, and is rearmed when the items change.
2026-10-27 15:27:41 +00:00
Maarten Lakerveld f74c63f257 Drop hass from the area controls card feature (#55333)
* Drop hass from the area controls card feature

The feature consumes the registries, localize and api it reads and
narrows statesContext to the entities its buttons show, so unrelated
state changes do not rerender it. The area control entities and group
toggle helpers take narrowed hass slices. The hui-card-feature wrappers
keep hass to feed custom features.

* Keep the default area controls stable and key the lookup on states

A fresh default array on every access missed both memoized lookups, which
now run per state notification as well as per render. States join the
cache key because the filter reads device classes from them, so an entity
can only match once its state exists.
2026-10-27 15:27:36 +00:00
Maarten Lakerveld 53a80e3056 Drop hass from the alarm panel, media control and weather forecast cards (#55330)
* Drop hass from the alarm panel, media control and weather forecast cards

The cards consume their entity state, formatters, localize, config,
themes, api and connection from context. callAlarmAction takes
callService and getSecondaryWeatherAttribute takes the formatter and
localize values it uses. The media control card extracts artwork colors
in update(), after @transform has refreshed the entity state for a new
config, and the alarm panel reports the keypad size before its first
update since masonry sizes cards before attaching them.

* Apply the media control theme without waiting for the entity

The theme was skipped when the player was missing on the config or
themes change, and nothing reapplied it once the entity appeared.
2026-10-27 15:27:31 +00:00
Maarten Lakerveld cc424b6a09 Drop hass from the map card and the trend graph card feature (#55332)
* Drop hass from the map card and the trend graph card feature

The map card narrows statesContext to the entities it tracks, including
the entities its visibility conditions read, so unrelated state changes
do not rerender it. The visibility condition checks take a narrowed hass
type that includes the registries the location condition resolves zones
through, the target selector helpers take what they read, and
extractConditionEntityIds visits every condition instead of returning at
the first logical group. The history stream helper takes a connection
and a states slice, which the map card and trend graph feature read live
so a resubscribe after a reconnect sees current states.

* Track core entity_id conditions and single-child groups on the map

The extractor skipped the core entity_id field that the checks prefer,
and threw on a logical group that holds one condition instead of a list.
It now also handles legacy conditions without a condition key.
2026-10-27 15:26:51 +00:00
Maarten Lakerveld c5818c5360 Drop hass from the repairs, updates and discovered devices cards (#55335)
Adds ConnectionSubscriptionController as a context based replacement for
SubscribeMixin. It keeps one websocket subscription alive while the host
is connected and re-evaluates an optional enabled callback after each
update. The repairs and discovered devices cards subscribe through it.
The updates card counts installable updates with a states transform that
waits for the formatters context and evaluates its visibility in
update(), after the transform ran. Its test provides contexts instead of
a hass object.
2026-10-27 15:26:29 +00:00
Petar Petrov 70b86832b2 Drop hass from the device automation pickers (#55302)
The device action, condition and trigger pickers read localization, states, formatters and the API from contexts.
2026-10-27 15:15:06 +00:00
Petar Petrov 2df237975b Drop hass from the object selector (#55315)
formatSelectorValue takes the values it reads, and the object selector consumes them from contexts.
2026-10-27 15:13:32 +00:00
renovate[bot] 60a632f584 Update dependency xss to v1.0.17 (#55354)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-27 16:12:25 +01:00
Aidan Timson 4bdf332f30 Add actions to data table group headers (#55235)
* Add an actions slot to data table group headers

* Dismiss new repositories from the Marketplace group header

* Style group header actions like the rows' icon buttons

* Keep group header actions in view and forward ungrouped slots

* Keep the error alert open when dismissing new repositories fails

* Let pages change the colour of group header actions

* Size group header actions like other header icon buttons

* Keep the pinned group header a table row

* Forward only supplied group actions and index the pinned header
2026-10-27 16:01:59 +01:00
Petar Petrov 73416452ef Drop hass from the mount, serial port, push and vacuum segment components (#55308)
getVacuumSegments, getAppKey, listSerialPorts and fetchSupervisorMounts take callWS, so their components read translations, config, registries and the API from contexts.
2026-10-27 14:23:01 +01:00
Petar Petrov fc3d0ee74b Drop hass from the assist pipeline and conversation agent pickers (#55306)
listAssistPipelines, listAgents, getConfigEntry and getSubEntries take callWS, so both pickers and their selectors read translations, the API, the config and the entity registry from contexts.
2026-10-27 14:22:51 +01:00
Petar Petrov 4952db4960 Drop hass from the label pickers (#55303)
The label registry helpers take callWS, so ha-label-picker, ha-labels-picker and ha-selector-label read their data from contexts.
2026-10-27 14:22:48 +01:00
Petar Petrov 2bdcea4bc0 Drop hass from the entity attribute and state content pickers (#55307)
Both pickers read entity state, formatters, translations and registries from contexts, so the attribute and UI state content selectors stop forwarding hass.
2026-10-27 11:38:29 +01:00
Petar Petrov f2bed231ff Drop hass from the media browser thumbnail (#55305)
The thumbnail reads dark mode, the Home Assistant URL and fetchWithAuth from contexts, and re-resolves brand icons when the theme changes.
2026-10-27 11:38:04 +01:00
Petar Petrov e1178bc80b Drop hass from the speech engine pickers (#55300)
The TTS and STT helpers take callWS instead of hass, so the TTS, STT and TTS voice pickers and their selectors read translations, registries, states and the API from contexts.
2026-10-27 11:37:42 +01:00
Petar Petrov d732af588c Drop hass from the floor pickers (#55311)
createFloorRegistryEntry and updateAreaRegistryEntry take callWS instead of hass, so ha-floor-picker and ha-floors-picker read registries, states, translations and the API from contexts.
2026-10-27 11:37:18 +01:00
Petar Petrov d492ecaefd Drop hass from the entity state pickers (#55313)
getStates takes the states and language, so the state pickers read entity states, formatters and translations from contexts.
2026-10-27 11:37:04 +01:00
WendelinandClaude Opus 5.5 bc6d8841eb Refactor add automation element dialog (#55233)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-10-27 10:29:41 +01:00
Bram Kragten 901c0a61ad Bumped version to 20260930.5 2026-10-23 15:07:48 +02:00
karwosts f2ad7bbd6a Fix blueprint import redirect links (#55087) 2026-10-23 15:07:04 +02:00
Petar Petrov 25c73b7c0d Fix Sankey labels on thin nodes showing at full size in Safari (#55020) 2026-10-23 15:07:02 +02:00
Raman GuptaandClaude Opus 5.5 41b7c079a8 Fix Z-Wave config parameter status always showing as error (#55002)
The zwave_js/set_config_parameter and set_raw_config_parameter commands
return the zwave-js-server CommandStatus string ("accepted" or
"queued"), not the numeric zwave-js SetValueStatus. #54287 retyped the
status as a number and mapped it through computeSetConfigParamStatus,
which turned every successful update into "error".

Type the status as the string union the backend sends and use it
directly again. The raw variant only returns status, so give it its own
result type without value_id.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-23 15:07:01 +02:00
WendelinandClaude Opus 5.5 e386b091f1 Prevent iOS Safari text inflation on landscape rotation (#54914)
* Prevent iOS Safari text inflation on landscape rotation

Fixes #53060

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CPRiV8TCjj8Z4SX9jX9578

* also add the fix for gallery and demo

---------

Co-authored-by: Claude <[email protected]>
2026-10-23 15:07:00 +02:00
Petar Petrov 52c4f360cc Disable Lit dev mode in the legacy build under pnpm (#54855)
Compile Lit without dev mode in the legacy build again
2026-10-23 15:06:58 +02:00
Bram Kragten 26690e06b0 Bumped version to 20260930.4 2026-10-16 13:23:16 +02:00
Maarten Lakerveld e7a89e0ac0 Stop the user condition editor from duplicating selected users (#54921)
When the users finish loading, the list picks up the pre-selected items and
fires a selected event for them. The handler appended that user again on
every open of the editor, so a single user condition grew one duplicate id
per edit. Ignore selection events that do not change the configured users.

Fixes #54894
2026-10-16 13:22:57 +02:00
Paulus SchoutsenandClaude eb90a50ca1 Keep hidden columns when reopening data table settings (#54874)
Keep column settings for tables without fixed last columns

The data table settings dialog dropped the stored column order when the
table had no lastFixed columns. With no column order, the dialog showed
every column as visible. Reopening the dialog on the statistics table
then showed hidden columns as enabled again.


Claude-Session: https://claude.ai/code/session_014uzH9wKHRBfTGqXypaYEgV

Co-authored-by: Claude <[email protected]>
2026-10-16 13:22:56 +02:00
MichaelandClaude Opus 5.5 e221ea2b19 Fix Matter add device button not opening the add device dialog (#54823)
The add device button on the Matter dashboard linked to
/config/matter/add, which navigated to the devices dashboard and opened
the Matter add device dialog without awaiting the navigation. The
navigation closes open dialogs before it completes, so the dialog was
closed again right away and the user ended up on the devices dashboard.

Open the dialog directly from the Matter dashboard, like the Z-Wave
dashboard does, and make the /config/matter/add route (used by the
add_matter_device my link) await the navigation before showing the
dialog from the app root, as its own element is detached by then.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-16 13:22:55 +02:00
Jan-Philipp Benecke abd5f9e985 Fix script graph crash on template shorthand conditions (#54798)
* Fix script graph crash on template shorthand conditions

* Move string to ConditionWithShorthand
2026-10-16 13:22:54 +02:00
Petar Petrov 2f9a4f99be Link Ethernet Matter nodes to Home Assistant in the network map (#54784) 2026-10-16 13:22:53 +02:00
Maarten Lakerveld 0cf9fcef97 Map overview fixes (#54774) 2026-10-16 13:21:22 +02:00
Bram KragtenandClaude Opus 5.5 3f7f4613d6 Remove invalid ::part() selector that broke CSS minification (#54772)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:29:05 +02:00
Bram Kragten 4a8501a6e5 Fix import from ui-support 2026-10-09 11:26:27 +02:00
Bram Kragten 4ae1775644 Bumped version to 20260930.3 2026-10-09 11:16:55 +02:00
Jan-Philipp Benecke 80cd338e77 Fix crash on null trigger entries in the automation editor (#54771) 2026-10-09 11:16:35 +02:00
Franck Nijhof cd8f785ce6 Don't count the characters of a string target (#54769) 2026-10-09 11:11:58 +02:00
Franck Nijhof 4f4f7bd633 Translate attribute names every entity can have (#54768)
* Translate attribute names every entity can have

* Translate the supported features attribute name
2026-10-09 11:11:57 +02:00
Franck Nijhof c2413fd7fa Greet in the interface language when it is a variant of the pipeline language (#54766) 2026-10-09 11:11:55 +02:00
Franck Nijhof ed596fe700 Hide hidden cards in grid sections without :has() (#54764) 2026-10-09 11:11:53 +02:00
Franck Nijhof e580fb8b5c Keep dropdown menus clear of safe areas (#54762) 2026-10-09 11:11:52 +02:00
Franck Nijhof 5ba7a906d4 Keep icon button labels off the ha-button host (#54761) 2026-10-09 11:11:51 +02:00
Franck Nijhof c6159615d7 Release streaming images when hui-image is detached (#54760) 2026-10-09 11:11:49 +02:00
Franck Nijhof b2f63cf345 Show the speech-to-text duration without VAD (#54759) 2026-10-09 11:11:48 +02:00
Franck Nijhof fbd7bb073e Give dialogs an accessible name (#54758) 2026-10-09 11:11:47 +02:00
Franck Nijhof 42fec4fb9c Expose the real invalid state of inputs to screen readers (#54757) 2026-10-09 11:11:46 +02:00
Franck Nijhof bcd7f0dc88 Show the ZHA config entry state when it isn't loaded (#54756) 2026-10-09 11:11:45 +02:00
Franck Nijhof 44b5dc9446 Fit the add integration list on short screens (#54755) 2026-10-09 11:11:43 +02:00
Franck Nijhof 4fd8732160 Fall back to local voice setup when the cloud status fails (#54754) 2026-10-09 11:11:42 +02:00
Petar Petrov 51675e0cd2 Fix negative upstream device untracked energy in devices detail graph (#54753) 2026-10-09 11:10:17 +02:00
Petar Petrov 36bddbff06 Hide Zigbee from the ESPHome device setup wizard (#54751) 2026-10-09 11:06:57 +02:00
Simon Lamon e8c15f10e2 Restore compatibillity with older chromium devices in ScrollFadeController (#54742)
Restore compatibillity with older chromium devices
2026-10-09 11:06:55 +02:00
Franck Nijhof 8f870146c6 Keep custom logbook messages for automations and scripts (#54739)
* Keep custom logbook messages for automations and scripts

* Only match the domain's own run message
2026-10-09 11:06:54 +02:00
Franck Nijhof 91c0d052df Convert CSS color names to hex when saving a label (#54738) 2026-10-09 11:06:53 +02:00
Franck Nijhof dfb5e7d768 Keep the label of color inputs raised (#54737) 2026-10-09 11:06:51 +02:00
Artur Pragacz 03fb6f5c29 Fix translation key for external config flow step description (#54722) 2026-10-09 11:06:50 +02:00
Maarten Lakerveld 11176a7593 Center the map on a single entity instead of fitting it (#54718)
The vector map's globe projection (new in 2026.10) cannot solve a camera
for a zero-area bound, so fitting a single-entity map warned "Map cannot
fit within canvas" and left the view on the default location. Center a
single point directly with easeTo/flyTo rather than routing it through
fitBounds, carrying the fit's padding as a transient offset and honouring
its fly request.
2026-10-09 11:06:48 +02:00
Paul Bottein 8a7b8ff2ac Add side padding to the statistics search field (#54717) 2026-10-09 11:06:47 +02:00
Franck Nijhof f61e1ab509 Respect custom icons for built-in panels in the sidebar (#54709) 2026-10-09 11:06:46 +02:00
Franck Nijhof 3e75d97b8a Show the right page title for more settings pages (#54708) 2026-10-09 11:06:45 +02:00
Franck Nijhof 8588d34fbe Only cache the frontend's own static files in the service worker (#54707) 2026-10-09 11:06:43 +02:00
Paul Bottein f4952764d4 Replace map overview more info button with a header icon (#54704)
* Replace map overview more info button with a header icon

* Reuse the show more information label

* Show a settings button for admins in the map overview

* Hide the map overview settings button in the demo
2026-10-09 11:06:42 +02:00
Franck Nijhof 52d38105ae Let long dropdown submenus scroll (#54692) 2026-10-09 11:06:41 +02:00
Franck Nijhof 8fa6c1d027 Keep a cleared input value when focusing it again (#54690) 2026-10-09 11:06:39 +02:00
Franck Nijhof 972b999195 Handle map labels without a name (#54689) 2026-10-09 11:06:38 +02:00
Petar Petrov fe6bc5530e Show the newest history states when the browser clock runs behind (#54688) 2026-10-09 11:06:37 +02:00
Franck Nijhof d5d7093ca3 Keep the key order when duplicating a script (#54687) 2026-10-09 11:06:36 +02:00
Franck Nijhof 52d30d5b6e Keep boolean fields inline in the narrow action editor (#54686) 2026-10-09 11:06:35 +02:00
Franck Nijhof 2a25673659 Fix ZHA reconfigure dialog errors after closing and on reconnect (#54684)
* Fix ZHA reconfigure dialog errors after closing and on reconnect

* Only handle reconfigure messages of the current run
2026-10-09 11:06:33 +02:00
Franck Nijhof 94838cf7d1 Load the HLS source once instead of on every media attach (#54683) 2026-10-09 11:06:32 +02:00
Franck Nijhof 9214bcac94 Wrap descriptions in voice assistant settings (#54678) 2026-10-09 11:06:30 +02:00
karwosts 3be6887df6 Fix create floor memoization issue (#54675) 2026-10-09 11:06:29 +02:00
Paul Bottein 33374693c9 Snap the map overview bottom sheet to fixed positions (#54663)
* Snap the map overview bottom sheet to fixed positions

* Include the safe area in the collapsed stop and follow reduced motion
2026-10-09 11:06:28 +02:00
Marcin BauerandClaude Opus 5.5 80516f1f00 Restore left-column timeline names on the history-graph card (#54661)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:06:26 +02:00
Petar Petrov 9253af6a19 Align history timeline X axis with line charts (#54657) 2026-10-09 11:06:25 +02:00
Bram KragtenandClaude Opus 5.5 58f1d51c7f Add realistic automations to the demo (#54647)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:04:03 +02:00
Bram KragtenandClaude Opus 5.5 05d6f614bc Show the next demo card on the home page demo again (#54646)
* Show the next demo card on the home page demo again

The home overview strategy now generates the favorites section itself
instead of returning a common-controls section strategy, so the demo
strategy no longer found the section to add its card to. Find the
favorites section by its heading instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Keep at least six favorites next to the demo card

The section strategy used to get a limit of two less, so seven tiles
became six. Only drop as many tiles as needed to keep six.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Keep the current user when the demo user subscription refreshes

The auth/current_user mock answered with the user from startup, so the
user subscription reset a changed user, like the non-admin scenario of
the e2e tests, to the initial admin user.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:00:44 +02:00
Bram KragtenandClaude Opus 5.5 6e2bb1b2f5 Mock editing the own profile in the demo (#54645)
The demo did not answer person/update_own_profile, so saving the profile
dialog showed an error. Also answer auth/current_user, so the user
subscription picks up the new name.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:00:43 +02:00
Bram KragtenandClaude Opus 5.5 f3a6e1028e Add focus indicator to entity and sensor cards (#54644)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:00:41 +02:00
WendelinandClaude f182b4600d Dismiss reboot/shutdown toast when frontend reconnects (#54640)
The "Rebooting system" toast was shown without an id and with no
timeout, so nothing could ever close it and it stayed on screen after
the host came back. Give it an id, dismiss it when the connection is
re-established, and also when the action fails with an error dialog.

Fixes #54630


Claude-Session: https://claude.ai/code/session_01SkS1AB8w2GPUuRBaDDmsEu

Co-authored-by: Claude <[email protected]>
2026-10-09 11:00:40 +02:00
Jan Čermák b7983e0906 Copy locale data to the landing page build (#54488)
The landing page bundle includes the Intl polyfill. When a browser
needs a polyfill, it loads locale data from /static/locale-data/.
The landing page build never copied that directory, so these requests
returned 404. The language picker then showed raw language codes.

Fixes home-assistant/landingpage#216
2026-10-09 11:00:38 +02:00
Bram KragtenandClaude Opus 5 6c988150e4 Draw the globe against a sky when the map is zoomed out (#54485)
Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-09 11:00:37 +02:00
Bram KragtenandClaude Opus 5.5 b88a39e273 Cache map tiles per asset class and stop refetching what still works (#54451)
* Cache map tiles per asset class and stop refetching what still works

The service worker kept glyphs, sprites and tiles in one 500-entry LRU, so
panning around evicted the fonts that every label on every map needs. Glyphs
and sprites move to a cache of their own, on the 30 day age core already sends
them with, and the tile ceiling goes up.

The TileJSON was deliberately left on the network because it is the switching
point for the tile source. Every map waits on it before asking for a tile
though, and at a five minute max-age that is a round trip before the first one.
Served stale-while-revalidate instead: out of the cache immediately, refreshed
behind it, so a moved endpoint still arrives.

The raster fallback redrew on every token rotation, refetching every tile on
screen twice an hour whether or not anything had failed. Later tiles pick up a
new token by themselves, so only a layer that actually had one refused needs
asking again - and since a refused tile is the only sign that path gets that
its token is stale, it now asks for a new one instead of waiting for the
refresh interval to come round.

Style requests that are in flight are shared, so a dashboard of maps, or a
theme toggle switching all of them at once, fetches each style once. Only while
in flight: a settled request is dropped, or a style that failed once would
never be fetched again.

Co-Authored-By: Claude Opus 5 <[email protected]>

* Hand the raster fallback the current map tiles token

A rotation is only announced once, so a vector layer that later falls back
handed the raster layer the token it was created with, and the refresh that
follows a refused tile fetches the already-current token and announces
nothing. The vector layer now tracks rotations itself.

Also covers in-flight style sharing, and passes a map style rather than
`false` in the raster recovery tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-10-09 11:00:35 +02:00
Maarten Lakerveld ec6b258750 Zone editor: fit without passive zones, hide covered radius handles, name tooltips, and start new zones at the map center (#54597)
* Leave passive zones out of the zones map fit

Opening the zones page fitted the map around every zone, so a few remote
passive zones pushed the view out to the whole globe and adding a zone took
a lot of zooming. Passive zones are still drawn and editable, but no longer
count toward the fit unless they are the only zones there are. Editable
locations gained a fit flag for this.

Fixes #54595

* Hide a zone's radius handle while the circle is under its icon

Zoomed out on the zones page, every zone's radius handle collapsed onto the
zone icon and covered it. The handle now hides while the radius on screen is
shorter than the icon's half width, so it returns as soon as the circle's
edge shows past the icon, and it stays while the radius is being dragged.

* Show a zone's name in a tooltip when hovering its marker on the zones map

The marker only had a native title, which appears late and in the
browser's own style. Every named editable location now gets an ha-tooltip
anchored to its element, so hovering a zone icon on the zones page names
it promptly; the native title is dropped so the two do not stack.

* Start a new zone at the center of the zones map

Create zone placed the new zone at a random spot up to 500 meters from
home, so it landed somewhere unexpected and often off-screen. When the zones
page has its map open, a new zone now starts at the center of the current
view, so panning to the right place first works as expected. A location
handed in, as from a person's Create zone action, still wins; without a map
the random offset around home remains.
2026-10-07 10:12:54 +02:00
Maarten Lakerveld ea1fabdfdd Map: float every marker, keep the selection in the URL, pan-first focus, and fix attribution and zone radius display (#54596)
* Auto-hide compact map attribution and restyle it for dark mode

MapLibre decides compact mode from the map canvas width alone (640px), so
shrinking the window flipped the attribution from compact back to inline
when the sidebar became a drawer and the map got wider. The engine now adds
the control itself and forces compact on a narrow viewport (870px, the app's
own narrow layout), swapping it when the breakpoint changes.

MapLibre only collapsed the opened compact attribution on a drag; it now
also collapses by itself after five seconds, unless the user opened it from
the info button.

MapLibre's stylesheet hardcodes a white attribution with black text, which
Home Assistant never overrode, so it stood out on a dark map. The dark mode
rules now match the zoom control styling, and the inline text color is set
explicitly instead of inherited from a page that may be in the other mode.

* Add show_zone_radius option to the map card

In panel layout the map hides zone radii except on the Zones tab, for the
selected zone, and while editing. A user asked to always see them, so the
new option draws the radius of every visible zone on all tabs. Other layouts
already show radii and are unaffected.

* Regroup map clusters up front for every fit

Clicking a bubble already regrouped its members at the zoom the fit would
land on, so they parted as the animation started. Any other way of zooming
in, such as picking a person in the map overview, waited for the move to end
before the bubble split. The fit itself now does the regrouping, for all
markers, so every animated fit shows the bubbles as they will end up.

* Return the map to its previous view from the overview's back button

Leaving a selected person or zone cleared the selection but left the map
zoomed in on it. The card now remembers where the map was when the overview
drilled in: the exact camera, or the previous entity's fit when one selection
followed another, and the back button eases back there. A camera that was
still auto-fitting resumes auto-fit instead of freezing. Clicking the map to
deselect stays put, since that is the user looking around.

Engines gain getView, and setView can animate.

* Float the selected map marker above a dot at its location

Selecting a person or device marked it with a ring only, and inside a zone
it stayed hidden in the zone's bubble. The selected marker is now excluded
from clustering, drawn a quarter larger, and hangs above a small dot at the
exact coordinate with a tail pointing down to it. The accuracy circle around
the dot carries the entity color, so the ring is dropped; an entity that
reports no accuracy gets a nominal 5 meter circle so the color still shows.
A fix of 10 meters or better draws that circle as a soft disc without an
outline, since a hard edge would claim more precision than is known; map
circles gained an outline option for this. The engine's marker anchor puts
the dot on the coordinate; both engines support it. Bubble avatars keep
their size.

* Open more info from a button in the map overview detail

The selected person, device or zone's name was the only way to the more-info
dialog, which made the title read as a link. The name is plain text now and
a filled More info button sits below the activity, where the user expects an
action. Focus still moves to the name when the detail view opens.

* Always show the selected marker's accuracy circle

The circle only appeared for a GPS fix looser than 100 meters, so most
phones never showed one. The selected marker now floats above a dot at its
coordinate, so even a small circle around that dot reads clearly; it is
never hidden for the selected entity. Focusing still fits the circle into
view only above 100 meters, since a tighter circle fits at the focus zoom.

* Describe every toggle in the map card editor

Only Show all and Show zone radius explained themselves; Scale ruler, Auto
fit, Fit zones and Cluster markers now have helper text too.

* Float every map marker above its location

Only the selected marker hung above a dot with a tail; the rest sat
centered on their coordinate, so a marker's frame always hid the spot it
marked. Every entity marker is now a pin whose tail points just above its
coordinate. Selecting one scales its frame up and shows the dot at the
exact location. Cluster bubble avatars keep their plain frames.

* Keep the map overview selection in browser history

The card remembered a single camera to restore from the overview's back
button, so after selecting one entity and then another the original view
was lost, and back from the second left the map zoomed on the first while
the overview showed the list. The selected entity now lives in the URL as
an entity_id query parameter; selecting pushes a history entry, and so
does deselecting, whether from the overview's back arrow or a click on
the map, so the browser's back and forward step through every selection.
The overview's arrow itself always returns to the list. Selecting focuses
the entity; deselecting leaves the map where it is. Only selectable
domains are accepted from the URL; anything else is dropped from it. The
saved camera, the animated setView and the autoFit view flag had no
other users.

* Pan to a selected map entity instead of zooming in on it

Focusing a person jumped to zoom 19 and a zone fitted its circle, which
often zoomed much further in than the user had been. Selecting now keeps
the current zoom and pans the entity into the free part of the view. It
only zooms in when another marker sits within 80 screen pixels, and then
just far enough to clear it, capped at zoom 17. Markers at the very same
position cannot be separated by zooming and are left alone. A zone is
fitted as its whole circle with a little room around it under that same
zoom cap, so a zone that fits the view keeps the zoom and a bigger one
zooms out just enough. A marker already on screen eases over; one off
screen flies there in an arc. Fits gain a fly option for this, and every
other fit now eases straight to its bounds on both engines, where
MapLibre used to fly. Map-engine gains pixelDistance, the Web Mercator
screen distance at a zoom level, with a test for its scale.

* Float every cluster bubble above its location

Only a bubble attached to a zone had a tail and hung above the zone's
circle; a bubble of nearby markers sat centered on the cluster, hiding the
spot it stood for. Every bubble now has the tail and floats above its
location with the same lift as a single marker's pin, so the two read as
one style. A zone's bubble keeps floating above the zone circle.
2026-10-07 10:12:53 +02:00
Bram Kragten af4f86743b Bumped version to 20260930.2 2026-10-07 10:07:02 +02:00
Artur Pragacz ed492d5b94 Consider next name part when unprefixing entity name (#54633)
* Consider next name part when unprefixing entity name

* Adjust
2026-10-07 10:05:34 +02:00
Paul Botteinandcopilot-swe-agent[bot] db00a6b7e7 Fix section editor crash while typing a strategy in YAML (#54622)
* Fix section editor crash while typing a strategy in YAML

* Reject array-valued section strategies in YAML

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-10-07 10:05:33 +02:00
WendelinandClaude 864f0877f6 Increase touch area of ha-checkbox (#54617)
Co-authored-by: Claude <[email protected]>
2026-10-07 10:05:31 +02:00
Wendelin d7062f3d9d Fix HaChipBase border radius (#54603)
Add border-radius to HaChipBase styles for improved appearance
2026-10-07 10:05:30 +02:00
Jonathan 3051032360 Show the requested run when a trace page switches item (#54591) 2026-10-07 10:05:29 +02:00
Paul Bottein 54c575efd1 Show referenced triggers instead of generated IDs in automation and trace descriptions (#54587) 2026-10-07 10:05:28 +02:00
Paul Bottein e6bc47e6ba Treat an entity name equal to its device name as no own name (#54585)
* Treat an entity name equal to its device name as no own name

* Use the same device name check when computing entity names

* Only check the user-set name against the device name

* Add tests for the device name check

* Clarify why only the user-set name is matched loosely
2026-10-07 10:05:26 +02:00
WendelinandClaude Sonnet 5.5 6244a8db04 Fix config entry state styling (#54584)
* Restore needs attention color on config entry rows

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Restore config entry row state styling

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Highlight config entry state with border color instead of tint

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Add quiet fill background to config entry states

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Use semantic colors for config entry state borders

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Drop state border colors and use semantic text colors

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Use semantic color for config entry link icon

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Add divider between discovered and attention flow rows

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Use semantic border and fill colors for discovered and attention lists

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Use warning colors for discovered list too

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Use standard divider color for flow list borders

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Use border width token for flow list borders

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* Restore warning icon color for failed unload state

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PMDcr7YWpvXhByiZZqoeAc

* review

---------

Co-authored-by: Claude <[email protected]>
2026-10-07 10:05:25 +02:00
Paul Bottein 3f2c1ea538 Show then branch before else in trace graph (#54583) 2026-10-07 10:05:24 +02:00
Paul Bottein aac947ee4d Fix option labels overflowing horizontal control selects (#54582) 2026-10-07 10:05:23 +02:00
Maarten Lakerveld 594d9be859 Bump home-assistant-js-websocket to 9.7.1 (#54577)
Exclude our own packages from the 3-day minimumReleaseAge rule so
fixes we publish ourselves can be picked up immediately. The
no-downgrade trust policy still rejects unsigned releases.
2026-10-07 10:05:22 +02:00
Paul Bottein 80ae2543e5 Fix map history paths flickering on every update (#54573)
Update map paths and zone circles in place instead of redrawing them
2026-10-07 10:05:21 +02:00
Wendelin 94d176c3d1 Fix styling for config entry device rows (#54570) 2026-10-07 10:05:20 +02:00
Jan-Philipp Benecke 192d9f785f Hide dangling trace graph connectors when nothing follows (#54568) 2026-10-07 10:05:18 +02:00
Paul Bottein 398ffa045b Fix first badges cut off in centered scrolling view header (#54565) 2026-10-07 10:05:17 +02:00
Wendelin e67538fb7c config-area-page fix missing list-item import (#54563) 2026-10-07 10:05:16 +02:00
Bram Kragten 025db7ffe7 Bumped version to 20260930.1 2026-10-05 11:02:45 +02:00
Paulus SchoutsenandClaude f9c958c80c Hide MCP URL hint until the server is enabled (#54532)
When the MCP server is not set up, the card has no URLs to show.
The description told the user to add one of the URLs below. Show
only the documentation link until the server is enabled.


Claude-Session: https://claude.ai/code/session_01MeyiXPWWRNRpD2XHVrtPSr

Co-authored-by: Claude <[email protected]>
2026-10-05 11:02:26 +02:00
Jan-Philipp Benecke 652897a3cb Handle undefined trigger condition IDs in rendering (#54527)
* Handle undefined trigger condition IDs in rendering

* Handle trigger conditions without ID in editor and updates
2026-10-05 11:02:25 +02:00
Maarten Lakerveld 3426449315 Keep padded map fits within the poles (#54526)
The MapLibre engine pads a fit by a fraction of its span. A zone whose
circle reaches a pole, or markers far apart, pushed the padded
latitude past 90, and MapLibre throws on such bounds, so the fit never
happened and the console filled with errors. The padded latitude is
now clamped to the poles.

Fixes #54498
2026-10-05 11:02:24 +02:00
Jan-Philipp Benecke 8e19b0249d Fix misplaced trace graph icons on iOS (#54520) 2026-10-05 11:02:23 +02:00
Paul Bottein bba034eeca Show entered and left in map zone activity (#54516)
* Show arrived and left in map zone activity

* Use entered instead of arrived in map zone activity
2026-10-05 11:02:21 +02:00
karwosts 31f2767038 Remove spurious color clear button (#54515)
* Improve number selectors in section settings

* Fix sections editor default color
2026-10-05 11:00:21 +02:00
Petar Petrov e0571044ae Detect the ESPHome Z-Wave discovery flow by the device MAC (#54513) 2026-10-05 10:57:27 +02:00
Maarten Lakerveld 0f00286641 Fix map overview tabs, activity timeline, cluster counts, zone fit and the new-zone route (#54512)
* Show four avatars in a cluster bubble before counting

A bubble capped at three avatars added a +1 chip for the fourth
member, taking a fourth slot to hide one avatar. Four members now show
as four avatars; from five on, three avatars and a count fill the same
four slots.

* Open the new zone dialog on every visit to /config/zone/new

The route was only handled in firstUpdated. The config panel is kept
alive between visits and the router reuses the zone page when it is
already the current one, so "Create zone from current location" did
nothing once the zone list had been opened earlier in the session. The
route is now handled on every route change, like /edit/ already was.

The dialog also opened before the more-info dialog it was launched
from had finished leaving the history, and that departure closed the
new dialog again. The page now awaits the replace navigation, which
waits for closing dialogs, before opening it.

* Hide the map overview while the dashboard is being edited

In panel view edit mode the map card kept rendering its overview. On
phones that is a bottom sheet fixed over the bottom of the screen,
which covered the card's edit bar and the view's add button, so the
map dashboard could not be edited in portrait. The overview is for
using the map, not configuring it, so it is left out while editing.

* Show only the overview tabs that have something to list

People and Zones were always offered, with an empty message when the
map card's entities held none of them; only Devices was hidden when
empty. All three now appear only with entries, the first remaining tab
takes over when the selected one empties, and the overview tells the
card so its marker filter follows. The empty-tab messages are gone
with the empty tabs.

* List the person of the current user first, as "Me"

The overview's People tab sorted everyone alike. The person linked to
the user looking at the map now comes first and is named "Me" in the
list and the detail header, through a localized string. Their initials
still come from their real name.

* Draw the overview activity as a timeline

The activity list reused logbook rows: the person's name on every row,
the state pushed to the right, dividers between rows. It is now a
timeline of its own. Each row names where the person went, with the
clock time and how long ago underneath, hung off a dot in the zone's
color with a rail to the next row. A zone's rows name who came or
left and where they are. Five rows show at first, with a "Show more"
button for the rest. The overview no longer needs the broad hass
object, which only the logbook row took.

* Fit the map to its zones when they are all it shows

Zones only joined the auto-fit with the fit_zones option. The panel
map does not set it, so on the Zones tab, with no people or devices
left, the fit found nothing and jumped straight to the home
coordinates at the default zoom. Zones now stand in whenever nothing
else is on the map, which goes through the animated fit like the
other tabs. With fit_zones they still join a fit of people and devices.
2026-10-05 10:57:26 +02:00
Petar Petrov 9cc166d3cf Fix double side padding around more-info history and activity (#54511) 2026-10-05 10:57:25 +02:00
Bram Kragten 3ad1ed5aa6 Resolve split devices in the logbook card (#54507) 2026-10-05 10:57:24 +02:00
Wendelin 8ee84c2462 Use optional short issue title localization to repairs dialog (#54506)
Add short issue title localization to repairs dialog
2026-10-05 10:57:22 +02:00
Paul Bottein eca108032f Fix area switch for entities without their own name (#54503) 2026-10-05 10:57:21 +02:00
Paul Bottein 3bfe67f784 Give zones their own map colors and prefer white zone icons (#54499)
* Give zones their own map color sequence

* Prefer white zone icons when contrast allows
2026-10-05 10:57:20 +02:00
Paul Bottein c27191de6b Show people and zones on the demo map (#54496) 2026-10-05 10:57:19 +02:00
Bram Kragten 31f626c877 Bump saturation of the default light mode map (#54492)
bump saturation of the default light mode map
2026-10-05 10:57:17 +02:00
Tom Mittelstädtand1358 b081db7020 Give tentative calendar events a striped background (#54483)
* Give tentative calendar events a striped background

A tentative bar gets a light fill in its calendar color with diagonal stripes in the text color, instead of the outline alone. A tentative dot keeps its color and size and gets stripes in the calendar's contrast color. The stripes set a tentative event apart from past days, which are already drawn faded. They repeat on whole pixels, so every stripe looks the same.

* Strengthen tentative dot stripes and add a forced-colors fallback

The stripes on a tentative dot are its only cue, so they now reach at least 3:1 against the dot. In forced colors the stripes and the dot's fill are dropped, so a tentative bar falls back to a dashed border and a tentative dot to a ring of the same size.

---------

Co-authored-by: 1358 <[email protected]>
2026-10-05 10:57:16 +02:00
509 changed files with 20244 additions and 8456 deletions

No files matched your search

+5 -3
View File
@@ -51,8 +51,8 @@
- This PR fixes or closes issue: fixes #
- This PR is related to issue or discussion:
- Link to documentation pull request:
- Link to developer documentation pull request:
- Link to [documentation][docs-repository] pull request:
- Link to [contributors documentation][contributors-repository] pull request:
- Link to backend pull request:
## Checklist
@@ -63,7 +63,8 @@
for before merging your code.
AI tools are welcome, but contributors are responsible for *fully*
understanding the code before submitting a PR.
understanding the code before submitting a PR. Please follow our AI policy:
https://contributors.home-assistant.io/start/ai-policy/
-->
- [ ] I understand the code I am submitting and can explain how it works.
@@ -105,4 +106,5 @@ To help with the load of incoming pull requests:
Below, some useful links you could explore:
-->
[docs-repository]: https://github.com/home-assistant/home-assistant.io
[contributors-repository]: https://github.com/home-assistant/contributors.home-assistant.io
[perfect-pr]: https://contributors.home-assistant.io/start/pull-requests/#before-you-open-it
+2 -2
View File
@@ -2,13 +2,13 @@
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
"frontend-modern": {
"app": 627654,
"core": 55147,
"core": 57901,
"authorize": 593862,
"onboarding": 709255
},
"frontend-legacy": {
"app": 724225,
"core": 157026,
"core": 163115,
"authorize": 693913,
"onboarding": 812415
}
+21 -8
View File
@@ -117,6 +117,16 @@ module.exports.swcOptions = () => ({
},
});
const corejsPolyfills = (exclude = []) => [
"babel-plugin-polyfill-corejs3",
{
method: "usage-global",
version: dependencies["core-js"],
shippedProposals: true,
exclude,
},
];
module.exports.babelOptions = ({ latestBuild, isTestBuild, sw, worker }) => ({
babelrc: false,
compact: false,
@@ -138,14 +148,7 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw, worker }) => ({
// Inject Core-JS polyfills on demand. Babel 8 removed preset-env's
// `useBuiltIns`/`corejs` options, so the equivalent polyfill provider is
// configured directly here (`usage-global` matches the old `useBuiltIns: "usage"`).
[
"babel-plugin-polyfill-corejs3",
{
method: "usage-global",
version: dependencies["core-js"],
shippedProposals: true,
},
],
corejsPolyfills(),
[
path.join(BABEL_PLUGINS, "inline-constants-plugin.cjs"),
{
@@ -191,6 +194,16 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw, worker }) => ({
].map((p) => new RegExp(`/node_modules/${p}/`)),
],
},
{
// It only constructs `Uint8Array`s, but any reference to one pulls in
// every typed array polyfill, and this lands in the `core` entrypoint.
test: /[\\/]node_modules[\\/]home-assistant-js-websocket[\\/]/,
plugins: [
corejsPolyfills([
/^(es|esnext)\.(array-buffer|typed-array|uint8-array)\./,
]),
],
},
{
// Use unambiguous for dependencies so that require() is correctly injected into CommonJS files
// Exclusions are needed in some cases where ES modules have no static imports or exports, such as polyfills
+1 -2
View File
@@ -1,4 +1,3 @@
import type { ActionDetail } from "@material/mwc-list/mwc-list";
import { mdiCast, mdiCastConnected, mdiViewDashboard } from "@mdi/js";
import type { Auth, Connection } from "home-assistant-js-websocket";
import type { TemplateResult, PropertyValues } from "lit";
@@ -215,7 +214,7 @@ class HcCast extends LitElement {
this.castManager.requestSession();
}
private async _handlePickView(ev: CustomEvent<ActionDetail>) {
private async _handlePickView(ev: CustomEvent<{ index: number }>) {
const path = this.lovelaceViews?.[ev.detail.index]?.path ?? ev.detail.index;
await ensureConnectedCastSession(this.castManager!, this.auth!);
castSendShowLovelaceView(this.castManager, this.auth.data.hassUrl, path);
+11 -9
View File
@@ -15,6 +15,7 @@ import type {
ZHANetworkBackup,
ZHANetworkSettings,
} from "../../../../../src/data/zha";
import { ZHADeviceType } from "../../../../../src/data/zha";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import { minutesAgo } from "../helpers";
@@ -56,7 +57,7 @@ const DEVICES: ZHADevice[] = [
device_reg_id: "zha-coordinator",
user_given_name: "Home Assistant Connect ZBT-1",
power_source: "Mains",
device_type: "Coordinator",
device_type: ZHADeviceType.Coordinator,
active_coordinator: true,
signature: {},
neighbors: [
@@ -83,7 +84,7 @@ const DEVICES: ZHADevice[] = [
device_reg_id: "zha-porch-light",
user_given_name: "Porch light",
power_source: "Mains",
device_type: "Router",
device_type: ZHADeviceType.Router,
active_coordinator: false,
signature: {},
neighbors: [
@@ -110,7 +111,7 @@ const DEVICES: ZHADevice[] = [
device_reg_id: "zha-hall-motion",
user_given_name: "Hall motion",
power_source: "Battery",
device_type: "EndDevice",
device_type: ZHADeviceType.EndDevice,
active_coordinator: false,
signature: {},
neighbors: [neighbor(PORCH_IEEE, "0x1a2b", "142", "Parent", "1")],
@@ -133,7 +134,7 @@ const DEVICES: ZHADevice[] = [
device_reg_id: "zha-tv-plug",
user_given_name: "TV plug",
power_source: "Mains",
device_type: "Router",
device_type: ZHADeviceType.Router,
active_coordinator: false,
signature: {},
neighbors: [neighbor(COORDINATOR_IEEE, "0x0000", "198", "Parent", "0")],
@@ -156,7 +157,7 @@ const DEVICES: ZHADevice[] = [
device_reg_id: "zha-kitchen-switch",
user_given_name: "Kitchen switch",
power_source: "Battery",
device_type: "EndDevice",
device_type: ZHADeviceType.EndDevice,
active_coordinator: false,
signature: {},
neighbors: [neighbor(PORCH_IEEE, "0x1a2b", "186", "Parent", "1")],
@@ -179,7 +180,7 @@ const DEVICES: ZHADevice[] = [
device_reg_id: "zha-landing-sensor",
user_given_name: "Landing sensor",
power_source: "Battery",
device_type: "EndDevice",
device_type: ZHADeviceType.EndDevice,
active_coordinator: false,
signature: {},
neighbors: [neighbor(OFFICE_IEEE, "0x7a8b", "164", "Parent", "2")],
@@ -202,7 +203,7 @@ const DEVICES: ZHADevice[] = [
device_reg_id: "zha-garage-contact",
user_given_name: "Garage contact",
power_source: "Battery",
device_type: "EndDevice",
device_type: ZHADeviceType.EndDevice,
active_coordinator: false,
signature: {},
neighbors: [neighbor(OFFICE_IEEE, "0x7a8b", "118", "Parent", "2")],
@@ -225,7 +226,7 @@ const DEVICES: ZHADevice[] = [
device_reg_id: "zha-office-plug",
user_given_name: "Office plug",
power_source: "Mains",
device_type: "Router",
device_type: ZHADeviceType.Router,
active_coordinator: false,
signature: {},
neighbors: [
@@ -593,7 +594,8 @@ export const mockZha = (hass: MockHomeAssistant) => {
hass.mockWS("zha/devices/bindable", (msg: { ieee: string }) =>
DEVICES.filter(
(candidate) =>
candidate.device_type === "Router" && candidate.ieee !== msg.ieee
candidate.device_type === ZHADeviceType.Router &&
candidate.ieee !== msg.ieee
)
);
hass.mockWS("zha/devices/bind", () => undefined);
+22 -2
View File
@@ -31,7 +31,7 @@ const exposedEntities: Record<string, ExposeEntitySettings> = {
export const mockExpose = (hass: MockHomeAssistant) => {
hass.mockWS("homeassistant/expose_entity/list", () => ({
exposed_entities: exposedEntities,
exposed_entities: structuredClone(exposedEntities),
}));
hass.mockWS(
"homeassistant/expose_new_entities/get",
@@ -39,6 +39,26 @@ export const mockExpose = (hass: MockHomeAssistant) => {
expose_new: msg.assistant !== "cloud.google_assistant",
})
);
hass.mockWS("homeassistant/expose_entity", () => null);
hass.mockWS(
"homeassistant/expose_entity",
(msg: {
assistants: (keyof ExposeEntitySettings)[];
entity_ids: string[];
should_expose: boolean;
}) => {
const changedEntityIds = msg.entity_ids.filter((entityId) => {
const settings = (exposedEntities[entityId] ??= {});
let changed = false;
for (const assistant of msg.assistants) {
if (Boolean(settings[assistant]) !== msg.should_expose) {
changed = true;
}
settings[assistant] = msg.should_expose;
}
return changed;
});
return { changed_entity_ids: changedEntityIds };
}
);
hass.mockWS("homeassistant/expose_new_entities/set", () => null);
};
+30
View File
@@ -48,6 +48,36 @@ export const createMediaPlayerEntities = () => [
group_members: ["media_player.playing", "media_player.stream_playing"],
},
},
{
entity_id: "media_player.music_all_features",
state: "playing",
attributes: {
friendly_name: "Playing The Music (all features)",
media_content_type: "music",
media_title: "I Wanna Be A Hippy (Flamman & Abraxas Radio Mix)",
media_artist: "Technohead",
// Pause + Seek + Volume Set + Volume Mute + Previous Track + Next Track + On + Off +
// Play Media + Volume Step + Select Source + Stop + Clear + Play + Shuffle Set +
// Sound Mode + Browse Media + Repeat Set + Grouping
supported_features: 1048511,
entity_picture: "/images/album_cover.jpg",
media_duration: 300,
media_position: 0,
media_position_updated_at: new Date(
// 23 seconds in
new Date().getTime() - 23000
).toISOString(),
volume_level: 0.5,
is_volume_muted: false,
shuffle: false,
repeat: "off",
source_list: ["AirPlay", "Blu-Ray", "TV", "USB", "iPod (USB)"],
source: "AirPlay",
sound_mode_list: ["Movie", "Music", "Game", "Pure Audio"],
sound_mode: "Music",
group_members: ["media_player.playing", "media_player.stream_playing"],
},
},
{
entity_id: "media_player.stream_playing",
state: "playing",
@@ -1,25 +1,21 @@
import { mdiFan, mdiFanSpeed1, mdiFanSpeed2, mdiFanSpeed3 } from "@mdi/js";
import type { TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement } from "lit/decorators";
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import type { HASSDomCurrentTargetEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-card";
import type { HaDropdownSelectEvent } from "../../../../src/components/ha-dropdown";
import "../../../../src/components/ha-control-select-menu";
import "../../../../src/components/ha-list-item";
import type { SelectOption } from "../../../../src/components/ha-control-select-menu";
import "../../../../src/components/ha-svg-icon";
interface SelectMenuOptions {
label: string;
value: string;
icon?: string;
}
interface SelectMenu {
label: string;
icon: string;
class?: string;
disabled?: boolean;
options: SelectMenuOptions[];
options: SelectOption[];
}
const selects: SelectMenu[] = [
@@ -48,17 +44,17 @@ const selects: SelectMenu[] = [
{
value: "low",
label: "Low",
icon: mdiFanSpeed1,
iconPath: mdiFanSpeed1,
},
{
value: "medium",
label: "Medium",
icon: mdiFanSpeed2,
iconPath: mdiFanSpeed2,
},
{
value: "high",
label: "High",
icon: mdiFanSpeed3,
iconPath: mdiFanSpeed3,
},
],
},
@@ -72,6 +68,8 @@ const selects: SelectMenu[] = [
@customElement("demo-components-ha-control-select-menu")
export class DemoHaControlSelectMenu extends LitElement {
@state() private _values: Record<string, string> = {};
protected render(): TemplateResult {
return html`
<ha-card>
@@ -81,31 +79,13 @@ export class DemoHaControlSelectMenu extends LitElement {
<div class="card-content">
<ha-control-select-menu
.label=${select.label}
.value=${this._values[select.label]}
.options=${select.options}
?disabled=${select.disabled}
fixedMenuPosition
naturalMenuWidth
data-label=${select.label}
@wa-select=${this._handleSelect}
>
<ha-svg-icon slot="icon" .path=${select.icon}></ha-svg-icon>
${select.options.map(
(option) => html`
<ha-list-item
.value=${option.value}
.graphic=${option.icon ? "icon" : undefined}
>
${
option.icon
? html`
<ha-svg-icon
slot="graphic"
.path=${option.icon}
></ha-svg-icon>
`
: nothing
}
${option.label ?? option.value}
</ha-list-item>
`
)}
</ha-control-select-menu>
</div>
`
@@ -114,6 +94,17 @@ export class DemoHaControlSelectMenu extends LitElement {
`;
}
private _handleSelect(
ev: HaDropdownSelectEvent & HASSDomCurrentTargetEvent<HTMLElement>
) {
const label = ev.currentTarget.dataset.label;
const value = ev.detail.item.value;
if (!label || !value) {
return;
}
this._values = { ...this._values, [label]: value };
}
static styles = css`
ha-card {
max-width: 600px;
@@ -3,7 +3,6 @@ import { customElement, state } from "lit/decorators";
import { formatDateTimeNumeric } from "../../../../src/common/datetime/format_date_time";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-control-select";
import "../../../../src/components/ha-list";
import type { FrontendLocaleData } from "../../../../src/data/translation";
import {
DateFormat,
@@ -49,7 +48,7 @@ export class DemoDateTimeDateTimeNumeric extends LitElement {
@value-changed=${this.handleValueChanged}
>
</ha-control-select>
<ha-list>
<div>
<div class="container header">
<div>Language</div>
<div class="center">Default (lang)</div>
@@ -96,7 +95,7 @@ export class DemoDateTimeDateTimeNumeric extends LitElement {
</div>
`
)}
</ha-list>
</div>
`;
}
@@ -3,7 +3,6 @@ import { customElement, state } from "lit/decorators";
import { formatDateTimeWithSeconds } from "../../../../src/common/datetime/format_date_time";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-control-select";
import "../../../../src/components/ha-list";
import type { FrontendLocaleData } from "../../../../src/data/translation";
import {
DateFormat,
@@ -49,7 +48,7 @@ export class DemoDateTimeDateTimeSeconds extends LitElement {
@value-changed=${this.handleValueChanged}
>
</ha-control-select>
<ha-list>
<div>
<div class="container header">
<div>Language</div>
<div class="center">Default (lang)</div>
@@ -96,7 +95,7 @@ export class DemoDateTimeDateTimeSeconds extends LitElement {
</div>
`
)}
</ha-list>
</div>
`;
}
@@ -3,7 +3,6 @@ import { customElement, state } from "lit/decorators";
import { formatShortDateTimeWithYear } from "../../../../src/common/datetime/format_date_time";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-control-select";
import "../../../../src/components/ha-list";
import type { FrontendLocaleData } from "../../../../src/data/translation";
import {
DateFormat,
@@ -49,7 +48,7 @@ export class DemoDateTimeDateTimeShortYear extends LitElement {
@value-changed=${this.handleValueChanged}
>
</ha-control-select>
<ha-list>
<div>
<div class="container header">
<div>Language</div>
<div class="center">Default (lang)</div>
@@ -96,7 +95,7 @@ export class DemoDateTimeDateTimeShortYear extends LitElement {
</div>
`
)}
</ha-list>
</div>
`;
}
@@ -3,7 +3,6 @@ import { customElement, state } from "lit/decorators";
import { formatShortDateTime } from "../../../../src/common/datetime/format_date_time";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-control-select";
import "../../../../src/components/ha-list";
import type { FrontendLocaleData } from "../../../../src/data/translation";
import {
DateFormat,
@@ -49,7 +48,7 @@ export class DemoDateTimeDateTimeShort extends LitElement {
@value-changed=${this.handleValueChanged}
>
</ha-control-select>
<ha-list>
<div>
<div class="container header">
<div>Language</div>
<div class="center">Default (lang)</div>
@@ -96,7 +95,7 @@ export class DemoDateTimeDateTimeShort extends LitElement {
</div>
`
)}
</ha-list>
</div>
`;
}
+2 -3
View File
@@ -3,7 +3,6 @@ import { customElement, state } from "lit/decorators";
import { formatDateTime } from "../../../../src/common/datetime/format_date_time";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-control-select";
import "../../../../src/components/ha-list";
import type { FrontendLocaleData } from "../../../../src/data/translation";
import {
DateFormat,
@@ -49,7 +48,7 @@ export class DemoDateTimeDateTime extends LitElement {
@value-changed=${this.handleValueChanged}
>
</ha-control-select>
<ha-list>
<div>
<div class="container header">
<div>Language</div>
<div class="center">Default (lang)</div>
@@ -96,7 +95,7 @@ export class DemoDateTimeDateTime extends LitElement {
</div>
`
)}
</ha-list>
</div>
`;
}
+2 -3
View File
@@ -2,7 +2,6 @@ import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators";
import { formatDateNumeric } from "../../../../src/common/datetime/format_date";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-list";
import type { FrontendLocaleData } from "../../../../src/data/translation";
import {
DateFormat,
@@ -27,7 +26,7 @@ export class DemoDateTimeDate extends LitElement {
};
const date = new Date();
return html`
<ha-list>
<div>
<div class="container header">
<div>Language</div>
<div class="center">Default (lang)</div>
@@ -86,7 +85,7 @@ export class DemoDateTimeDate extends LitElement {
</div>
`
)}
</ha-list>
</div>
`;
}
+2 -3
View File
@@ -3,7 +3,6 @@ import { customElement, state } from "lit/decorators";
import { formatTimeWithSeconds } from "../../../../src/common/datetime/format_time";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-control-select";
import "../../../../src/components/ha-list";
import type { FrontendLocaleData } from "../../../../src/data/translation";
import {
DateFormat,
@@ -49,7 +48,7 @@ export class DemoDateTimeTimeSeconds extends LitElement {
@value-changed=${this.handleValueChanged}
>
</ha-control-select>
<ha-list>
<div>
<div class="container header">
<div>Language</div>
<div class="center">Default (lang)</div>
@@ -96,7 +95,7 @@ export class DemoDateTimeTimeSeconds extends LitElement {
</div>
`
)}
</ha-list>
</div>
`;
}
+2 -3
View File
@@ -3,7 +3,6 @@ import { customElement, state } from "lit/decorators";
import { formatTimeWeekday } from "../../../../src/common/datetime/format_time";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-control-select";
import "../../../../src/components/ha-list";
import type { FrontendLocaleData } from "../../../../src/data/translation";
import {
DateFormat,
@@ -49,7 +48,7 @@ export class DemoDateTimeTimeWeekday extends LitElement {
@value-changed=${this.handleValueChanged}
>
</ha-control-select>
<ha-list>
<div>
<div class="container header">
<div>Language</div>
<div class="center">Default (lang)</div>
@@ -96,7 +95,7 @@ export class DemoDateTimeTimeWeekday extends LitElement {
</div>
`
)}
</ha-list>
</div>
`;
}
+2 -3
View File
@@ -3,7 +3,6 @@ import { customElement, state } from "lit/decorators";
import { formatTime } from "../../../../src/common/datetime/format_time";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-control-select";
import "../../../../src/components/ha-list";
import type { FrontendLocaleData } from "../../../../src/data/translation";
import {
DateFormat,
@@ -49,7 +48,7 @@ export class DemoDateTimeTime extends LitElement {
@value-changed=${this.handleValueChanged}
>
</ha-control-select>
<ha-list>
<div>
<div class="container header">
<div>Language</div>
<div class="center">Default (lang)</div>
@@ -96,7 +95,7 @@ export class DemoDateTimeTime extends LitElement {
</div>
`
)}
</ha-list>
</div>
`;
}
@@ -25,6 +25,13 @@ const CONFIGS = [
entity: "media_player.music_playing",
},
},
{
heading: "Playing Music (all features)",
config: {
type: "media-control",
entity: "media_player.music_all_features",
},
},
{
heading: "Playing Stream",
config: {
@@ -81,6 +81,15 @@ const CONFIGS = [
state_color: true,
},
},
{
heading: "With custom color",
config: {
type: "sensor",
entity: "sensor.outside_temperature",
color: "teal",
graph: "line",
},
},
{
heading: "With name, icon and unit",
config: {
+8 -8
View File
@@ -63,7 +63,7 @@
"@formatjs/intl-numberformat": "9.4.3",
"@formatjs/intl-pluralrules": "6.3.16",
"@formatjs/intl-relativetimeformat": "12.3.16",
"@home-assistant/webawesome": "3.14.0-ha.1",
"@home-assistant/webawesome": "3.14.0-ha.2",
"@lezer/common": "1.5.4",
"@lezer/highlight": "1.2.5",
"@lit-labs/motion": "1.1.0",
@@ -78,8 +78,8 @@
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.23",
"@tsparticles/engine": "4.4.0",
"@tsparticles/preset-links": "4.4.0",
"@tsparticles/engine": "4.5.0",
"@tsparticles/preset-links": "4.5.0",
"@versatiles/style": "7.0.1",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.201.1",
@@ -101,7 +101,7 @@
"fullcalendar": "7.1.3",
"fuse.js": "7.5.0",
"hls.js": "1.7.3",
"home-assistant-js-websocket": "9.7.1",
"home-assistant-js-websocket": "9.8.1",
"idb-keyval": "6.3.0",
"intl-messageformat": "12.1.4",
"js-yaml": "5.5.0",
@@ -109,8 +109,8 @@
"leaflet.markercluster": "1.5.3",
"lit": "3.3.3",
"lit-html": "3.3.3",
"maplibre-gl": "6.15.0",
"marked": "18.1.1",
"maplibre-gl": "6.16.0",
"marked": "18.1.2",
"memoize-one": "6.0.0",
"nanoid": "6.0.2",
"node-vibrant": "4.0.4",
@@ -132,7 +132,7 @@
"workbox-precaching": "7.4.1",
"workbox-routing": "7.4.1",
"workbox-strategies": "7.4.1",
"xss": "1.0.16",
"xss": "1.0.17",
"zrender": "6.1.0"
},
"devDependencies": {
@@ -154,7 +154,7 @@
"@playwright/test": "1.64.1",
"@rsdoctor/rspack-plugin": "1.6.5",
"@rspack/core": "2.2.9",
"@rspack/dev-server": "2.2.1",
"@rspack/dev-server": "2.2.2",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "7.0.1",
"@types/chromecast-caf-sender": "1.0.11",
+24
View File
@@ -13,3 +13,27 @@ index 95366713472f3ec5f3649547bb1fe202b806a2e6..e91f99229d554e79077486e1caf3602c
originX: e.pinchX,
originY: e.pinchY,
isAvailableBehavior: null
diff --git a/lib/label/LabelManager.js b/lib/label/LabelManager.js
index 3d233be98b14223168726ebb3d9cd9b768fa2554..d6abfe5484ea8c2f0cb3a9d7c7c2fe31e7516fc7 100644
--- a/lib/label/LabelManager.js
+++ b/lib/label/LabelManager.js
@@ -43,6 +43,7 @@
*/
// TODO: move labels out of viewport.
import { BoundingRect, updateProps, initProps, isElementRemoved } from '../util/graphic.js';
+import { getAnimationConfig } from '../animation/basicTransition.js';
import { getECData } from '../util/innerStore.js';
import { parsePercent } from '../util/number.js';
import Transformable from 'zrender/lib/core/Transformable.js';
@@ -395,6 +396,11 @@ var LabelManager = /** @class */function () {
textEl.attr(layoutStore.oldLayoutEmphasis);
}
}
+ // An instant update, e.g. on roam, stops all animations and would leave the fade in half done
+ var updateConfig = getAnimationConfig('update', seriesModel, dataIndex);
+ if (!(updateConfig && updateConfig.duration > 0)) {
+ textEl.stopAnimation('enter', true);
+ }
updateProps(textEl, newProps, seriesModel, dataIndex);
}
layoutStore.oldLayout = newProps;
+153 -153
View File
@@ -171,7 +171,7 @@ overrides:
patchedDependencies:
'@material/[email protected]': 9450e509a86887d8f68ca9cf3f81e6ed70e94e49de13c5f6f20f94519ad4a987
[email protected]: b02e30d0fb3e2b865321c17ca1234593db0d05a1365cb6c932cd47c0e673fca0
[email protected]: 4c014bd1952b4d1f21311ed2212dc87192e70b6e782597ecc7a485270247bbb5
[email protected]: f2ec2ae6877dfc201c542b4f9ded1d03831258bf328ae7bba876d14ce4508a06
[email protected]: cdc7ece96d53357d38ff3c124a2553f86e3a5dc333d641d223eb6f07e28275f0
[email protected]: 75e6346ad8d8af554c167f77ac7d9b9652f010398efdc6b399925a4795133099
@@ -250,8 +250,8 @@ importers:
specifier: 12.3.16
version: 12.3.16
'@home-assistant/webawesome':
specifier: 3.14.0-ha.1
version: 3.14.0-ha.1(@floating-ui/[email protected])(@types/[email protected])
specifier: 3.14.0-ha.2
version: 3.14.0-ha.2(@floating-ui/[email protected])(@types/[email protected])
'@lezer/common':
specifier: 1.5.4
version: 1.5.4
@@ -278,7 +278,7 @@ importers:
version: 1.0.3
'@maplibre/maplibre-gl-leaflet':
specifier: 0.1.4
version: 0.1.4(@types/[email protected])([email protected])([email protected]5.0)
version: 0.1.4(@types/[email protected])([email protected])([email protected]6.0)
'@material/mwc-list':
specifier: 0.27.0
version: 0.27.0(patch_hash=9450e509a86887d8f68ca9cf3f81e6ed70e94e49de13c5f6f20f94519ad4a987)
@@ -295,14 +295,14 @@ importers:
specifier: 0.5.23
version: 0.5.23
'@tsparticles/engine':
specifier: 4.4.0
version: 4.4.0
specifier: 4.5.0
version: 4.5.0
'@tsparticles/preset-links':
specifier: 4.4.0
version: 4.4.0
specifier: 4.5.0
version: 4.5.0
'@versatiles/style':
specifier: 7.0.1
version: 7.0.1([email protected]5.0)
version: 7.0.1([email protected]6.0)
'@vibrant/color':
specifier: 4.0.4
version: 4.0.4
@@ -347,10 +347,10 @@ importers:
version: 0.0.1
echarts:
specifier: 6.1.0
version: 6.1.0(patch_hash=b02e30d0fb3e2b865321c17ca1234593db0d05a1365cb6c932cd47c0e673fca0)
version: 6.1.0(patch_hash=4c014bd1952b4d1f21311ed2212dc87192e70b6e782597ecc7a485270247bbb5)
echarts-extension-chart2music:
specifier: 0.1.1
version: 0.1.1([email protected](patch_hash=b02e30d0fb3e2b865321c17ca1234593db0d05a1365cb6c932cd47c0e673fca0))([email protected])
version: 0.1.1([email protected](patch_hash=4c014bd1952b4d1f21311ed2212dc87192e70b6e782597ecc7a485270247bbb5))([email protected])
element-internals-polyfill:
specifier: 3.0.2
version: 3.0.2
@@ -364,8 +364,8 @@ importers:
specifier: 1.7.3
version: 1.7.3
home-assistant-js-websocket:
specifier: 9.7.1
version: 9.7.1
specifier: 9.8.1
version: 9.8.1
idb-keyval:
specifier: 6.3.0
version: 6.3.0
@@ -388,11 +388,11 @@ importers:
specifier: 3.3.3
version: 3.3.3
maplibre-gl:
specifier: 6.15.0
version: 6.15.0
specifier: 6.16.0
version: 6.16.0
marked:
specifier: 18.1.1
version: 18.1.1
specifier: 18.1.2
version: 18.1.2
memoize-one:
specifier: 6.0.0
version: 6.0.0
@@ -457,8 +457,8 @@ importers:
specifier: 7.4.1
version: 7.4.1
xss:
specifier: 1.0.16
version: 1.0.16
specifier: 1.0.17
version: 1.0.17
zrender:
specifier: 6.1.0
version: 6.1.0
@@ -518,8 +518,8 @@ importers:
specifier: 2.2.9
version: 2.2.9(@swc/[email protected])
'@rspack/dev-server':
specifier: 2.2.1
version: 2.2.1(@rspack/[email protected](@swc/[email protected]))
specifier: 2.2.2
version: 2.2.2(@rspack/[email protected](@swc/[email protected]))
'@types/babel__plugin-transform-runtime':
specifier: 7.9.5
version: 7.9.5
@@ -2024,8 +2024,8 @@ packages:
resolution: {integrity: sha512-kjotm7XJrJ6v+7knhPaRgaT6q8F8K2jiafwYdNHLzmV0uGLuZY43FK6smNSHUPrhq5kX2slCUy+RGG/xGqmIKA==}
engines: {node: '>=10.13.0'}
'@home-assistant/[email protected].1':
resolution: {integrity: sha512-h/HVT3LnDIk9rQ2+xAm0WTXFG89sV1KEAqLwjxl5tYBDAftQZCxV+5adchRref8anvBtp1opctjxHtuNBwgQ2g==}
'@home-assistant/[email protected].2':
resolution: {integrity: sha512-EX1qiX5h3LAoTjuE3rgfuRlNq3Rds5tGrgDGlHoFbxhXaCSTTtuYU6tdR8vgOXKGUDi5gBDrcoNdLYKFXSrknA==}
engines: {node: '>=14.17.0'}
'@html-eslint/[email protected]':
@@ -2258,8 +2258,8 @@ packages:
'@maplibre/[email protected]':
resolution: {integrity: sha512-7M2O7ABM80Oi8XPkfkrjKB8mOBIxGoVgkA52Mu4RMWoBvvMdagQ522+WxVYujZsMpqIr3BpzrTbXrm4k8lCzCQ==}
'@maplibre/[email protected].2':
resolution: {integrity: sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==}
'@maplibre/[email protected].3':
resolution: {integrity: sha512-dEe5XUd64RQbxNxy2OzIkJnwNDWjVmSXjCd0A7zLirm7QJ/WWbmtDFIkfsiM4yt1cplqzG6oSivRYeWx1SM1yg==}
'@marijn/[email protected]':
resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==}
@@ -2990,8 +2990,8 @@ packages:
'@swc/helpers':
optional: true
'@rspack/[email protected].3':
resolution: {integrity: sha512-GxnGj9jy76G3eCPyZei81fwKLAMLZaPEEqFz1/QDYquhwi/qYZX5fekFJ1XVpuwxGEK9KSX3hxZylfwrs4cmLA==}
'@rspack/[email protected].6':
resolution: {integrity: sha512-Rcx7dlDZQ9ddUJGuutFvnSeAKl+ZOc1fnC3htibs8yew3zMD9VgvSUe4MAwEn94Gb390l5heA+s/j5KhoSG5MQ==}
engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
'@rspack/core': ^2.0.0
@@ -2999,8 +2999,8 @@ packages:
'@rspack/core':
optional: true
'@rspack/[email protected].1':
resolution: {integrity: sha512-Il8HbYGK3rH5rM0XmUOsOGZVw69BKRpBZ61P28Fy8ry35CtfniDBWtbo/rvJtIT/sK0qwijmNrhF498ltsV+uA==}
'@rspack/[email protected].2':
resolution: {integrity: sha512-pK4V9YJW8dkfjy6WYJ2T6DrPkcIn2yjA1HiaU146141j8UCxHok5lsplyqudLzerOhvwqMrNFmnsuXLnFbfn4Q==}
engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
'@rspack/core': ^2.0.0
@@ -3156,85 +3156,85 @@ packages:
resolution: {integrity: sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q==}
engines: {node: '>=12'}
'@tsparticles/animation-utils@4.4.0':
resolution: {integrity: sha512-yl4iE9SSuJ2n8B096t/vn4bWN+wWIkDm11OfTsIM3/64bJLB/5T92n6iOm/XLC3r6g8M8XuDi6ntTnpOcwsGxg==}
'@tsparticles/animation-utils@4.5.0':
resolution: {integrity: sha512-jIcPbhaA7X9gIEzUOsw63Vn7558ZLRbPeAodfzvIEo+VYhmnKOCrnf7e91THZ2XoFYjRQkkOt94uJfT8bma1TQ==}
peerDependencies:
'@tsparticles/engine': 4.4.0
'@tsparticles/engine': 4.5.0
'@tsparticles/basic@4.4.0':
resolution: {integrity: sha512-lTbKBPzab0alPy+4Wm/xehrVpT1FrpEMaQcTU2eTV0c0G++5/kOINEWkC7GL7/cHovycukxB+tOMYVT54bSprA==}
'@tsparticles/basic@4.5.0':
resolution: {integrity: sha512-4yZucXSWNeHh5NXjJ1is4aiq1Fi8hW5gBTrlWbIm7JcqBjUxNkYCqdLKvOCR/5bnToqjtezBJWlArwBdbzuU6w==}
'@tsparticles/canvas-utils@4.4.0':
resolution: {integrity: sha512-3zVr95OZ05BExgVYNT8WPqA/wFp83TiEJo66rZCZ/nZd1Ny8UCYLMKBcbUPBvGS2GB87UMK8+ygleZnlFhGTtw==}
'@tsparticles/canvas-utils@4.5.0':
resolution: {integrity: sha512-Yu/lxI9DzJvcvXSLlqEc7j/1B9f3A/Wa2yK6R+fn3ysDmf4CUVdjOiwa5Y65LlvvSpZMfBLbPoIgkwnCSbYaow==}
peerDependencies:
'@tsparticles/engine': 4.4.0
'@tsparticles/engine': 4.5.0
'@tsparticles/engine@4.4.0':
resolution: {integrity: sha512-zUDykZ7qBqHYMXr6NveXg51kBlvfaQKMmWkMJ8nT7fC4dXdNayb1+TxOfAoqH9/U/u0Eyr0YE+72ai/ymS0uww==}
'@tsparticles/engine@4.5.0':
resolution: {integrity: sha512-ClhG2dxDU7HUAqCHGQZLgyIQRJFhuuCFCnNowSB9znuoNrxSeln4UKVBbRRv/994Eoi5OHuHuGaK9iw6ZeT21g==}
'@tsparticles/interaction-particles-links@4.4.0':
resolution: {integrity: sha512-n2OkzB8BjQkzFrtiVq8O42/Tf+/Psng+K71MhJDI+czkwLo3PdmG51MCsy2w9ywCvGwWci2nwxMQ1qZL7nPONg==}
'@tsparticles/interaction-particles-links@4.5.0':
resolution: {integrity: sha512-L6F7eJdFVn3JsulaKrIwnl52rE9EazfMrNIUpa829PRoi/30BPOmRP469EKjdv3mntiwjUXgWFx8NDj+iQkJgw==}
peerDependencies:
'@tsparticles/engine': 4.4.0
'@tsparticles/plugin-interactivity': 4.4.0
'@tsparticles/engine': 4.5.0
'@tsparticles/plugin-interactivity': 4.5.0
'@tsparticles/plugin-blend@4.4.0':
resolution: {integrity: sha512-X/SG2WIasgHqA+fLZFu6MMv3z5VY5uX4VIfKjXz1qxZjEjn0w4kzZrbtZBxXxRKdrm8Dg8Qu2mmBokNZ8Bny3g==}
'@tsparticles/plugin-blend@4.5.0':
resolution: {integrity: sha512-iTy4OajUDyDHyYVh2NaUZjKwzrjFfiGLiWl1I04dli417Ecmwfq/pU6leX/SIARVAqdvaeZOwFg4YmIN+zvv6w==}
peerDependencies:
'@tsparticles/engine': 4.4.0
'@tsparticles/engine': 4.5.0
'@tsparticles/plugin-hex-color@4.4.0':
resolution: {integrity: sha512-sbRW6fi/YOS/hvFi6GCQiKI2zMCrmydrhlzqWyw2UUyg4Rr3FviQbIC2VRZrMc30Cm6kSkR3SovXIFDpZzO94Q==}
'@tsparticles/plugin-hex-color@4.5.0':
resolution: {integrity: sha512-yO3Rw27rR4ZpJo8IuBp0br1GXaju7iUl4u8zVD8WNcig7bbgoZqzhFhofgQYY9UPLVLyG1dnn8aODsk5Rq0X2g==}
peerDependencies:
'@tsparticles/engine': 4.4.0
'@tsparticles/engine': 4.5.0
'@tsparticles/plugin-hsl-color@4.4.0':
resolution: {integrity: sha512-nw/7cGoU99y8veeYD7yA7ygNyrGr461V9xx+ed+jrj0LyXQbLF3d/r78ovMseoHxm4SWa9pmtoQxS3fBHIgecw==}
'@tsparticles/plugin-hsl-color@4.5.0':
resolution: {integrity: sha512-6AyFRVVXyHb4NrytzwKoCIUhSD/ZXIIr1av2z7cNgaLJCZ00qrur/G7FJhK1XUBNR4DV6/hYhP9ohSfrEhdZ+Q==}
peerDependencies:
'@tsparticles/engine': 4.4.0
'@tsparticles/engine': 4.5.0
'@tsparticles/plugin-interactivity@4.4.0':
resolution: {integrity: sha512-jquoxlT4ra1Mc4glVlIfPubmchyIq5IZ25RdnvAop1uuc+6tu4N1zq5SSeyRO8MhJ94xevsNe6ozjmqvZ9CzKA==}
'@tsparticles/plugin-interactivity@4.5.0':
resolution: {integrity: sha512-Ydxd29SRldhjbvf5IIhu5NIfCAW2LdqSk6aN60zJb6AupV+wxztJ4/e4JL1t0ymj9992iQSVD9nQql4yzbdOwA==}
peerDependencies:
'@tsparticles/engine': 4.4.0
'@tsparticles/engine': 4.5.0
'@tsparticles/plugin-move@4.4.0':
resolution: {integrity: sha512-o05YP6bWxljbxmsQoqbay8sn4GtHIiTQZhZmcIyxJw/q+oSCNKHWALElSAqa1H7Dp/7ltnmd4rH4pgbQtHnfEw==}
'@tsparticles/plugin-move@4.5.0':
resolution: {integrity: sha512-0lkYVygqc30SqBiuviI900fBKPHkJJyuZVpUBSolGSTz7J1P49PXKqy1az6f+ub42SZxFcGOChtQ5vwpZSh0Gg==}
peerDependencies:
'@tsparticles/engine': 4.4.0
'@tsparticles/engine': 4.5.0
'@tsparticles/plugin-rgb-color@4.4.0':
resolution: {integrity: sha512-s7YlsrcjndVLiOgKNPQaPcEQmmZeZqHec0lpSbt8BgR2pQmjFvCMSzHDA/aaqZ6JX8iVqvj+bT8uqT5XQDq70Q==}
'@tsparticles/plugin-rgb-color@4.5.0':
resolution: {integrity: sha512-DBhtOO82K2kLaFginNEGfgfKoSXycj0eJzCHp2kSQ0HjKkpwINdK2nFh2QXfsYfjP1fQbLaIu3CuJG/VmjkDvw==}
peerDependencies:
'@tsparticles/engine': 4.4.0
'@tsparticles/engine': 4.5.0
'@tsparticles/preset-links@4.4.0':
resolution: {integrity: sha512-skdPsg/3AgBFtv0vrn20KrPGbIa0AdZw812Avg86BW7NqVGeX3JLyowZbFRQTPU13CcUo9YXCeJccoZv6FovOw==}
'@tsparticles/preset-links@4.5.0':
resolution: {integrity: sha512-Itwr6ECwf3ORiroUvLAwwg8O66IT3tQ2CFdml6A0u3ze1kfBmL9JT/u9UYG4QFICT0qjE86pkXpFRv8NwWLkBw==}
'@tsparticles/shape-circle@4.4.0':
resolution: {integrity: sha512-pCdCenLohHwaeeuMCw9bh1icGB+CKwBYx67L5dTBw6aig7LRvL+Kd1PKClJTgXkx8Mb1Xb7KLNmSYgh/ABRtEQ==}
'@tsparticles/shape-circle@4.5.0':
resolution: {integrity: sha512-6fyvghJFvUMokKIQU6p7KKwcX3pTnp6gizj/occVTnar8AxxOlXNjK1J019etwnqdLbSWU/E7Qdt7y4zCTPD1A==}
peerDependencies:
'@tsparticles/engine': 4.4.0
'@tsparticles/engine': 4.5.0
'@tsparticles/updater-opacity@4.4.0':
resolution: {integrity: sha512-StQAExNqeOHANRAMXHdaKKEBD1DrRwBZ3TQF6DPtERtoElj8BvMlPHq38W0TSzfvR0lFP91p2Pzo5txVzVJ3Gg==}
'@tsparticles/updater-opacity@4.5.0':
resolution: {integrity: sha512-808wb6KUe1wYzth/hJBMzcLDsApmES4FHOavoulyQmSJdWfiNED4LjoX2MNJg4ry56wtg9IXS7qa8XaqdekQYA==}
peerDependencies:
'@tsparticles/engine': 4.4.0
'@tsparticles/engine': 4.5.0
'@tsparticles/updater-out-modes@4.4.0':
resolution: {integrity: sha512-IzHaYhOVI6mBFqub1ivd/5UMkvn5Pb/j9xS8edT7XeSNYqO3UeomLJci0KE6Xsm+4K/kBLGbCRW5+Tf+kV4E0g==}
'@tsparticles/updater-out-modes@4.5.0':
resolution: {integrity: sha512-fOzj/2nUtwPK7A+1WN+EnQZtLPBI8++hxkgOHr+RIEGo2d9K4qOgoIxI1zpa6WHt9fSXCk4rWsjENpc++pB+JQ==}
peerDependencies:
'@tsparticles/engine': 4.4.0
'@tsparticles/engine': 4.5.0
'@tsparticles/updater-paint@4.4.0':
resolution: {integrity: sha512-v7liuaT9BwHsd1hCfJF2d93M2gD3lSfsfIfKjPihAYo73hS43Wq7Cf1F0yjEiR3yXryRGH1QmYYdhonKAvyDKw==}
'@tsparticles/updater-paint@4.5.0':
resolution: {integrity: sha512-BJK3De1UZW01uyIk5AJVW4ex2ejEfoU5Vqw512Ox/kGipx3qXsaROTbh/9+NbqMtRyDtYuDc5PgGWIWTyYb7TQ==}
peerDependencies:
'@tsparticles/engine': 4.4.0
'@tsparticles/engine': 4.5.0
'@tsparticles/updater-size@4.4.0':
resolution: {integrity: sha512-Qq8QjC0MMjlaHjtpWUROSqe14eKVphq92n0VR4/d4WY+pnSG1U1VJzF43Kyfb4IyV06b29F0TUROIG7Ivqk36g==}
'@tsparticles/updater-size@4.5.0':
resolution: {integrity: sha512-42AZGbnWUmIL7giRaerPauYHaX2Agx482+8dq3hBYvvc0vM4Pks/fhpaKtTCAGG4CJb3fvaiyRrF7T75CQVAdQ==}
peerDependencies:
'@tsparticles/engine': 4.4.0
'@tsparticles/engine': 4.5.0
'@tufjs/[email protected]':
resolution: {integrity: sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==}
@@ -5205,8 +5205,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==}
home-assistant-js-websocket@9.7.1:
resolution: {integrity: sha512-zOAiyF+gUc/IsiswYdWKns6SCBbvWb8iJtMX9+Dk0wlbdfcp+I38T2Vx36krWeWMk/GPwoSiYwCs5hk/+Hsn7w==}
home-assistant-js-websocket@9.8.1:
resolution: {integrity: sha512-WvovbrSoYLTKfh6aY5SxSWPPy50/mLWLCihQL8G2N+2e3mTrg3QTQCAp4R32/LwbkRBDGj0XNs6JFj8e2IYEUw==}
[email protected]:
resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==}
@@ -5898,8 +5898,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ==}
[email protected]5.0:
resolution: {integrity: sha512-kxFetbvUHDOVaAP1CYb226Mdk2QhzIPddqM0xJTFD35wu78Mpd6NM7+H68ALjc6Oa0kAOX5EAdMKyvD2u3qfcg==}
[email protected]6.0:
resolution: {integrity: sha512-gApO66EMtoDZuio680RUBvskR7ahFd/NNhLN4tBAs+Y7FeT4gQV8zVFgYVMRmFxQznOEzecW19w6xOd9XjZjiA==}
engines: {node: '>=16.14.0', npm: '>=8.1.0'}
[email protected]:
@@ -5907,8 +5907,8 @@ packages:
engines: {node: '>= 18'}
hasBin: true
[email protected].1:
resolution: {integrity: sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg==}
[email protected].2:
resolution: {integrity: sha512-DfOnvLAQ6WYeWxWO1u4RyFOeqyHup6dks5PPqnoFiooU7ipRvidO/2j8plhAHIiZg574E9ZlzSjUiPMLAzFkgg==}
engines: {node: '>= 20'}
hasBin: true
@@ -7776,8 +7776,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
[email protected]6:
resolution: {integrity: sha512-CsEvZ0QNH9vVgf3qldg+mJmmIwreZZEu/JS1SgldJrJqZgwyMrmIfENt1BJlkj/cPxO0c+R80kB5OMRtUPgqpQ==}
[email protected]7:
resolution: {integrity: sha512-+SEr5IDSgTBnhT6bnnf0mVRHT91+HU517zkahLNhkWLz8retCgeR1YDT4OknLiExA5+chn2Wy7z3KyjvnD8A2w==}
engines: {node: '>= 0.10.0'}
hasBin: true
@@ -9483,7 +9483,7 @@ snapshots:
dependencies:
is-negated-glob: 1.0.0
'@home-assistant/[email protected].1(@floating-ui/[email protected])(@types/[email protected])':
'@home-assistant/[email protected].2(@floating-ui/[email protected])(@types/[email protected])':
dependencies:
'@ctrl/tinycolor': 4.1.0
'@floating-ui/dom': 1.8.0
@@ -9784,11 +9784,11 @@ snapshots:
dependencies:
kdbush: 4.1.0
'@maplibre/[email protected](@types/[email protected])([email protected])([email protected]5.0)':
'@maplibre/[email protected](@types/[email protected])([email protected])([email protected]6.0)':
dependencies:
'@types/leaflet': 1.9.22
leaflet: 1.9.4
maplibre-gl: 6.15.0
maplibre-gl: 6.16.0
'@maplibre/[email protected]':
dependencies:
@@ -9803,7 +9803,7 @@ snapshots:
dependencies:
'@mapbox/point-geometry': 1.1.0
'@maplibre/[email protected].2':
'@maplibre/[email protected].3':
dependencies:
'@mapbox/point-geometry': 1.1.0
'@types/geojson': 7946.0.16
@@ -10675,14 +10675,14 @@ snapshots:
optionalDependencies:
'@swc/helpers': 0.5.23
'@rspack/[email protected].3(@rspack/[email protected](@swc/[email protected]))':
'@rspack/[email protected].6(@rspack/[email protected](@swc/[email protected]))':
optionalDependencies:
'@rspack/core': 2.2.9(@swc/[email protected])
'@rspack/[email protected].1(@rspack/[email protected](@swc/[email protected]))':
'@rspack/[email protected].2(@rspack/[email protected](@swc/[email protected]))':
dependencies:
'@rspack/core': 2.2.9(@swc/[email protected])
'@rspack/dev-middleware': 2.0.3(@rspack/[email protected](@swc/[email protected]))
'@rspack/dev-middleware': 2.0.6(@rspack/[email protected](@swc/[email protected]))
'@rspack/[email protected]': {}
@@ -10844,88 +10844,88 @@ snapshots:
magic-string: 0.30.21
string.prototype.matchall: 4.0.12
'@tsparticles/animation-utils@4.4.0(@tsparticles/engine@4.4.0)':
'@tsparticles/animation-utils@4.5.0(@tsparticles/engine@4.5.0)':
dependencies:
'@tsparticles/engine': 4.4.0
'@tsparticles/engine': 4.5.0
'@tsparticles/basic@4.4.0':
'@tsparticles/basic@4.5.0':
dependencies:
'@tsparticles/engine': 4.4.0
'@tsparticles/plugin-blend': 4.4.0(@tsparticles/engine@4.4.0)
'@tsparticles/plugin-hex-color': 4.4.0(@tsparticles/engine@4.4.0)
'@tsparticles/plugin-hsl-color': 4.4.0(@tsparticles/engine@4.4.0)
'@tsparticles/plugin-move': 4.4.0(@tsparticles/engine@4.4.0)
'@tsparticles/plugin-rgb-color': 4.4.0(@tsparticles/engine@4.4.0)
'@tsparticles/shape-circle': 4.4.0(@tsparticles/engine@4.4.0)
'@tsparticles/updater-opacity': 4.4.0(@tsparticles/engine@4.4.0)
'@tsparticles/updater-out-modes': 4.4.0(@tsparticles/engine@4.4.0)
'@tsparticles/updater-paint': 4.4.0(@tsparticles/engine@4.4.0)
'@tsparticles/updater-size': 4.4.0(@tsparticles/engine@4.4.0)
'@tsparticles/engine': 4.5.0
'@tsparticles/plugin-blend': 4.5.0(@tsparticles/engine@4.5.0)
'@tsparticles/plugin-hex-color': 4.5.0(@tsparticles/engine@4.5.0)
'@tsparticles/plugin-hsl-color': 4.5.0(@tsparticles/engine@4.5.0)
'@tsparticles/plugin-move': 4.5.0(@tsparticles/engine@4.5.0)
'@tsparticles/plugin-rgb-color': 4.5.0(@tsparticles/engine@4.5.0)
'@tsparticles/shape-circle': 4.5.0(@tsparticles/engine@4.5.0)
'@tsparticles/updater-opacity': 4.5.0(@tsparticles/engine@4.5.0)
'@tsparticles/updater-out-modes': 4.5.0(@tsparticles/engine@4.5.0)
'@tsparticles/updater-paint': 4.5.0(@tsparticles/engine@4.5.0)
'@tsparticles/updater-size': 4.5.0(@tsparticles/engine@4.5.0)
'@tsparticles/canvas-utils@4.4.0(@tsparticles/engine@4.4.0)':
'@tsparticles/canvas-utils@4.5.0(@tsparticles/engine@4.5.0)':
dependencies:
'@tsparticles/engine': 4.4.0
'@tsparticles/engine': 4.5.0
'@tsparticles/engine@4.4.0': {}
'@tsparticles/engine@4.5.0': {}
'@tsparticles/interaction-particles-links@4.4.0(@tsparticles/engine@4.4.0)(@tsparticles/plugin-interactivity@4.4.0(@tsparticles/engine@4.4.0))':
'@tsparticles/interaction-particles-links@4.5.0(@tsparticles/engine@4.5.0)(@tsparticles/plugin-interactivity@4.5.0(@tsparticles/engine@4.5.0))':
dependencies:
'@tsparticles/canvas-utils': 4.4.0(@tsparticles/engine@4.4.0)
'@tsparticles/engine': 4.4.0
'@tsparticles/plugin-interactivity': 4.4.0(@tsparticles/engine@4.4.0)
'@tsparticles/canvas-utils': 4.5.0(@tsparticles/engine@4.5.0)
'@tsparticles/engine': 4.5.0
'@tsparticles/plugin-interactivity': 4.5.0(@tsparticles/engine@4.5.0)
'@tsparticles/plugin-blend@4.4.0(@tsparticles/engine@4.4.0)':
'@tsparticles/plugin-blend@4.5.0(@tsparticles/engine@4.5.0)':
dependencies:
'@tsparticles/engine': 4.4.0
'@tsparticles/engine': 4.5.0
'@tsparticles/plugin-hex-color@4.4.0(@tsparticles/engine@4.4.0)':
'@tsparticles/plugin-hex-color@4.5.0(@tsparticles/engine@4.5.0)':
dependencies:
'@tsparticles/engine': 4.4.0
'@tsparticles/engine': 4.5.0
'@tsparticles/plugin-hsl-color@4.4.0(@tsparticles/engine@4.4.0)':
'@tsparticles/plugin-hsl-color@4.5.0(@tsparticles/engine@4.5.0)':
dependencies:
'@tsparticles/engine': 4.4.0
'@tsparticles/engine': 4.5.0
'@tsparticles/plugin-interactivity@4.4.0(@tsparticles/engine@4.4.0)':
'@tsparticles/plugin-interactivity@4.5.0(@tsparticles/engine@4.5.0)':
dependencies:
'@tsparticles/engine': 4.4.0
'@tsparticles/engine': 4.5.0
'@tsparticles/plugin-move@4.4.0(@tsparticles/engine@4.4.0)':
'@tsparticles/plugin-move@4.5.0(@tsparticles/engine@4.5.0)':
dependencies:
'@tsparticles/engine': 4.4.0
'@tsparticles/engine': 4.5.0
'@tsparticles/plugin-rgb-color@4.4.0(@tsparticles/engine@4.4.0)':
'@tsparticles/plugin-rgb-color@4.5.0(@tsparticles/engine@4.5.0)':
dependencies:
'@tsparticles/engine': 4.4.0
'@tsparticles/engine': 4.5.0
'@tsparticles/preset-links@4.4.0':
'@tsparticles/preset-links@4.5.0':
dependencies:
'@tsparticles/basic': 4.4.0
'@tsparticles/engine': 4.4.0
'@tsparticles/interaction-particles-links': 4.4.0(@tsparticles/engine@4.4.0)(@tsparticles/plugin-interactivity@4.4.0(@tsparticles/engine@4.4.0))
'@tsparticles/plugin-interactivity': 4.4.0(@tsparticles/engine@4.4.0)
'@tsparticles/basic': 4.5.0
'@tsparticles/engine': 4.5.0
'@tsparticles/interaction-particles-links': 4.5.0(@tsparticles/engine@4.5.0)(@tsparticles/plugin-interactivity@4.5.0(@tsparticles/engine@4.5.0))
'@tsparticles/plugin-interactivity': 4.5.0(@tsparticles/engine@4.5.0)
'@tsparticles/shape-circle@4.4.0(@tsparticles/engine@4.4.0)':
'@tsparticles/shape-circle@4.5.0(@tsparticles/engine@4.5.0)':
dependencies:
'@tsparticles/engine': 4.4.0
'@tsparticles/engine': 4.5.0
'@tsparticles/updater-opacity@4.4.0(@tsparticles/engine@4.4.0)':
'@tsparticles/updater-opacity@4.5.0(@tsparticles/engine@4.5.0)':
dependencies:
'@tsparticles/animation-utils': 4.4.0(@tsparticles/engine@4.4.0)
'@tsparticles/engine': 4.4.0
'@tsparticles/animation-utils': 4.5.0(@tsparticles/engine@4.5.0)
'@tsparticles/engine': 4.5.0
'@tsparticles/updater-out-modes@4.4.0(@tsparticles/engine@4.4.0)':
'@tsparticles/updater-out-modes@4.5.0(@tsparticles/engine@4.5.0)':
dependencies:
'@tsparticles/engine': 4.4.0
'@tsparticles/engine': 4.5.0
'@tsparticles/updater-paint@4.4.0(@tsparticles/engine@4.4.0)':
'@tsparticles/updater-paint@4.5.0(@tsparticles/engine@4.5.0)':
dependencies:
'@tsparticles/engine': 4.4.0
'@tsparticles/engine': 4.5.0
'@tsparticles/updater-size@4.4.0(@tsparticles/engine@4.4.0)':
'@tsparticles/updater-size@4.5.0(@tsparticles/engine@4.5.0)':
dependencies:
'@tsparticles/animation-utils': 4.4.0(@tsparticles/engine@4.4.0)
'@tsparticles/engine': 4.4.0
'@tsparticles/animation-utils': 4.5.0(@tsparticles/engine@4.5.0)
'@tsparticles/engine': 4.5.0
'@tufjs/[email protected]': {}
@@ -11279,11 +11279,11 @@ snapshots:
'@unrs/[email protected]':
optional: true
'@versatiles/[email protected]([email protected]5.0)':
'@versatiles/[email protected]([email protected]6.0)':
dependencies:
'@maplibre/maplibre-gl-style-spec': 26.4.4
optionalDependencies:
maplibre-gl: 6.15.0
maplibre-gl: 6.16.0
'@vibrant/[email protected]': {}
@@ -12190,14 +12190,14 @@ snapshots:
optionalDependencies:
wcwidth: 1.0.1
[email protected]([email protected](patch_hash=b02e30d0fb3e2b865321c17ca1234593db0d05a1365cb6c932cd47c0e673fca0))([email protected]):
[email protected]([email protected](patch_hash=4c014bd1952b4d1f21311ed2212dc87192e70b6e782597ecc7a485270247bbb5))([email protected]):
dependencies:
chart2music: 1.20.0([email protected])
echarts: 6.1.0(patch_hash=b02e30d0fb3e2b865321c17ca1234593db0d05a1365cb6c932cd47c0e673fca0)
echarts: 6.1.0(patch_hash=4c014bd1952b4d1f21311ed2212dc87192e70b6e782597ecc7a485270247bbb5)
transitivePeerDependencies:
- typescript
[email protected](patch_hash=b02e30d0fb3e2b865321c17ca1234593db0d05a1365cb6c932cd47c0e673fca0):
[email protected](patch_hash=4c014bd1952b4d1f21311ed2212dc87192e70b6e782597ecc7a485270247bbb5):
dependencies:
tslib: 2.8.1
zrender: 6.1.0
@@ -12962,7 +12962,7 @@ snapshots:
[email protected]: {}
home-assistant-js-websocket@9.7.1: {}
home-assistant-js-websocket@9.8.1: {}
[email protected]:
dependencies:
@@ -13666,7 +13666,7 @@ snapshots:
[email protected]: {}
[email protected]5.0:
[email protected]6.0:
dependencies:
'@mapbox/point-geometry': 1.1.0
'@mapbox/tiny-sdf': 2.3.0
@@ -13675,7 +13675,7 @@ snapshots:
'@maplibre/geojson-vt': 6.3.0
'@maplibre/maplibre-gl-style-spec': 26.4.4
'@maplibre/mlt': 1.3.0
'@maplibre/vt-pbf': 4.3.2
'@maplibre/vt-pbf': 4.3.3
'@types/geojson': 7946.0.16
bidi-js: 1.1.0
earcut: 3.2.4
@@ -13689,7 +13689,7 @@ snapshots:
[email protected]: {}
[email protected].1: {}
[email protected].2: {}
[email protected]: {}
@@ -15770,7 +15770,7 @@ snapshots:
[email protected]: {}
[email protected]6:
[email protected]7:
dependencies:
commander: 2.20.3
cssfilter: 0.0.10
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260930.0"
version = "20261028.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+10 -12
View File
@@ -4,8 +4,8 @@ import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-icon-next";
import "../components/ha-list";
import "../components/ha-list-item";
import "../components/item/ha-list-item-button";
import "../components/list/ha-list-base";
import type { AuthProvider } from "../data/auth";
declare global {
@@ -32,21 +32,19 @@ export class HaPickAuthProvider extends LitElement {
>${this._localize("ui.panel.page-authorize.pick_auth_provider")}</span
>
</h3>
<ha-list>
<ha-list-base>
${this.authProviders.map(
(provider) => html`
<ha-list-item
hasMeta
role="button"
<ha-list-item-button
.auth_provider=${provider}
@click=${this._handlePick}
>
${provider.name}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
<span slot="headline">${provider.name}</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`
)}
</ha-list>
</ha-list-base>
`;
}
@@ -80,9 +78,9 @@ export class HaPickAuthProvider extends LitElement {
background: var(--card-background-color);
padding: 0 15px;
}
ha-list {
ha-list-base {
margin: 16px -16px 0;
--mdc-list-side-padding: 24px;
--ha-row-item-padding-inline: 24px;
}
`;
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Return a copy of the list with the value in it, without adding duplicates
* @param values - The current list
* @param value - The value to add
*/
export const arrayWith = <T>(
values: readonly T[] | undefined,
value: T
): T[] => (values?.includes(value) ? [...values] : [...(values ?? []), value]);
/**
* Return a copy of the list without the value
* @param values - The current list
* @param value - The value to remove
*/
export const arrayWithout = <T>(
values: readonly T[] | undefined,
value: T
): T[] => (values ?? []).filter((v) => v !== value);
+34
View File
@@ -0,0 +1,34 @@
import type { VisibilityCondition } from "../../panels/lovelace/common/validate-condition";
import { ensureArray } from "../array/ensure-array";
// YAML dashboards can hold entries like `- ` (null) or a bare string.
const isConditionObject = (c: unknown): c is VisibilityCondition =>
typeof c === "object" && c !== null;
/**
* Drop entries that are not condition objects, at any depth. Returns the
* same array when every entry is valid.
*/
export const dropInvalidConditions = (
conditions: VisibilityCondition[]
): VisibilityCondition[] => {
let changed = false;
const valid: VisibilityCondition[] = [];
for (const c of conditions) {
if (!isConditionObject(c)) {
changed = true;
continue;
}
if ("conditions" in c && c.conditions != null) {
const children = ensureArray(c.conditions);
const validChildren = dropInvalidConditions(children);
if (validChildren !== children) {
changed = true;
valid.push({ ...c, conditions: validChildren } as VisibilityCondition);
continue;
}
}
valid.push(c);
}
return changed ? valid : conditions;
};
@@ -0,0 +1,128 @@
import type { ReactiveControllerHost } from "lit";
import { ref } from "lit/directives/ref";
const MAX_ROWS = 2;
// How many items fit in maxRows of a wrapping row, leaving room for the
// "more" item after them when not all of them fit.
export const fitInRows = (
widths: number[],
moreWidth: number,
rowWidth: number,
gap: number,
maxRows: number
): number => {
const fits = (end: number, width: number) =>
end === 0 || end + gap + width <= rowWidth;
let rows = 1;
let end = 0;
let fit = 0;
for (const [index, width] of widths.entries()) {
if (rows < maxRows || fits(end, moreWidth)) {
fit = index;
}
if (fits(end, width)) {
end += (end === 0 ? 0 : gap) + width;
} else {
rows++;
end = width;
}
if (rows > maxRows) {
return fit;
}
}
return widths.length;
};
// Fits the items of a wrapping legend list into two rows. The list keeps its
// "more" item rendered with the legend-more class, and items past the fit
// stay rendered out of the flow, so both can always be measured.
export class LegendFitController {
// Items to show while collapsed, undefined until the list is measured
public fit?: number;
private _host: ReactiveControllerHost;
private _element?: Element;
private _more?: Element;
private _widths: number[] = [];
private _rowWidth?: number;
private _resizeObserver?: ResizeObserver;
constructor(host: ReactiveControllerHost) {
this._host = host;
}
public target() {
return ref(this._setElement);
}
// Fits again from the current widths, for when the items change
public refit() {
this._widths = [];
this._fit();
}
private _setElement = (element?: Element) => {
if (element === this._element) {
return;
}
this._resizeObserver?.disconnect();
this._element = element;
this._more = undefined;
if (element) {
this._resizeObserver ??= new ResizeObserver(() => this._fit());
this._resizeObserver.observe(element);
}
};
private _fit() {
const list = this._element;
const rowWidth = list?.getBoundingClientRect().width;
if (!list || !rowWidth) {
return;
}
const children = Array.from(list.children);
const more = children.find((child) =>
child.classList.contains("legend-more")
);
// Switching between More and Less resizes it, not always the list
if (more !== this._more) {
if (this._more) {
this._resizeObserver?.unobserve(this._more);
}
if (more) {
this._resizeObserver?.observe(more);
}
this._more = more;
}
const widths = children
.filter((child) => child !== more)
.map((child) => child.getBoundingClientRect().width);
// Values resize the items on every state update. Fitting each item at its
// widest since the last resize keeps the more button from flickering.
if (rowWidth !== this._rowWidth || widths.length !== this._widths.length) {
this._rowWidth = rowWidth;
this._widths = widths;
} else {
this._widths = widths.map((width, index) =>
Math.max(width, this._widths[index])
);
}
const fit = fitInRows(
this._widths,
more?.getBoundingClientRect().width ?? 0,
rowWidth,
parseFloat(getComputedStyle(list).columnGap) || 0,
MAX_ROWS
);
if (fit !== this.fit) {
this.fit = fit;
this._host.requestUpdate();
}
}
}
@@ -26,8 +26,10 @@ export interface QueryParamsControllerOptions {
* Called when the page of the host is navigated to while the host is
* connected, like a second link to an open page or coming back to it. Read
* the URL with `searchParams()` and apply it, then `write()` the state back.
* Gets the `popstate` or `location-changed` event, or nothing when the host
* is connected again.
*/
navigated: () => void;
navigated: (ev?: Event) => void;
/**
* The path of the host's page, for a page that can first be connected on
* a sub-path it sends on to itself, like an add link. Defaults to the path
@@ -159,7 +161,7 @@ export class QueryParamsController<
}
// Leaving the page gets here too, before the router swaps the page
if (this._isActive()) {
this._options.navigated();
this._options.navigated(ev);
}
};
+1 -1
View File
@@ -85,7 +85,7 @@ export const parseTimeString = (timeString: string, timezone: string): Date => {
* @returns true if current time matches the condition
*/
export const checkTimeInRange = (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "locale" | "config">,
{ after, before, weekdays }: Omit<TimeCondition, "condition">
): boolean => {
const timezone =
+15 -2
View File
@@ -1,5 +1,7 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { computeCssColor } from "../color/compute-color";
import { computeDomain } from "./compute_domain";
import { stateColorCss } from "./state_color";
export const ALERT_DOMAINS = [
"alarm_control_panel",
@@ -8,8 +10,6 @@ export const ALERT_DOMAINS = [
"lock",
];
export const DEFAULT_ALERT_COLOR = "red";
export const computeAlertActiveStates = (entityId: string): string[] => {
switch (computeDomain(entityId)) {
case "alarm_control_panel":
@@ -25,3 +25,16 @@ export const computeAlertActiveStates = (entityId: string): string[] => {
export const isAlertActive = (stateObj: HassEntity): boolean =>
computeAlertActiveStates(stateObj.entity_id).includes(stateObj.state);
export const computeAlertColor = (
stateObj: HassEntity,
color: string
): string | undefined => {
if (color === "none") {
return undefined;
}
if (color === "state") {
return stateColorCss(stateObj);
}
return computeCssColor(color);
};
@@ -3,7 +3,10 @@ import type {
EntityRegistryDisplayEntry,
EntityRegistryEntry,
} from "../../data/entity/entity_registry";
import type { HomeAssistant, HomeAssistantRegistries } from "../../types";
import type {
HomeAssistantInternationalization,
HomeAssistantRegistries,
} from "../../types";
import { ensureArray } from "../array/ensure-array";
import { computeRTL } from "../util/compute_rtl";
import { computeAreaName } from "./compute_area_name";
@@ -211,16 +214,20 @@ export interface EntityPickerDisplay {
secondary?: string;
}
export interface EntityPickerDisplayOptions extends HomeAssistantRegistries {
language: HomeAssistantInternationalization["language"];
translationMetadata: HomeAssistantInternationalization["translationMetadata"];
}
export const computeEntityPickerDisplay = (
hass: HomeAssistantRegistries &
Pick<HomeAssistant, "language" | "translationMetadata">,
options: EntityPickerDisplayOptions,
stateObj: HassEntity
): EntityPickerDisplay => {
const parts = computeEntityNameParts(stateObj, hass);
const parts = computeEntityNameParts(stateObj, options);
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
options.language,
options.translationMetadata.translations
);
const primary = parts.entity || parts.device || stateObj.entity_id;
+12 -13
View File
@@ -1,6 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
import { stringCompare } from "../string/compare";
import { computeDomain } from "./compute_domain";
import { computeStateDomain } from "./compute_state_domain";
@@ -248,7 +247,8 @@ const FIXED_DOMAIN_ATTRIBUTE_STATES = {
};
export const getStatesDomain = (
hass: HomeAssistant,
states: HassEntities,
language: string,
domain: string,
attribute?: string | undefined
): string[] => {
@@ -274,15 +274,13 @@ export const getStatesDomain = (
case "person":
if (!attribute) {
result.push(
...Object.entries(hass.states)
...Object.entries(states)
.filter(
([entityId]) =>
computeDomain(entityId) === "zone" && entityId !== "zone.home"
)
.map(([_entityId, stateObj]) => computeZoneTrackerState(stateObj))
.sort((zone1, zone2) =>
stringCompare(zone1, zone2, hass.locale.language)
)
.sort((zone1, zone2) => stringCompare(zone1, zone2, language))
);
}
break;
@@ -370,23 +368,24 @@ export const getValueAttribute = (
): string | undefined => DOMAIN_VALUE_ATTRIBUTES[domain]?.[optionsAttribute];
export const getStates = (
hass: HomeAssistant,
state: HassEntity,
states: HassEntities,
language: string,
stateObj: HassEntity,
attribute: string | undefined = undefined
): string[] => {
const domain = computeStateDomain(state);
const domain = computeStateDomain(stateObj);
const result: string[] = [];
// Fixed values based on a domain
result.push(...getStatesDomain(hass, domain, attribute));
result.push(...getStatesDomain(states, language, domain, attribute));
// Dynamic values based on the entities
const optionsAttribute = getOptionsAttribute(domain, attribute);
if (optionsAttribute) {
const options = state.attributes[optionsAttribute];
const options = stateObj.attributes[optionsAttribute];
// Sensors only expose their options when their device class is `enum`.
const enumSensor =
domain !== "sensor" || state.attributes.device_class === "enum";
domain !== "sensor" || stateObj.attributes.device_class === "enum";
if (enumSensor && Array.isArray(options)) {
result.push(...options);
}
+1 -1
View File
@@ -43,7 +43,7 @@ export const computeGroupEntitiesState = (states: HassEntity[]): string => {
};
export const toggleGroupEntities = (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "callService">,
states: HassEntity[]
) => {
if (!states.length) {
+1 -1
View File
@@ -28,7 +28,7 @@ const RASTER_TILE_URL = `${MAP_TILES_PATH}/raster/{z}/{x}/{y}.png?token={token}`
// policy strips again unless the tiles ask for it back.
const DEMO_RASTER_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const OSM_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
'<a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
// Browsers keep about 16 live WebGL contexts and drop the oldest, which a
// dashboard full of map cards hits. A transient loss is restored, hence a grace.
+22
View File
@@ -5,6 +5,7 @@ import type {
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
import type { FrontendLocaleData } from "../../data/translation";
import { NumberFormat } from "../../data/translation";
import { blankBeforeUnit } from "../translations/blank_before_unit";
/**
* Returns true if the entity is considered numeric based on the attributes it has
@@ -71,6 +72,27 @@ export const formatNumber = (
.map((part) => part.value)
.join("");
/**
* Formats a number like `formatNumber` and appends the unit, separated by the spacing the unit and locale expect.
*
* @param num The number to format
* @param unit The unit to append, nothing is appended when empty
* @param localeOptions The user-selected language and formatting, from `hass.locale`
* @param options Intl.NumberFormatOptions to use
*/
export const formatNumberWithUnit = (
num: string | number,
unit: string | null | undefined,
localeOptions?: FrontendLocaleData,
options?: Intl.NumberFormatOptions
): string => {
const formatted = formatNumber(num, localeOptions, options);
if (!unit) {
return formatted;
}
return `${formatted}${blankBeforeUnit(unit, localeOptions)}${unit}`;
};
/**
* Returns an array of objects containing the formatted number in parts
* Similar to Intl.NumberFormat.prototype.formatToParts()
@@ -11,7 +11,7 @@ export const blankBeforePercent = (
case "fr":
case "sk":
case "sv":
return " ";
return "\u00A0";
default:
return "";
}
+1 -1
View File
@@ -11,5 +11,5 @@ export const blankBeforeUnit = (
if (localeOptions && unit === "%") {
return blankBeforePercent(localeOptions);
}
return " ";
return "\u00A0";
};
@@ -36,7 +36,9 @@ export const timeCachePromiseFunc = async <T, H = HomeAssistant>(
return result.result;
}
anyHass[cacheKey] = undefined;
if (anyHass[cacheKey] === result) {
anyHass[cacheKey] = undefined;
}
return timeCachePromiseFunc(
cacheKey,
cacheTime,
@@ -73,7 +75,9 @@ export const timeCachePromiseFunc = async <T, H = HomeAssistant>(
},
// On failure, clear cache right away
() => {
anyHass[cacheKey] = undefined;
if (anyHass[cacheKey] === cachePromise) {
anyHass[cacheKey] = undefined;
}
}
);
+63 -20
View File
@@ -30,6 +30,7 @@ import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import type { HassConfig } from "home-assistant-js-websocket";
import { LegendFitController } from "../../common/controllers/legend-fit-controller";
import { consume } from "../../common/decorators/consume";
import { ensureArray } from "../../common/array/ensure-array";
import { getAllGraphColors } from "../../common/color/colors";
@@ -71,8 +72,6 @@ import { downSampleLineData } from "./down-sample";
import { wrapLitTooltipFormatter } from "./lit-tooltip-formatter";
export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
const LEGEND_OVERFLOW_LIMIT = 10;
const LEGEND_OVERFLOW_LIMIT_MOBILE = 6;
const DOUBLE_TAP_TIME = 300;
// echarts' own default, restored when switching back from touch input
const DEFAULT_TOOLTIP_TRIGGER_ON = "mousemove|click|mousewheel";
@@ -90,6 +89,8 @@ export const DEFAULT_CHART_WIDTH = 500;
// is short enough for a whole screenful; on a desktop that would cover the page.
const VISIBILITY_ROOT_MARGIN_NARROW = "100%";
const VISIBILITY_ROOT_MARGIN = "300px";
export type ZoomSliderMode = "auto" | "always" | "never";
const DEFERRED_PROPS = [
"options",
"data",
@@ -164,9 +165,10 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
@property({ attribute: "small-controls", type: Boolean })
public smallControls?: boolean;
// For charts zoomed from outside, like the synced History charts
@property({ attribute: "hide-zoom-controls", type: Boolean })
public hideZoomControls = false;
// "auto" shows the zoom slider once zoomed. "never" hides every zoom
// control, for charts zoomed from outside like the synced History charts.
@property({ attribute: "zoom-slider" })
public zoomSlider: ZoomSliderMode = "auto";
// For charts sized to their content, like timelines, which grow to fit the
// zoom slider instead of giving up plot height
@@ -292,6 +294,12 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
private _legendItemCount?: number;
private _legendFit = new LegendFitController(this);
private _renderedLegendFit?: number;
private _legendItemsKey?: string;
// Zoom window in percent
@state() private _zoomRange: [number, number] = [0, 100];
@@ -522,7 +530,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
}
}
protected updated() {
protected updated(changedProps: PropertyValues) {
const room = this._showZoomSlider();
if (this.chart && room !== this._zoomSliderRoom) {
this._zoomSliderRoom = room;
@@ -538,6 +546,24 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
this.chart.setOption({ grid: this._getGridOption() });
}
}
if (this.chart && this._legendFit.fit !== this._renderedLegendFit) {
// Within a fixed height the host does not resize when the legend does.
this._shouldResizeChart = true;
this._resizeChartIfNeeded();
}
this._renderedLegendFit = this._legendFit.fit;
if (changedProps.has("options") || changedProps.has("data")) {
// New values keep the widest widths, but other items are fitted afresh
const legendItemsKey = JSON.stringify(
this._getLegendItems()?.map((item) =>
typeof item === "string" ? item : [item.id, item.name]
)
);
if (legendItemsKey !== this._legendItemsKey) {
this._legendItemsKey = legendItemsKey;
this._legendFit.refit();
}
}
}
protected render() {
@@ -594,7 +620,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
>
${
(this._isZoomed || this._isGraphRoamed) &&
!this.hideZoomControls &&
this.zoomSlider !== "never" &&
!showZoomSlider
? html`<ha-icon-button
class="zoom-reset"
@@ -651,20 +677,15 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
}
}
const overflowLimit = this._isMobileSize
? LEGEND_OVERFLOW_LIMIT_MOBILE
: LEGEND_OVERFLOW_LIMIT;
const fit = this._legendFit.fit ?? items.length;
return html`<div
class=${classMap({
"chart-legend": true,
"multiple-items": items.length > 1,
})}
>
<ul>
<ul ${this._legendFit.target()}>
${items.map((item, index) => {
if (!this.expandLegend && index >= overflowLimit) {
return nothing;
}
let itemStyle: Record<string, any> = {};
let value = "";
let noLabelClick = false;
@@ -693,7 +714,10 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
@pointerleave=${this._legendPointerCancel}
@pointercancel=${this._legendPointerCancel}
@contextmenu=${this._legendContextMenu}
class=${classMap({ hidden: this._hiddenDatasets.has(id) })}
class=${classMap({
hidden: this._hiddenDatasets.has(id),
overflow: !this.expandLegend && index >= fit,
})}
>
<button
type="button"
@@ -729,8 +753,13 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
</li>`;
})}
${
items.length > overflowLimit
? html`<li>
items.length
? html`<li
class=${classMap({
"legend-more": true,
overflow: fit >= items.length,
})}
>
<ha-assist-chip
@click=${this._toggleExpandedLegend}
filled
@@ -741,7 +770,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
)
: `${this._i18n.localize(
"ui.components.history_charts.expand_legend"
)} (${items.length - overflowLimit})`
)} (${items.length - fit})`
}
>
<ha-svg-icon
@@ -943,6 +972,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
if (zoomStart !== 0 || zoomEnd !== 100) {
this.zoom(zoomStart, zoomEnd, true);
}
fireEvent(this, "chart-setup");
} finally {
this._loading = false;
this._applyDeferredWork();
@@ -1037,9 +1067,12 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
private _showZoomSlider() {
const [start, end] = this._zoomRange;
return (
!this.hideZoomControls &&
this.zoomSlider !== "never" &&
this._hasZoomableXAxis() &&
(this._zoomSliderHeld || start !== 0 || end !== 100)
(this.zoomSlider === "always" ||
this._zoomSliderHeld ||
start !== 0 ||
end !== 100)
);
}
@@ -1270,6 +1303,10 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
},
bar: { itemStyle: { barBorderWidth: 1.5 } },
graph: {
itemStyle: {
borderColor: style.getPropertyValue("--primary-text-color"),
borderWidth: 0,
},
label: {
color: style.getPropertyValue("--primary-text-color"),
textBorderColor,
@@ -2166,6 +2203,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
color: var(--primary-text-color);
}
.chart-legend ul {
position: relative;
margin: 0;
padding: 0;
display: flex;
@@ -2188,6 +2226,10 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
.chart-legend.multiple-items li:has(.value) {
max-width: 300px;
}
.chart-legend li.overflow {
position: absolute;
visibility: hidden;
}
.chart-legend .hidden {
color: var(--secondary-text-color);
}
@@ -2287,5 +2329,6 @@ declare global {
end: number;
};
"chart-sankeyroam": { zoom: number };
"chart-setup": undefined;
}
}
+141 -21
View File
@@ -69,6 +69,13 @@ export interface NetworkData {
const PHYSICS_DISABLE_THRESHOLD = 512;
const SELECTED_NODE_MAX_ZOOM = 2;
// Keeps neighbors clear of the search field, buttons and legend
const SELECTED_NODE_FIT_MARGIN = 72;
const FOLLOW_ROAM = "follow-selected-node";
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports
let GraphChart: typeof import("echarts/lib/chart/graph/install");
@@ -92,6 +99,12 @@ export class HaNetworkGraph extends LitElement {
@property({ attribute: false }) public searchFilter = "";
/**
* Id of a node to highlight. The view centers on it, zoomed so its neighbors
* stay visible, and follows it until the user moves the view.
*/
@property({ attribute: false }) public selectedNode?: string;
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@state() private _highlightedNodes?: Set<string>;
@@ -106,6 +119,10 @@ export class HaNetworkGraph extends LitElement {
private _nodePositions: Record<string, { x: number; y: number }> = {};
private _followSelectedNode = false;
private _followedChart?: EChartsType;
@query("ha-chart-base") private _baseChart?: HaChartBase;
constructor() {
@@ -145,11 +162,13 @@ export class HaNetworkGraph extends LitElement {
this._reducedMotion,
this._showLabels,
isMobile,
hasHighlightedNodes
hasHighlightedNodes,
this.selectedNode
)}
.options=${this._createOptions(this.data?.categories)}
height="100%"
.extraComponents=${[GraphChart]}
@chart-setup=${this._updateFollowing}
>
<slot name="search" slot="search"></slot>
<slot name="button" slot="button"></slot>
@@ -212,6 +231,10 @@ export class HaNetworkGraph extends LitElement {
this._applyHighlighting();
this._updateMouseoverHandler();
}
if (changedProperties.has("selectedNode")) {
this._followSelectedNode = !!this.selectedNode;
this._updateFollowing();
}
}
private _nodeMatchesFilter(node: NetworkNode, lowerFilter: string): boolean {
@@ -242,7 +265,8 @@ export class HaNetworkGraph extends LitElement {
reducedMotion: boolean,
showLabels: boolean,
isMobile: boolean,
hasHighlightedNodes?: boolean
hasHighlightedNodes?: boolean,
selectedNode?: string
) => ({
id: "network",
type: "graph",
@@ -285,7 +309,12 @@ export class HaNetworkGraph extends LitElement {
},
edgeSymbol: ["none", "arrow"],
edgeSymbolSize: 10,
data: this._getSeriesData(data.nodes, data.links, this._nodePositions),
data: this._getSeriesData(
data.nodes,
data.links,
this._nodePositions,
selectedNode
),
links: data.links.map((link) => ({
...link,
value: link.reverseValue
@@ -303,25 +332,28 @@ export class HaNetworkGraph extends LitElement {
(
nodes: NetworkNode[],
links: NetworkLink[],
nodePositions: Record<string, { x: number; y: number }>
nodePositions: Record<string, { x: number; y: number }>,
selectedNode?: string
) =>
this._getPositionedNodes(nodes, links, nodePositions).map(
(node) =>
({
id: node.id,
name: node.name,
context: node.context,
category: node.category,
value: node.value,
symbolSize: node.symbolSize || 30,
symbol: node.symbol || "circle",
itemStyle: node.itemStyle || {},
label: node.label,
fixed: node.fixed,
x: node.x,
y: node.y,
}) as NonNullable<GraphSeriesOption["data"]>[number]
),
this._getPositionedNodes(nodes, links, nodePositions).map((node) => {
const selected = node.id === selectedNode;
return {
id: node.id,
name: node.name,
context: node.context,
category: node.category,
value: node.value,
symbolSize: node.symbolSize || 30,
symbol: node.symbol || "circle",
itemStyle: selected
? { ...node.itemStyle, borderWidth: 3 }
: node.itemStyle || {},
label: selected ? { ...node.label, fontWeight: "bold" } : node.label,
fixed: node.fixed,
x: node.x,
y: node.y,
} as NonNullable<GraphSeriesOption["data"]>[number];
}),
deepEqual
);
@@ -477,6 +509,94 @@ export class HaNetworkGraph extends LitElement {
}
}
private _updateFollowing() {
const chart = this._followSelectedNode ? this._baseChart?.chart : undefined;
if (chart !== this._followedChart) {
const previous = this._followedChart;
if (previous && !previous.isDisposed()) {
previous.off("rendered", this._centerOnSelectedNode);
previous.off("graphroam", this._handleGraphRoam);
previous.getZr().off("drag", this._stopFollowing);
}
chart?.on("rendered", this._centerOnSelectedNode);
chart?.on("graphroam", this._handleGraphRoam);
chart?.getZr().on("drag", this._stopFollowing);
this._followedChart = chart;
}
this._centerOnSelectedNode();
}
private _stopFollowing = () => {
this._followSelectedNode = false;
this._updateFollowing();
};
// A roam dispatched while ECharts renders is queued, so a flag set around
// dispatchAction would be cleared before the event fires
private _handleGraphRoam = (ev: unknown) => {
if ((ev as { from?: string }).from !== FOLLOW_ROAM) {
this._stopFollowing();
}
};
private _centerOnSelectedNode = () => {
const chart = this._followedChart;
if (!chart) {
return;
}
const series: any = chart
// @ts-ignore private method but no other way to get the graph positions
.getModel()
.getSeriesByIndex(0);
const node = series?.getGraph().getNodeById(this.selectedNode);
const position: number[] | undefined = node?.getLayout();
if (!position) {
return;
}
const toPixel = (point: number[]) =>
chart.convertToPixel({ seriesId: "network" }, point);
const [x, y] = toPixel(position);
const zoom = series.coordinateSystem.getRoamTransform()[0];
const centerX = chart.getWidth() / 2;
const centerY = chart.getHeight() / 2;
// Zoom so the neighbors stay in view
const fitX = Math.max(centerX - SELECTED_NODE_FIT_MARGIN, centerX / 2);
const fitY = Math.max(centerY - SELECTED_NODE_FIT_MARGIN, centerY / 2);
let targetZoom = SELECTED_NODE_MAX_ZOOM;
for (const edge of node.edges) {
const neighbor: number[] | undefined = (
edge.node1 === node ? edge.node2 : edge.node1
).getLayout();
if (neighbor) {
const [neighborX, neighborY] = toPixel(neighbor);
targetZoom = Math.min(
targetZoom,
(zoom * fitX) / Math.abs(neighborX - x),
(zoom * fitY) / Math.abs(neighborY - y)
);
}
}
const zoomChanged = Math.abs(zoom - targetZoom) >= 1e-3;
if (
!zoomChanged &&
Math.abs(centerX - x) < 0.5 &&
Math.abs(centerY - y) < 0.5
) {
return;
}
chart.dispatchAction({
type: "graphRoam",
from: FOLLOW_ROAM,
seriesId: "network",
dx: centerX - x,
dy: centerY - y,
// Any zoom, even 1, rescales every node and edge
zoom: zoomChanged ? targetZoom / zoom : undefined,
originX: centerX,
originY: centerY,
});
};
public disconnectedCallback(): void {
super.disconnectedCallback();
if (this._emphasisGuardHandler) {
@@ -1,9 +1,10 @@
import type { LineSeriesOption } from "echarts/charts";
import type { VisualMapComponentOption } from "echarts/components";
import type { HassEntities } from "home-assistant-js-websocket";
import { getGraphColorByIndex } from "../../common/color/colors";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import type { LineChartEntity, LineChartState } from "../../data/history";
import type { HomeAssistant } from "../../types";
import type { LocalizeFunc } from "../../common/translations/localize";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
const safeParseFloat = (value) => {
@@ -19,7 +20,8 @@ export const CLIMATE_MODE_CONFIGS = [
] as const;
export interface StateHistoryChartLineDataParams {
hass: HomeAssistant;
localize: LocalizeFunc;
states: HassEntities;
data: LineChartEntity[];
endTime: Date;
names?: Record<string, string>;
@@ -40,13 +42,13 @@ export interface StateHistoryChartLineData {
/**
* Transforms processed history (`LineChartEntity[]`) into ECharts series for
* `state-history-chart-line`. Pure data processing: all environment inputs
* (current time, theme style, hass) are injected so the transform is
* (current time, theme style, localize, states) are injected so the transform is
* deterministic and benchmarkable.
*/
export function generateStateHistoryChartLineData(
params: StateHistoryChartLineDataParams
): StateHistoryChartLineData | undefined {
const { hass, computedStyles, endTime } = params;
const { localize, states: currentStates, computedStyles, endTime } = params;
// Work with numeric epoch timestamps (ms) instead of Date objects below.
// Charts can hold a huge number of points, and allocating a Date per point
// is needless GC pressure; the "time" axis consumes numbers natively.
@@ -175,10 +177,10 @@ export function generateStateHistoryChartLineData(
addDataSet(
states.entity_id + "-current_temperature",
params.showNames
? hass.localize("ui.card.climate.current_temperature", {
? localize("ui.card.climate.current_temperature", {
name: name,
})
: hass.localize(
: localize(
"component.climate.entity_component._.state_attributes.current_temperature.name"
)
);
@@ -186,10 +188,10 @@ export function generateStateHistoryChartLineData(
addDataSet(
`${states.entity_id}-${action}`,
params.showNames
? hass.localize(`ui.card.climate.${action}`, {
? localize(`ui.card.climate.${action}`, {
name: name,
})
: hass.localize(
: localize(
`component.climate.entity_component._.state_attributes.hvac_action.state.${action}`
),
computedStyles.getPropertyValue(cssVar),
@@ -201,22 +203,22 @@ export function generateStateHistoryChartLineData(
addDataSet(
states.entity_id + "-target_temperature_mode",
params.showNames
? hass.localize("ui.card.climate.target_temperature_mode", {
? localize("ui.card.climate.target_temperature_mode", {
name: name,
mode: hass.localize("ui.card.climate.high"),
mode: localize("ui.card.climate.high"),
})
: hass.localize(
: localize(
"component.climate.entity_component._.state_attributes.target_temp_high.name"
)
);
addDataSet(
states.entity_id + "-target_temperature_mode_low",
params.showNames
? hass.localize("ui.card.climate.target_temperature_mode", {
? localize("ui.card.climate.target_temperature_mode", {
name: name,
mode: hass.localize("ui.card.climate.low"),
mode: localize("ui.card.climate.low"),
})
: hass.localize(
: localize(
"component.climate.entity_component._.state_attributes.target_temp_low.name"
)
);
@@ -224,10 +226,10 @@ export function generateStateHistoryChartLineData(
addDataSet(
states.entity_id + "-target_temperature",
params.showNames
? hass.localize("ui.card.climate.target_temperature_entity", {
? localize("ui.card.climate.target_temperature_entity", {
name: name,
})
: hass.localize(
: localize(
"component.climate.entity_component._.state_attributes.temperature.name"
)
);
@@ -281,10 +283,10 @@ export function generateStateHistoryChartLineData(
addDataSet(
states.entity_id + "-target_humidity",
params.showNames
? hass.localize("ui.card.humidifier.target_humidity_entity", {
? localize("ui.card.humidifier.target_humidity_entity", {
name: name,
})
: hass.localize(
: localize(
"component.humidifier.entity_component._.state_attributes.humidity.name"
)
);
@@ -293,10 +295,10 @@ export function generateStateHistoryChartLineData(
addDataSet(
states.entity_id + "-current_humidity",
params.showNames
? hass.localize("ui.card.humidifier.current_humidity_entity", {
? localize("ui.card.humidifier.current_humidity_entity", {
name: name,
})
: hass.localize(
: localize(
"component.humidifier.entity_component._.state_attributes.current_humidity.name"
)
);
@@ -308,10 +310,10 @@ export function generateStateHistoryChartLineData(
addDataSet(
states.entity_id + "-humidifying",
params.showNames
? hass.localize("ui.card.humidifier.humidifying", {
? localize("ui.card.humidifier.humidifying", {
name: name,
})
: hass.localize(
: localize(
"component.humidifier.entity_component._.state_attributes.action.state.humidifying"
),
computedStyles.getPropertyValue("--state-humidifier-on-color"),
@@ -321,10 +323,10 @@ export function generateStateHistoryChartLineData(
addDataSet(
states.entity_id + "-drying",
params.showNames
? hass.localize("ui.card.humidifier.drying", {
? localize("ui.card.humidifier.drying", {
name: name,
})
: hass.localize(
: localize(
"component.humidifier.entity_component._.state_attributes.action.state.drying"
),
computedStyles.getPropertyValue("--state-humidifier-on-color"),
@@ -334,10 +336,10 @@ export function generateStateHistoryChartLineData(
addDataSet(
states.entity_id + "-on",
params.showNames
? hass.localize("ui.card.humidifier.on_entity", {
? localize("ui.card.humidifier.on_entity", {
name: name,
})
: hass.localize("component.humidifier.entity_component._.state.on"),
: localize("component.humidifier.entity_component._.state.on"),
undefined,
true
);
@@ -445,7 +447,7 @@ export function generateStateHistoryChartLineData(
// allow 1s of leeway for "now"
const isUpToNow = nowMs - endTimeMs <= 1000;
if (domain === "sensor" && isUpToNow && data.length === 1) {
const stateObj = hass.states[states.entity_id];
const stateObj = currentStates[states.entity_id];
const currentValue = stateObj ? safeParseFloat(stateObj.state) : null;
if (currentValue !== null) {
data[0].data!.push([Math.max(nowMs, endTimeMs), currentValue]);
@@ -1,3 +1,4 @@
import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -5,11 +6,25 @@ import type { VisualMapComponentOption } from "echarts/components";
import type { LineSeriesOption } from "echarts/charts";
import type { YAXisOption } from "echarts/types/dist/shared";
import { styleMap } from "lit/directives/style-map";
import { consume } from "../../common/decorators/consume";
import { transform } from "../../common/decorators/transform";
import { computeRTL } from "../../common/util/compute_rtl";
import {
configContext,
entitiesContext,
formattersContext,
internationalizationContext,
statesContext,
} from "../../data/context";
import type { LineChartEntity } from "../../data/history";
import type { HomeAssistant } from "../../types";
import type {
HomeAssistant,
HomeAssistantConfig,
HomeAssistantFormatters,
HomeAssistantInternationalization,
} from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import type { ZoomSliderMode } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import {
@@ -22,11 +37,11 @@ import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_tim
import {
getNumberFormatOptions,
formatNumber,
formatNumberWithUnit,
} from "../../common/number/format_number";
import { measureTextWidth } from "../../util/text";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import { computeAttributeValueDisplay } from "../../common/entity/compute_attribute_display";
// Minimum width reserved for the Y-axis labels; also the value _yWidth is
@@ -50,8 +65,6 @@ const ENTITY_DATASET_SUFFIXES = [
@customElement("state-history-chart-line")
export class StateHistoryChartLine extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public data: LineChartEntity[] = [];
@property({ attribute: false }) public names?: Record<string, string>;
@@ -93,8 +106,8 @@ export class StateHistoryChartLine extends LitElement {
@property({ attribute: "expand-legend", type: Boolean })
public expandLegend?: boolean;
@property({ attribute: "hide-zoom-controls", type: Boolean })
public hideZoomControls = false;
@property({ attribute: "zoom-slider" })
public zoomSlider: ZoomSliderMode = "auto";
@state() private _chartData: LineSeriesOption[] = [];
@@ -114,6 +127,28 @@ export class StateHistoryChartLine extends LitElement {
private _yAxisFractionDigits = 1;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: HomeAssistantInternationalization;
@state()
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
private _hassConfig!: HassConfig;
@consume({ context: formattersContext, subscribe: true })
private _formatters!: HomeAssistantFormatters;
@consume({ context: entitiesContext, subscribe: true })
private _entities!: HomeAssistant["entities"];
@consume({ context: statesContext, subscribe: true })
private _states?: HassEntities;
private _optionsEntityStates: Record<string, string | undefined> = {};
protected render() {
return html`
<ha-chart-base
@@ -125,7 +160,7 @@ export class StateHistoryChartLine extends LitElement {
@dataset-unhidden=${this._datasetUnhidden}
@chart-zoom=${this._handleDataZoom}
.expandLegend=${this.expandLegend}
.hideZoomControls=${this.hideZoomControls}
.zoomSlider=${this.zoomSlider}
.clickLabelForMoreInfo=${this.clickForMoreInfo}
@legend-label-click=${this._handleLegendLabelClick}
></ha-chart-base>
@@ -136,8 +171,8 @@ export class StateHistoryChartLine extends LitElement {
const time = params[0].axisValue;
const title = formatDateTimeWithSeconds(
new Date(time),
this.hass.locale,
this.hass.config
this._i18n.locale,
this._hassConfig
);
const datapoints: Record<string, any>[] = [];
// Index the hovered points by series so the per-dataset lookup below is
@@ -178,22 +213,20 @@ export class StateHistoryChartLine extends LitElement {
color: dataset.color,
});
});
const unit = this.unit
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
: "";
return html`${title}${datapoints.map((param) => {
const entityId = this._entityIds[param.seriesIndex];
const stateObj = this.hass.states[entityId];
const entry = this.hass.entities[entityId];
const stateObj = this._states?.[entityId];
const entry = this._entities?.[entityId];
const stateValue = String(param.value[1]);
const value = stateObj
? this.hass.formatEntityState(stateObj, stateValue)
: `${formatNumber(
? this._formatters.formatEntityState(stateObj, stateValue)
: formatNumberWithUnit(
stateValue,
this.hass.locale,
this.unit,
this._i18n.locale,
getNumberFormatOptions(undefined, entry)
)}${unit}`;
);
const dataIndex = this._datasetToDataIndex[param.seriesIndex];
const data = this.data[dataIndex];
let statSuffix: TemplateResult | typeof nothing = nothing;
@@ -201,8 +234,10 @@ export class StateHistoryChartLine extends LitElement {
const source =
data.states.length === 0 ||
param.value[0] < data.states[0].last_changed
? this.hass.localize("ui.components.history_charts.source_stats")
: this.hass.localize("ui.components.history_charts.source_history");
? this._i18n.localize("ui.components.history_charts.source_stats")
: this._i18n.localize(
"ui.components.history_charts.source_history"
);
// Five non-breaking spaces indent the source label.
statSuffix = html`<br />${"\u00a0".repeat(5)}${source}`;
}
@@ -243,7 +278,7 @@ export class StateHistoryChartLine extends LitElement {
) {
const id = ev.detail.id;
let entityId = id;
if (!this.hass.states[entityId]) {
if (!this._states?.[entityId]) {
for (const suffix of ENTITY_DATASET_SUFFIXES) {
if (id.endsWith(suffix)) {
entityId = id.slice(0, -suffix.length);
@@ -251,7 +286,7 @@ export class StateHistoryChartLine extends LitElement {
}
}
}
if (this.hass.states[entityId]) {
if (this._states?.[entityId]) {
fireEvent(this, "hass-more-info", { entityId });
}
}
@@ -282,12 +317,19 @@ export class StateHistoryChartLine extends LitElement {
changedProps.has("paddingYAxis") ||
changedProps.has("_visualMap") ||
changedProps.has("_yWidth") ||
(changedProps.has("hass") &&
this._hasEntityStatesChanged(changedProps.get("hass")))
changedProps.has("_i18n") ||
changedProps.has("_hassConfig") ||
this._hasEntityStatesChanged()
) {
this._optionsEntityStates = Object.fromEntries(
this._entityIds.map((entityId) => [
entityId,
this._states?.[entityId]?.state,
])
);
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
this._i18n.language,
this._i18n.translationMetadata.translations
);
let minYAxis: number | ((values: { min: number }) => number) | undefined =
this.minYAxis;
@@ -358,7 +400,7 @@ export class StateHistoryChartLine extends LitElement {
.map((d, i) => ({ dataset: d, entityId: this._entityIds[i] }))
.filter((item) => !(item.dataset as LineSeriesOption).areaStyle)
.map((item) => {
const stateObj = this.hass.states[item.entityId];
const stateObj = this._states?.[item.entityId];
let value: string | undefined;
if (stateObj) {
@@ -384,18 +426,18 @@ export class StateHistoryChartLine extends LitElement {
}
// Use the helper to format temperature with proper unit
value = computeAttributeValueDisplay(
this.hass.localize,
this._i18n.localize,
stateObj,
this.hass.locale,
this.hass.config,
this.hass.entities,
this._i18n.locale,
this._hassConfig,
this._entities,
attribute
);
}
// Default for non-temperature datasets / missing attribute
if (value === undefined) {
value = this.hass.formatEntityState(stateObj);
value = this._formatters.formatEntityState(stateObj);
}
}
@@ -424,10 +466,10 @@ export class StateHistoryChartLine extends LitElement {
}
}
private _hasEntityStatesChanged(oldHass: HomeAssistant): boolean {
private _hasEntityStatesChanged(): boolean {
return this._entityIds.some(
(entityId) =>
this.hass.states[entityId]?.state !== oldHass.states[entityId]?.state
this._states?.[entityId]?.state !== this._optionsEntityStates[entityId]
);
}
@@ -439,7 +481,8 @@ export class StateHistoryChartLine extends LitElement {
this._chartTime = new Date();
const data = generateStateHistoryChartLineData({
hass: this.hass,
localize: this._i18n.localize,
states: this._states ?? {},
data: this.data,
endTime: this.endTime,
names: this.names,
@@ -461,7 +504,7 @@ export class StateHistoryChartLine extends LitElement {
}
private _formatYAxisLabel = (value: number) => {
const label = formatNumber(value, this.hass.locale, {
const label = formatNumber(value, this._i18n.locale, {
minimumFractionDigits: value === 0 ? 0 : this._yAxisFractionDigits,
maximumFractionDigits: this._yAxisFractionDigits,
});
@@ -1,3 +1,4 @@
import type { HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -8,10 +9,20 @@ import type {
} from "echarts/types/dist/shared";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import millisecondsToDuration from "../../common/datetime/milliseconds_to_duration";
import { consume } from "../../common/decorators/consume";
import { computeRTL } from "../../common/util/compute_rtl";
import {
configContext,
internationalizationContext,
statesContext,
} from "../../data/context";
import type { TimelineEntity } from "../../data/history";
import type { HomeAssistant } from "../../types";
import type {
HomeAssistantConfig,
HomeAssistantInternationalization,
} from "../../types";
import { DEFAULT_CHART_WIDTH, MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import type { ZoomSliderMode } from "./ha-chart-base";
import { itemTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
@@ -27,8 +38,6 @@ const GRID_BOTTOM = 30;
@customElement("state-history-chart-timeline")
export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public data: TimelineEntity[] = [];
@property({ type: Boolean }) public narrow = false;
@@ -60,8 +69,8 @@ export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: false }) public chartIndex?;
@property({ attribute: "hide-zoom-controls", type: Boolean })
public hideZoomControls = false;
@property({ attribute: "zoom-slider" })
public zoomSlider: ZoomSliderMode = "auto";
@state() private _chartData: CustomSeriesOption[] = [];
@@ -71,6 +80,16 @@ export class StateHistoryChartTimeline extends LitElement {
private _chartTime: Date = new Date();
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: HomeAssistantInternationalization;
@consume({ context: configContext, subscribe: true })
private _hassConfig!: HomeAssistantConfig;
@consume({ context: statesContext, subscribe: true })
private _states?: HassEntities;
protected render() {
return html`
<ha-chart-base
@@ -87,7 +106,7 @@ export class StateHistoryChartTimeline extends LitElement {
grow-for-zoom-slider
@chart-click=${this._handleChartClick}
@chart-zoom=${this._handleDataZoom}
.hideZoomControls=${this.hideZoomControls}
.zoomSlider=${this.zoomSlider}
></ha-chart-base>
`;
}
@@ -151,13 +170,13 @@ export class StateHistoryChartTimeline extends LitElement {
? params[0]
: params;
const durationInMs = value![2] - value![1];
const formattedDuration = `${this.hass.localize(
const formattedDuration = `${this._i18n.localize(
"ui.components.history_charts.duration"
)}: ${millisecondsToDuration(durationInMs)}`;
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
this._i18n.language,
this._i18n.translationMetadata.translations
);
return html`${
seriesName
@@ -169,12 +188,12 @@ export class StateHistoryChartTimeline extends LitElement {
></ha-chart-tooltip-marker
>${name}<br />${formatDateTimeWithSeconds(
new Date(value![1]),
this.hass.locale,
this.hass.config
this._i18n.locale,
this._hassConfig.config
)}<br />${formatDateTimeWithSeconds(
new Date(value![2]),
this.hass.locale,
this.hass.config
this._i18n.locale,
this._hassConfig.config
)}<br />${formattedDuration}`;
};
@@ -199,7 +218,8 @@ export class StateHistoryChartTimeline extends LitElement {
changedProps.has("showNames") ||
changedProps.has("insideLabels") ||
changedProps.has("paddingYAxis") ||
changedProps.has("_yWidth")
changedProps.has("_yWidth") ||
changedProps.has("_i18n")
) {
this._createOptions();
}
@@ -216,8 +236,8 @@ export class StateHistoryChartTimeline extends LitElement {
: this.paddingYAxis;
const labelMargin = 5;
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
this._i18n.language,
this._i18n.translationMetadata.translations
);
this._chartOptions = {
xAxis: {
@@ -322,7 +342,7 @@ export class StateHistoryChartTimeline extends LitElement {
private _generateData() {
this._chartTime = new Date();
this._chartData = generateStateHistoryChartTimelineData({
states: this.hass.states,
states: this._states ?? {},
data: this.data,
startTime: this.startTime,
endTime: this.endTime,
+22 -14
View File
@@ -1,4 +1,5 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { HassConfig } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import {
@@ -9,19 +10,24 @@ import {
state,
} from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { consume } from "../../common/decorators/consume";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import type {
HASSDomEvent,
HASSDomTargetEvent,
} from "../../common/dom/fire_event";
import type { LocalizeFunc } from "../../common/translations/localize";
import { configContext } from "../../data/context";
import type {
HistoryResult,
LineChartUnit,
TimelineEntity,
} from "../../data/history";
import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types";
import type { HomeAssistantConfig } from "../../types";
import "./state-history-chart-line";
import type { StateHistoryChartLine } from "./state-history-chart-line";
import "./state-history-chart-timeline";
@@ -53,8 +59,6 @@ declare global {
@customElement("state-history-charts")
export class StateHistoryCharts extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public historyData?: HistoryResult;
@property({ type: Boolean }) public narrow = false;
@@ -116,6 +120,15 @@ export class StateHistoryCharts extends LitElement {
@state() private _chartCount = 0;
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@state()
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
private _hassConfig!: HassConfig;
@queryAll("state-history-chart-line, state-history-chart-timeline")
private _chartComponents!: NodeListOf<
StateHistoryChartLine | StateHistoryChartTimeline
@@ -127,21 +140,21 @@ export class StateHistoryCharts extends LitElement {
@restoreScroll(".container") private _savedScrollPos?: number;
protected render() {
if (!isComponentLoaded(this.hass.config, "history")) {
if (!isComponentLoaded(this._hassConfig, "history")) {
return html`<div class="info">
${this.hass.localize("ui.components.history_charts.history_disabled")}
${this._localize("ui.components.history_charts.history_disabled")}
</div>`;
}
if (this.isLoadingData && !this.historyData) {
return html`<div class="info">
${this.hass.localize("ui.components.history_charts.loading_history")}
${this._localize("ui.components.history_charts.loading_history")}
</div>`;
}
if (this._isHistoryEmpty()) {
return html`<div class="info">
${this.hass.localize("ui.components.history_charts.no_history_found")}
${this._localize("ui.components.history_charts.no_history_found")}
</div>`;
}
const combinedItems = this.historyData!.timeline.length
@@ -186,7 +199,6 @@ export class StateHistoryCharts extends LitElement {
if (!Array.isArray(item)) {
return html`<div class="entry-container line">
<state-history-chart-line
.hass=${this.hass}
.unit=${item.unit}
.data=${item.data}
.identifier=${item.identifier}
@@ -206,13 +218,12 @@ export class StateHistoryCharts extends LitElement {
@chart-zoom-with-index=${this._handleTimelineSync}
.height=${this.virtualize ? undefined : this.height}
.expandLegend=${this.expandLegend}
?hide-zoom-controls=${this.syncCharts}
.zoomSlider=${this.syncCharts ? "never" : "auto"}
></state-history-chart-line>
</div> `;
}
return html`<div class="entry-container timeline">
<state-history-chart-timeline
.hass=${this.hass}
.data=${item}
.startTime=${this._computedStartTime}
.endTime=${this._computedEndTime}
@@ -226,15 +237,12 @@ export class StateHistoryCharts extends LitElement {
.clickForMoreInfo=${this.clickForMoreInfo}
@y-width-changed=${this._yWidthChanged}
@chart-zoom-with-index=${this._handleTimelineSync}
?hide-zoom-controls=${this.syncCharts}
.zoomSlider=${this.syncCharts ? "never" : "auto"}
></state-history-chart-timeline>
</div> `;
};
protected shouldUpdate(changedProps: PropertyValues): boolean {
if (changedProps.size === 1 && changedProps.has("hass")) {
return false;
}
if (
changedProps.size === 1 &&
changedProps.has("_maxYWidth") &&
+7 -5
View File
@@ -19,9 +19,9 @@ import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_tim
import { formatTimeWithSeconds } from "../../common/datetime/format_time";
import {
formatNumber,
formatNumberWithUnit,
getNumberFormatOptions,
} from "../../common/number/format_number";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import { bidiIsolate } from "../../common/bidi";
import {
apiContext,
@@ -297,9 +297,6 @@ export class StatisticsChart extends LitElement {
const rendered: Record<string, boolean> = {};
const chartIsBar = this.chartType.startsWith("bar");
const period = this.period;
const unit = this.unit
? `${blankBeforeUnit(this.unit, this._i18n.locale)}${this.unit}`
: "";
const rows: {
time?: string;
color: string;
@@ -375,7 +372,12 @@ export class StatisticsChart extends LitElement {
maximumFractionDigits: 2,
};
const value = `${formatNumber(rawValue, this._i18n.locale, options)}${unit}`;
const value = formatNumberWithUnit(
rawValue,
this.unit,
this._i18n.locale,
options
);
rows.push({
time: rows.length === 0 ? rawTime : undefined,
+131 -37
View File
@@ -124,7 +124,28 @@ export interface DataTableRowData {
export type SortableColumnContainer = Record<string, ClonedDataTableColumnData>;
// Slot for the actions at the end of a group's header, they don't toggle the
// group. Put `ha-icon-button`s with a label in it. They get the rows' icon
// colour and size by default, which `--data-table-group-actions-color`,
// `--data-table-group-actions-button-size` and
// `--data-table-group-actions-icon-size` change for all of them; style a
// button itself for its own colour or size.
export const GROUP_ACTIONS_SLOT_PREFIX = "group-actions-";
export const groupActionsSlot = (group: string) =>
`${GROUP_ACTIONS_SLOT_PREFIX}${group}`;
const UNDEFINED_GROUP_KEY = "zzzzz_undefined";
// The group a row's value of the group column puts it in. Rows without a value
// share a reserved group, sorted to the bottom.
const dataTableGroupKey = (
value: string | number | boolean | null | undefined
) => {
const key = String(value);
return key === "undefined" ? UNDEFINED_GROUP_KEY : key;
};
const AUTO_FOCUS_ALLOWED_ACTIVE_TAGS = ["BODY", "HTML", "HOME-ASSISTANT"];
// Default row height, used to fill the viewport with skeleton rows.
@@ -209,6 +230,9 @@ export class HaDataTable extends LitElement {
@query(".sticky-group-header .group-header")
private _stickyGroupHeaderCell?: HTMLElement;
@query(".sticky-group-header .group-actions")
private _stickyGroupActions?: HTMLElement;
// Group pinned to the top of the scroller while its own header is scrolled out of view
@state() private _stickyGroup?: string;
@@ -486,6 +510,14 @@ export class HaDataTable extends LitElement {
)
: [];
// The pinned group header is the same row as its group's header
const stickyGroupIndex =
this._stickyGroup === undefined
? -1
: items.findIndex(
(item) => item.append && item.groupHeader === this._stickyGroup
);
return html`
<div class="mdc-data-table">
<slot name="header" @slotchange=${this._calcTableHeight}>
@@ -726,25 +758,20 @@ export class HaDataTable extends LitElement {
</div>
`
: html`
<lit-virtualizer
scroller
class="mdc-data-table__content scroller ha-scrollbar ${classMap(
{ grouped: Boolean(this.groupColumn) && !this.autoHeight }
)}"
tabindex=${ifDefined(!this.autoHeight ? "0" : undefined)}
@scroll=${this._saveScrollPos}
@visibilityChanged=${this._handleVisibilityChanged}
.items=${items}
.keyFunction=${this._keyFunction}
.renderItem=${renderRow}
></lit-virtualizer>
${
// Rendered before the rows, so the group's actions slot
// shows here and not in its scrolled out header row
this.groupColumn &&
!this.autoHeight &&
this._stickyGroup !== undefined
? html`<div
class="mdc-data-table__row sticky-group-header"
aria-hidden="true"
role="row"
aria-rowindex=${ifDefined(
stickyGroupIndex === -1
? undefined
: stickyGroupIndex + (this.loadError ? 3 : 2)
)}
.group=${this._stickyGroup}
@click=${this._collapseStickyGroup}
>
@@ -757,6 +784,18 @@ export class HaDataTable extends LitElement {
</div>`
: nothing
}
<lit-virtualizer
scroller
class="mdc-data-table__content scroller ha-scrollbar ${classMap(
{ grouped: Boolean(this.groupColumn) && !this.autoHeight }
)}"
tabindex=${ifDefined(!this.autoHeight ? "0" : undefined)}
@scroll=${this._saveScrollPos}
@visibilityChanged=${this._handleVisibilityChanged}
.items=${items}
.keyFunction=${this._keyFunction}
.renderItem=${renderRow}
></lit-virtualizer>
`
}
</div>
@@ -1031,12 +1070,10 @@ export class HaDataTable extends LitElement {
if (groupColumn) {
const isGroupSortColumn = sortColumn === groupColumn;
const grouped = groupBy(items, (item) => item[groupColumn]);
if (grouped.undefined) {
// make sure ungrouped items are at the bottom
grouped[UNDEFINED_GROUP_KEY] = grouped.undefined;
delete grouped.undefined;
}
const grouped = groupBy(items, (item) =>
dataTableGroupKey(item[groupColumn])
);
const sortedEntries: [string, DataTableRowData[]][] = Object.keys(
grouped
)
@@ -1113,9 +1150,11 @@ export class HaDataTable extends LitElement {
localize: LocalizeFunc | undefined,
sticky = false
) {
// The sticky copy is hidden from assistive technology, so its button is
// made inert. It handles clicks on its whole row instead, as its cell
// shifts with horizontal scrolling.
// The sticky copy's button and name are hidden from assistive technology,
// and its button is made inert. It handles clicks on its whole row
// instead, as its cell shifts with horizontal scrolling. The group's
// actions show in the sticky copy while it is pinned, so they stay
// available there.
return html`<div
class="mdc-data-table__cell group-header"
role="cell"
@@ -1133,14 +1172,27 @@ export class HaDataTable extends LitElement {
?inert=${sticky}
>
</ha-icon-button>
${
groupName === UNDEFINED_GROUP_KEY
? localize?.("ui.components.data-table.ungrouped") || "Ungrouped"
: groupName || ""
}
<span
class="group-name"
aria-hidden=${ifDefined(sticky ? "true" : undefined)}
>
${
groupName === UNDEFINED_GROUP_KEY
? localize?.("ui.components.data-table.ungrouped") || "Ungrouped"
: groupName || ""
}
</span>
<div class="group-actions" @click=${this._stopGroupToggle}>
<slot name=${groupActionsSlot(groupName)}></slot>
</div>
</div>`;
}
// Actions don't toggle the group their header belongs to
private _stopGroupToggle(ev: Event) {
ev.stopPropagation();
}
private _memFilterData = memoizeOne(
(
data: DataTableRowData[],
@@ -1442,6 +1494,11 @@ export class HaDataTable extends LitElement {
if (this._stickyGroupHeaderCell) {
this._stickyGroupHeaderCell.style.transform = `translateX(${-scrollLeft}px)`;
}
// Its actions stay at the end of the visible row, like the rows' own
if (this._stickyGroupActions) {
this._stickyGroupActions.style.transform = `translateX(${scrollLeft}px)`;
}
}
@eventOptions({ passive: true })
@@ -1509,18 +1566,15 @@ export class HaDataTable extends LitElement {
}
public collapseAllGroups() {
if (
!this.groupColumn ||
!this.data.some((item) => item[this.groupColumn!])
) {
const groupColumn = this.groupColumn;
if (!groupColumn || !this.data.some((item) => item[groupColumn])) {
return;
}
const grouped = groupBy(this.data, (item) => item[this.groupColumn!]);
if (grouped.undefined) {
// undefined is a reserved group name
grouped[UNDEFINED_GROUP_KEY] = grouped.undefined;
delete grouped.undefined;
}
const grouped = groupBy(this.data, (item) =>
dataTableGroupKey(item[groupColumn])
);
this._collapsedGroups = Object.keys(grouped);
this._lastSelectedRowId = null;
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
@@ -1879,6 +1933,16 @@ export class HaDataTable extends LitElement {
display: flex;
align-items: center;
cursor: pointer;
/* Clipping would make the cell the actions' sticky scroll container,
and cut off the pinned header's actions */
overflow: visible;
}
.group-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-header ha-icon-button {
@@ -1889,6 +1953,36 @@ export class HaDataTable extends LitElement {
transform: rotate(180deg);
}
.group-actions {
display: flex;
align-items: center;
gap: var(--ha-space-2);
/* Centre in the row, not below the header's top padding */
align-self: stretch;
margin-top: -12px;
margin-inline-start: auto;
/* The rows' icon button size, inherited by the slotted ones */
--_button-size: var(--data-table-group-actions-button-size, 48px);
--ha-icon-button-size: var(--_button-size);
--mdc-icon-size: var(--data-table-group-actions-icon-size, 24px);
/* Centre the last action over the row's last cell, such as an
overflow menu, which is a 48px icon button */
padding-inline-end: calc(
var(--ha-space-4) + (48px - var(--_button-size)) / 2
);
/* Stay in view when the rows scroll horizontally */
position: sticky;
inset-inline-end: 0;
cursor: default;
/* Don't pass the group name's weight on to slotted menus */
font-weight: var(--ha-font-weight-normal);
/* Inherited by the slotted icon buttons, like the rows' icon cells */
color: var(
--data-table-group-actions-color,
var(--secondary-text-color)
);
}
.sticky-group-header {
position: absolute;
top: 0;
+21 -12
View File
@@ -1,6 +1,5 @@
import { TZDate } from "@date-fns/tz";
import type { ContextType } from "@lit/context";
import type { ActionDetail } from "@material/mwc-list";
import { mdiCalendarToday } from "@mdi/js";
import "cally";
import type { HassConfig } from "home-assistant-js-websocket/dist/types";
@@ -17,6 +16,7 @@ import {
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
HASSDomTargetEvent,
} from "../../common/dom/fire_event";
@@ -32,8 +32,8 @@ import type { HaBaseTimeInput } from "../ha-base-time-input";
import "../ha-icon-button";
import "../ha-icon-button-next";
import "../ha-icon-button-prev";
import "../ha-list";
import "../ha-list-item";
import "../item/ha-list-item-button";
import "../list/ha-list-base";
import "../ha-time-input";
import type { HaTimeInput } from "../ha-time-input";
import type { DateRangePickerRanges } from "./ha-date-range-picker";
@@ -166,11 +166,19 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
}
return html`
<ha-list @action=${this._setDateRange} activatable>
${Object.keys(this.ranges!).map(
(name) => html`<ha-list-item>${name}</ha-list-item>`
<ha-list-base>
${Object.entries(this.ranges!).map(
([name, range], index) => html`
<ha-list-item-button
.range=${range}
.index=${index}
@click=${this._setDateRange}
>
<span slot="headline">${name}</span>
</ha-list-item-button>
`
)}
</ha-list>
</ha-list-base>
`;
}
@@ -343,11 +351,12 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
this._saveDateRangePreset(chip.range, chip.index);
}
private _setDateRange(ev: HASSDomEvent<ActionDetail>) {
const dateRange: [Date, Date] = Object.values(this.ranges!)[
ev.detail.index
];
this._saveDateRangePreset(dateRange, ev.detail.index);
private _setDateRange(
ev: HASSDomCurrentTargetEvent<
HTMLElement & { range: [Date, Date]; index: number }
>
) {
this._saveDateRangePreset(ev.currentTarget.range, ev.currentTarget.index);
}
private _saveDateRangePreset(range: [Date, Date], index: number) {
@@ -9,13 +9,13 @@ import { HaDeviceAutomationPicker } from "./ha-device-automation-picker";
@customElement("ha-device-action-picker")
class HaDeviceActionPicker extends HaDeviceAutomationPicker<DeviceAction> {
protected get NO_AUTOMATION_TEXT() {
return this.hass.localize(
return this._i18n.localize(
"ui.panel.config.devices.automation.actions.no_actions"
);
}
protected get UNKNOWN_AUTOMATION_TEXT() {
return this.hass.localize(
return this._i18n.localize(
"ui.panel.config.devices.automation.actions.unknown_action"
);
}
@@ -1,21 +1,35 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { ContextType } from "@lit/context";
import type { HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consume } from "../../common/decorators/consume";
import { preserveUnchangedEntityStatesRecord } from "../../common/decorators/consume-context-entry";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import type { LocalizeFunc } from "../../common/translations/localize";
import { fullEntitiesContext } from "../../data/context";
import {
apiContext,
formattersContext,
fullEntitiesContext,
internationalizationContext,
statesContext,
} from "../../data/context";
import type { DeviceAutomation } from "../../data/device/device_automation";
import {
deviceAutomationsEqual,
sortDeviceAutomations,
} from "../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import type { CallWS, HomeAssistant, ValueChangedEvent } from "../../types";
import { entityRegistryById } from "../../data/entity/entity_registry";
import type {
CallWS,
HomeAssistantFormatters,
ValueChangedEvent,
} from "../../types";
import "../ha-combo-box-item";
import "../ha-generic-picker";
import {
@@ -29,8 +43,6 @@ const NO_AUTOMATION_KEY = "NO_AUTOMATION";
export abstract class HaDeviceAutomationPicker<
T extends DeviceAutomation,
> extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property({ attribute: false }) public deviceId?: string;
@@ -47,8 +59,49 @@ export abstract class HaDeviceAutomationPicker<
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
@state()
@consume({ context: internationalizationContext, subscribe: true })
protected _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: statesContext, subscribe: true })
@transform<HassEntities, HassEntities>({
transformer: function (
this: HaDeviceAutomationPicker<DeviceAutomation>,
states
) {
const registryById = entityRegistryById(this._entityReg ?? []);
const picked: HassEntities = {};
[...(this._automations ?? []), this.value].forEach((automation) => {
const id = automation?.entity_id;
const entityId = id?.includes(".")
? id
: id && registryById[id]?.entity_id;
if (entityId && states?.[entityId]) {
picked[entityId] = states[entityId];
}
});
return preserveUnchangedEntityStatesRecord(this._states, picked);
},
watch: ["_automations", "value", "_entityReg"],
})
private _states: HassEntities = {};
@state()
@consume({ context: formattersContext, subscribe: true })
@transform<
HomeAssistantFormatters,
HomeAssistantFormatters["formatEntityName"]
>({
transformer: ({ formatEntityName }) => formatEntityName,
})
private _formatEntityName!: HomeAssistantFormatters["formatEntityName"];
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
protected get NO_AUTOMATION_TEXT() {
return this.hass.localize(
return this._i18n.localize(
"ui.panel.config.devices.automation.actions.no_actions"
);
}
@@ -56,7 +109,7 @@ export abstract class HaDeviceAutomationPicker<
private _localizeDeviceAutomation: (
localize: LocalizeFunc,
states: HassEntities,
formatEntityName: HomeAssistant["formatEntityName"],
formatEntityName: HomeAssistantFormatters["formatEntityName"],
entityRegistry: EntityRegistryEntry[],
automation: T
) => string;
@@ -109,11 +162,19 @@ export abstract class HaDeviceAutomationPicker<
.label=${this.label}
.value=${value}
.disabled=${!this._automations || this._automations.length === 0}
.getItems=${this._getItems(value, this._automations)}
.getItems=${this._getItems(
value,
this._automations,
this._i18n.localize,
this._i18n.locale.language,
this._states,
this._formatEntityName,
this._entityReg
)}
@value-changed=${this._automationChanged}
.rowRenderer=${this._rowRenderer}
.valueRenderer=${this._valueRenderer}
.unknownItemText=${this.hass.localize(
.unknownItemText=${this._i18n.localize(
"ui.panel.config.devices.automation.actions.unknown_action"
)}
hide-clear-icon
@@ -130,17 +191,25 @@ export abstract class HaDeviceAutomationPicker<
}
private _getItems = memoizeOne(
(value: string, automations: T[] | undefined) => {
(
value: string,
automations: T[] | undefined,
localize: LocalizeFunc,
language: string,
states: HassEntities,
formatEntityName: HomeAssistantFormatters["formatEntityName"],
entityReg: EntityRegistryEntry[]
) => {
if (!automations) {
return () => undefined;
}
const automationListItems = automations.map((automation, idx) => {
const primary = this._localizeDeviceAutomation(
this.hass.localize,
this.hass.states,
this.hass.formatEntityName,
this._entityReg,
localize,
states,
formatEntityName,
entityReg,
automation
);
return {
@@ -150,11 +219,7 @@ export abstract class HaDeviceAutomationPicker<
});
automationListItems.sort((a, b) =>
caseInsensitiveStringCompare(
a.primary,
b.primary,
this.hass.locale.language
)
caseInsensitiveStringCompare(a.primary, b.primary, language)
);
if (value === NO_AUTOMATION_KEY) {
@@ -185,9 +250,9 @@ export abstract class HaDeviceAutomationPicker<
const text = described
? this._localizeDeviceAutomation(
this.hass.localize,
this.hass.states,
this.hass.formatEntityName,
this._i18n.localize,
this._states,
this._formatEntityName,
this._entityReg,
described
)
@@ -204,7 +269,7 @@ export abstract class HaDeviceAutomationPicker<
this._automations = this.deviceId
? (
await this._fetchDeviceAutomations(
this.hass.callWS,
this._api.callWS,
this.deviceId
).catch(() => [] as T[])
).sort(sortDeviceAutomations)
@@ -9,13 +9,13 @@ import { HaDeviceAutomationPicker } from "./ha-device-automation-picker";
@customElement("ha-device-condition-picker")
class HaDeviceConditionPicker extends HaDeviceAutomationPicker<DeviceCondition> {
protected get NO_AUTOMATION_TEXT() {
return this.hass.localize(
return this._i18n.localize(
"ui.panel.config.devices.automation.conditions.no_conditions"
);
}
protected get UNKNOWN_AUTOMATION_TEXT() {
return this.hass.localize(
return this._i18n.localize(
"ui.panel.config.devices.automation.conditions.unknown_condition"
);
}
+136 -41
View File
@@ -2,15 +2,28 @@ import { mdiSwapHorizontal } from "@mdi/js";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import type { ContextType } from "@lit/context";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { consume } from "../../common/decorators/consume";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { getDeviceArea } from "../../common/entity/context/get_device_context";
import { computeRTL } from "../../common/util/compute_rtl";
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
import {
apiContext,
brandsAccessTokenContext,
configContext,
connectionContext,
internationalizationContext,
registriesContext,
statesContext,
uiContext,
} from "../../data/context";
import {
deviceComboBoxKeys,
getDevices,
@@ -23,7 +36,11 @@ import {
} from "../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import { domainToName } from "../../data/integration";
import type { HomeAssistant } from "../../types";
import type {
HomeAssistant,
HomeAssistantConfig,
HomeAssistantUI,
} from "../../types";
import { brandsUrl } from "../../util/brands-url";
import "../ha-alert";
import "../ha-button";
@@ -42,7 +59,40 @@ export type HaDevicePickerEntityFilterFunc = (entity: HassEntity) => boolean;
@customElement("ha-device-picker")
export class HaDevicePicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@state()
@consume({ context: registriesContext, subscribe: true })
private _registries!: ContextType<typeof registriesContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: uiContext, subscribe: true })
@transform<HomeAssistantUI, boolean | undefined>({
transformer: (ui) => ui?.themes?.darkMode,
})
private _darkMode?: boolean;
@state()
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, string | undefined>({
transformer: (config) => config?.auth?.data.hassUrl,
})
private _hassUrl?: string;
@state()
@consume({ context: brandsAccessTokenContext, subscribe: true })
private _brandsAccessToken?: string;
@consume({ context: apiContext, subscribe: true })
private _api?: ContextType<typeof apiContext>;
@consume({ context: connectionContext, subscribe: true })
private _connection?: ContextType<typeof connectionContext>;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@@ -123,7 +173,9 @@ export class HaDevicePicker extends LitElement {
private _getDevicesMemoized = memoizeOne(
(
_devices: HomeAssistant["devices"],
states: HomeAssistant["states"],
registries: ContextType<typeof registriesContext>,
i18n: ContextType<typeof internationalizationContext>,
configEntryLookup: Record<string, ConfigEntry>,
includeDomains?: string[],
excludeDomains?: string[],
@@ -133,7 +185,15 @@ export class HaDevicePicker extends LitElement {
excludeDevices?: string[],
value?: string
) =>
getDevices(this.hass, configEntryLookup, {
getDevices({
states,
areas: registries.areas,
devices: registries.devices,
entities: registries.entities,
localize: i18n.localize,
language: i18n.locale.language,
translationMetadata: i18n.translationMetadata,
configEntryLookup,
includeDomains,
excludeDomains,
includeDeviceClasses,
@@ -150,21 +210,24 @@ export class HaDevicePicker extends LitElement {
this._loadConfigEntries();
}
protected willUpdate(changedProperties: PropertyValues<this>): void {
protected willUpdate(changedProperties: PropertyValues): void {
const devices = this._registries?.devices;
if (
!this.hass ||
!devices ||
!this.value ||
this._compositeSplits !== undefined ||
this._loadingCompositeSplits
) {
return;
}
const oldHass = changedProperties.get("hass") as HomeAssistant | undefined;
const oldRegistries = changedProperties.get("_registries") as
ContextType<typeof registriesContext> | undefined;
const devicesChanged =
changedProperties.has("hass") && this.hass.devices !== oldHass?.devices;
changedProperties.has("_registries") &&
devices !== oldRegistries?.devices;
if (
(changedProperties.has("value") || devicesChanged) &&
!this.hass.devices[this.value]
!devices[this.value]
) {
// The selected device is not in the registry; it might be a legacy
// composite device that was split into separate devices. Fetch the
@@ -174,16 +237,25 @@ export class HaDevicePicker extends LitElement {
}
private async _loadConfigEntries() {
const configEntries = await getConfigEntries(this.hass);
if (!this._api) {
return;
}
const configEntries = await getConfigEntries(this._api);
this._configEntryLookup = Object.fromEntries(
configEntries.map((entry) => [entry.entry_id, entry])
);
}
private async _loadCompositeSplits() {
if (!this._api || !this._connection) {
return;
}
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
this._compositeSplits = await fetchDeviceCompositeSplits(
this._connection.connection,
this._api.callWS
);
} catch (_err) {
this._compositeSplits = {};
} finally {
@@ -194,12 +266,12 @@ export class HaDevicePicker extends LitElement {
private _getReplacement = memoizeOne(
(
value: string | undefined,
_devices: HomeAssistant["devices"],
devices: HomeAssistant["devices"],
compositeSplits: DeviceCompositeSplits | undefined,
items: (DevicePickerItem | string)[],
replacementDeviceIds: string[] | undefined
) => {
if (!value || !compositeSplits || this.hass.devices[value]) {
if (!value || !compositeSplits || devices[value]) {
return undefined;
}
const split = compositeSplits[value];
@@ -224,7 +296,9 @@ export class HaDevicePicker extends LitElement {
private _getItems = () =>
this._getDevicesMemoized(
this.hass.devices,
this._states,
this._registries,
this._i18n,
this._configEntryLookup,
this.includeDomains,
this.excludeDomains,
@@ -264,10 +338,18 @@ export class HaDevicePicker extends LitElement {
};
private _valueRenderer = memoizeOne(
(configEntriesLookup: Record<string, ConfigEntry>, isReplaced: boolean) =>
(
configEntriesLookup: Record<string, ConfigEntry>,
isReplaced: boolean,
registries: ContextType<typeof registriesContext>,
localize: ContextType<typeof internationalizationContext>["localize"],
darkMode: boolean | undefined,
hassUrl: string | undefined,
_brandsAccessToken: string | undefined
) =>
(value: string) => {
const deviceId = value;
const device = this.hass.devices[deviceId];
const device = registries.devices[deviceId];
if (!device) {
// The removed device has no name left to show, so say what happened
@@ -280,16 +362,18 @@ export class HaDevicePicker extends LitElement {
.path=${mdiSwapHorizontal}
></ha-svg-icon>
<span slot="headline"
>${this.hass.localize(
"ui.components.device-picker.device_replaced"
)}</span
>${localize("ui.components.device-picker.device_replaced")}</span
>
`;
}
return html`<span slot="headline">${deviceId}</span>`;
}
const area = getDeviceArea(device, this.hass.areas, this.hass.devices);
const area = getDeviceArea(
device,
registries.areas,
registries.devices
);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
@@ -313,9 +397,9 @@ export class HaDevicePicker extends LitElement {
{
domain: configEntry.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
darkOptimized: darkMode,
},
this.hass.auth.data.hassUrl
hassUrl
)}
/>`
: nothing
@@ -328,8 +412,8 @@ export class HaDevicePicker extends LitElement {
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => {
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
this._i18n.language,
this._i18n.translationMetadata.translations
);
return html`
<ha-combo-box-item
@@ -368,9 +452,9 @@ export class HaDevicePicker extends LitElement {
{
domain: item.domain,
type: "icon",
darkOptimized: this.hass.themes.darkMode,
darkOptimized: this._darkMode,
},
this.hass.auth.data.hassUrl
this._hassUrl
)}
/>
`
@@ -399,18 +483,18 @@ export class HaDevicePicker extends LitElement {
protected render() {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.device-picker.placeholder");
this._i18n.localize("ui.components.device-picker.placeholder");
// Only resolve a replacement (which needs the full item list) when the
// value is a missing device that we know was replaced, to avoid computing
// the item list on every render for the common case.
const replacement =
this.value &&
!this.hass.devices[this.value] &&
!this._registries.devices[this.value] &&
this._compositeSplits?.[this.value]
? this._getReplacement(
this.value,
this.hass.devices,
this._registries.devices,
this._compositeSplits,
this._getItems(),
this.replacementDeviceIds
@@ -423,7 +507,12 @@ export class HaDevicePicker extends LitElement {
const valueRenderer = this._valueRenderer(
this._configEntryLookup,
canReplace
canReplace,
this._registries,
this._i18n.localize,
this._darkMode,
this._hassUrl,
this._brandsAccessToken
);
return html`
@@ -435,7 +524,7 @@ export class HaDevicePicker extends LitElement {
.label=${this.label}
.searchLabel=${this.searchLabel}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass.localize(
.emptyLabel=${this._i18n.localize(
"ui.components.device-picker.no_devices"
)}
.placeholder=${placeholder}
@@ -447,7 +536,7 @@ export class HaDevicePicker extends LitElement {
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${valueRenderer}
.searchKeys=${deviceComboBoxKeys}
.unknownItemText=${this.hass.localize(
.unknownItemText=${this._i18n.localize(
"ui.components.device-picker.unknown"
)}
@value-changed=${this._valueChanged}
@@ -466,7 +555,9 @@ export class HaDevicePicker extends LitElement {
// The split devices all inherit the composite's name, so the integration is
// what tells them apart.
const replacementDevice =
candidates.length === 1 ? this.hass.devices[candidates[0]] : undefined;
candidates.length === 1
? this._registries.devices[candidates[0]]
: undefined;
const replacementName = replacementDevice
? computeDeviceName(replacementDevice)
: undefined;
@@ -478,22 +569,22 @@ export class HaDevicePicker extends LitElement {
<ha-alert alert-type="warning">
${
replacementName && replacementDomain
? this.hass.localize(
? this._i18n.localize(
"ui.components.device-picker.device_replaced_by_one_integration",
{
device: replacementName,
integration: domainToName(
this.hass.localize,
this._i18n.localize,
replacementDomain
),
}
)
: replacementName
? this.hass.localize(
? this._i18n.localize(
"ui.components.device-picker.device_replaced_by_one",
{ device: replacementName }
)
: this.hass.localize(
: this._i18n.localize(
"ui.components.device-picker.device_replaced_by_multiple",
{ count: candidates.length }
)
@@ -506,8 +597,12 @@ export class HaDevicePicker extends LitElement {
>
${
candidates.length === 1
? this.hass.localize("ui.components.device-picker.replace_update")
: this.hass.localize("ui.components.device-picker.replace_choose")
? this._i18n.localize(
"ui.components.device-picker.replace_update"
)
: this._i18n.localize(
"ui.components.device-picker.replace_choose"
)
}
</ha-button>
</ha-alert>
@@ -517,7 +612,7 @@ export class HaDevicePicker extends LitElement {
private _handleReplace = () => {
const replacement = this._getReplacement(
this.value,
this.hass.devices,
this._registries.devices,
this._compositeSplits,
this._getItems(),
this.replacementDeviceIds
@@ -556,7 +651,7 @@ export class HaDevicePicker extends LitElement {
}
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.device-picker.no_match", {
this._i18n.localize("ui.components.device-picker.no_match", {
term: html`<b>‘${search}’</b>`,
});
@@ -9,13 +9,13 @@ import { HaDeviceAutomationPicker } from "./ha-device-automation-picker";
@customElement("ha-device-trigger-picker")
class HaDeviceTriggerPicker extends HaDeviceAutomationPicker<DeviceTrigger> {
protected get NO_AUTOMATION_TEXT() {
return this.hass.localize(
return this._i18n.localize(
"ui.panel.config.devices.automation.triggers.no_triggers"
);
}
protected get UNKNOWN_AUTOMATION_TEXT() {
return this.hass.localize(
return this._i18n.localize(
"ui.panel.config.devices.automation.triggers.unknown_trigger"
);
}
+2 -10
View File
@@ -1,7 +1,7 @@
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import type { ValueChangedEvent } from "../../types";
import "./ha-device-picker";
import type {
HaDevicePickerDeviceFilterFunc,
@@ -10,8 +10,6 @@ import type {
@customElement("ha-devices-picker")
class HaDevicesPicker extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ type: Array }) public value?: string[];
@property() public helper?: string;
@@ -51,10 +49,6 @@ class HaDevicesPicker extends LitElement {
public entityFilter?: HaDevicePickerEntityFilterFunc;
protected render() {
if (!this.hass) {
return nothing;
}
const currentDevices = this._currentDevices;
return html`
${currentDevices.map(
@@ -62,7 +56,6 @@ class HaDevicesPicker extends LitElement {
<div>
<ha-device-picker
.curValue=${entityId}
.hass=${this.hass}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
@@ -78,7 +71,6 @@ class HaDevicesPicker extends LitElement {
)}
<div>
<ha-device-picker
.hass=${this.hass}
.helper=${this.helper}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
@@ -1,17 +1,27 @@
import { LitElement, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { HassEntity } from "home-assistant-js-websocket";
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { consume } from "../../common/decorators/consume";
import {
consumeEntityStates,
consumeLocalize,
} from "../../common/decorators/consume-context-entry";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import type { LocalizeFunc } from "../../common/translations/localize";
import { formattersContext } from "../../data/context";
import type { HomeAssistantFormatters, ValueChangedEvent } from "../../types";
import "../ha-generic-picker";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
import type { PickerValueRenderer } from "../ha-picker-field";
type FormatEntityAttributeName =
HomeAssistantFormatters["formatEntityAttributeName"];
@customElement("ha-entity-attribute-picker")
class HaEntityAttributePicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId?: string | string[];
/**
@@ -38,18 +48,32 @@ class HaEntityAttributePicker extends LitElement {
@property() public helper?: string;
@state()
@consumeEntityStates({ entityIdPath: ["entityId"] })
private _stateObjs?: Record<string, HassEntity>;
@state()
@consume({ context: formattersContext, subscribe: true })
@transform<HomeAssistantFormatters, FormatEntityAttributeName>({
transformer: (formatters) => formatters?.formatEntityAttributeName,
})
private _formatEntityAttributeName!: FormatEntityAttributeName;
@state() @consumeLocalize() private _localize!: LocalizeFunc;
private _getItemsMemoized = memoizeOne(
(
entityId: string | string[] | undefined,
stateObjs: Record<string, HassEntity> | undefined,
hideAttributes: string[] | undefined,
hass: HomeAssistant
formatEntityAttributeName: FormatEntityAttributeName
): PickerComboBoxItem[] => {
const entityIds = entityId ? ensureArray(entityId) : [];
const options: PickerComboBoxItem[] = [];
const optionsSet = new Set<string>();
for (const id of entityIds) {
const stateObj = hass.states[id];
const stateObj = stateObjs?.[id];
if (!stateObj) {
continue;
}
@@ -63,7 +87,7 @@ class HaEntityAttributePicker extends LitElement {
optionsSet.add(attribute);
options.push({
id: attribute,
primary: hass.formatEntityAttributeName(stateObj, attribute),
primary: formatEntityAttributeName(stateObj, attribute),
sorting_label: attribute,
});
}
@@ -75,19 +99,28 @@ class HaEntityAttributePicker extends LitElement {
);
private _getItems = () =>
this._getItemsMemoized(this.entityId, this.hideAttributes, this.hass);
this._getItemsMemoized(
this.entityId,
this._stateObjs,
this.hideAttributes,
this._formatEntityAttributeName
);
private _valueRenderer = memoizeOne(
(items: PickerComboBoxItem[]): PickerValueRenderer =>
(value: string) => {
const item = items.find((option) => option.id === value);
return html`<span slot="headline">${item?.primary ?? value}</span>`;
}
);
protected render() {
if (!this.hass) {
return nothing;
}
return html`
<ha-generic-picker
.value=${this.value}
.label=${
this.label ??
this.hass.localize(
this._localize(
"ui.components.entity.entity-attribute-picker.attribute"
)
}
@@ -96,19 +129,13 @@ class HaEntityAttributePicker extends LitElement {
.helper=${this.helper}
.allowCustomValue=${this.allowCustomValue}
.getItems=${this._getItems}
.valueRenderer=${this._valueRenderer}
.valueRenderer=${this._valueRenderer(this._getItems())}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
`;
}
private _valueRenderer: PickerValueRenderer = (value: string) => {
const items = this._getItems();
const item = items.find((option) => option.id === value);
return html`<span slot="headline">${item?.primary ?? value}</span>`;
};
private _valueChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const newValue = ev.detail.value;
+145 -118
View File
@@ -1,4 +1,5 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { ContextType } from "@lit/context";
import { mdiDragHorizontalVariant, mdiPlus, mdiRestore } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
@@ -8,6 +9,12 @@ import { ifDefined } from "lit/directives/if-defined";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { consume } from "../../common/decorators/consume";
import {
consumeEntityState,
consumeLocalize,
} from "../../common/decorators/consume-context-entry";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import {
computeDefaultEntityNameItems,
@@ -16,11 +23,15 @@ import {
type EntityNameType,
} from "../../common/entity/compute_entity_name_display";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import type { LocalizeKeys } from "../../common/translations/localize";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../chips/ha-assist-chip";
import type {
LocalizeFunc,
LocalizeKeys,
} from "../../common/translations/localize";
import { formattersContext, registriesContext } from "../../data/context";
import type { HomeAssistantFormatters, ValueChangedEvent } from "../../types";
import "../chips/ha-chip-set";
import "../chips/ha-input-chip";
import "../ha-button";
import "../ha-button-toggle-group";
import "../ha-combo-box-item";
import "../ha-generic-picker";
@@ -68,8 +79,6 @@ const parseOptionValue = (value: string): EntityNameItem => {
@customElement("ha-entity-name-picker")
export class HaEntityNamePicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId?: string;
@property({ attribute: false }) public value?:
@@ -90,8 +99,30 @@ export class HaEntityNamePicker extends LitElement {
@state() private _mode?: "composed" | "custom";
@state()
@consumeEntityState({ entityIdPath: ["entityId"] })
private _stateObj?: HassEntity;
@state()
@consume({ context: registriesContext, subscribe: true })
private _registries!: ContextType<typeof registriesContext>;
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@state()
@consume({ context: formattersContext, subscribe: true })
@transform<
HomeAssistantFormatters,
HomeAssistantFormatters["formatEntityName"]
>({
transformer: ({ formatEntityName }) => formatEntityName,
})
private _formatEntityName!: HomeAssistantFormatters["formatEntityName"];
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@query("ha-button.add") private _addButton?: HTMLElement;
private _editIndex?: number;
connectedCallback(): void {
@@ -114,13 +145,13 @@ export class HaEntityNamePicker extends LitElement {
protected render() {
const modeButtons = [
{
label: this.hass.localize(
label: this._localize(
"ui.components.entity.entity-name-picker.mode_composed"
),
value: "composed",
},
{
label: this.hass.localize(
label: this._localize(
"ui.components.entity.entity-name-picker.mode_custom"
),
value: "custom",
@@ -130,7 +161,7 @@ export class HaEntityNamePicker extends LitElement {
return html`
<div class="container">
<div class="header">
${this.label ? html`<label>${this.label}</label>` : nothing}
${this.label ? html`<label id="label">${this.label}</label>` : nothing}
${
this.allowCustom
? html`
@@ -167,9 +198,7 @@ export class HaEntityNamePicker extends LitElement {
const items = this._items;
const value =
items.length === 1 && items[0].type === "text" ? items[0].text || "" : "";
const stateObj = this.entityId
? this.hass.states[this.entityId]
: undefined;
const stateObj = this._stateObj;
return html`
<ha-input
.disabled=${this.disabled}
@@ -177,10 +206,7 @@ export class HaEntityNamePicker extends LitElement {
.value=${value}
.placeholder=${
stateObj
? this.hass.formatEntityName(
stateObj,
this.value ?? this.defaultValue
)
? this._formatEntityName(stateObj, this.value ?? this.defaultValue)
: undefined
}
@input=${this._textInputChanged}
@@ -199,7 +225,7 @@ export class HaEntityNamePicker extends LitElement {
class="restore"
slot=${ifDefined(slot)}
.path=${mdiRestore}
.label=${this.hass.localize(
.label=${this._localize(
"ui.components.entity.entity-name-picker.restore"
)}
@click=${this._restore}
@@ -209,7 +235,7 @@ export class HaEntityNamePicker extends LitElement {
private _renderPicker() {
const value = this._items;
const validTypes = this._validTypes(this.entityId);
const validTypes = this._validTypes(this._stateObj, this._registries);
return html`
<ha-generic-picker
@@ -220,15 +246,15 @@ export class HaEntityNamePicker extends LitElement {
.value=${this._getPickerValue()}
no-sort
?allow-custom-value=${this.allowCustom}
.customValueLabel=${this.hass.localize(
.customValueLabel=${this._localize(
"ui.components.entity.entity-name-picker.custom_name"
)}
.emptyLabel=${this.hass.localize(
.emptyLabel=${this._localize(
"ui.components.entity.entity-name-picker.no_more_parts"
)}
@value-changed=${this._pickerValueChanged}
.searchFn=${this._searchFn}
.searchLabel=${this.hass.localize(
.searchLabel=${this._localize(
"ui.components.entity.entity-name-picker.search"
)}
>
@@ -240,7 +266,9 @@ export class HaEntityNamePicker extends LitElement {
handle-selector="button.primary.action"
filter=".add"
>
<ha-chip-set>
<ha-chip-set
aria-labelledby=${ifDefined(this.label ? "label" : undefined)}
>
${repeat(
this._items,
(item) => item,
@@ -266,22 +294,16 @@ export class HaEntityNamePicker extends LitElement {
`;
}
)}
${
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-button
class="add"
size="s"
appearance="filled"
.disabled=${this.disabled}
@click=${this._addItem}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this._localize("ui.common.add")}
</ha-button>
</ha-chip-set>
</ha-sortable>
${this._renderRestoreButton()}
@@ -349,6 +371,10 @@ export class HaEntityNamePicker extends LitElement {
const idx = parseInt((ev.target as HTMLElement).dataset.idx || "", 10);
value.splice(idx, 1);
this._setValue(value);
if (!value.length) {
await this.updateComplete;
this._addButton?.focus();
}
}
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
@@ -392,32 +418,18 @@ export class HaEntityNamePicker extends LitElement {
if (this.defaultValue !== undefined) {
return this._toItems(this.defaultValue);
}
return this._defaultItems(
this.entityId ? this.hass.states[this.entityId] : undefined,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
return this._defaultItems(this._stateObj, this._registries);
}
private _defaultItems = memoizeOne(
(
stateObj: HassEntity | undefined,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"]
registries: ContextType<typeof registriesContext>
): EntityNameItem[] => {
if (!stateObj) {
return DEFAULT_NAME_WITHOUT_ENTITY;
}
return computeDefaultEntityNameItems(stateObj, {
entities,
devices,
areas,
floors,
});
return computeDefaultEntityNameItems(stateObj, registries);
}
);
@@ -449,81 +461,88 @@ export class HaEntityNamePicker extends LitElement {
return `"${item.text}"`;
}
if (KNOWN_TYPES.has(item.type)) {
return this.hass.localize(
return this._localize(
`ui.components.entity.entity-name-picker.types.${item.type as EntityNameType}`
);
}
return item.type;
};
private _validTypes = memoizeOne((entityId?: string) => {
const stateObj = entityId ? this.hass.states[entityId] : undefined;
private _validTypes = memoizeOne(
(
stateObj: HassEntity | undefined,
registries: ContextType<typeof registriesContext>
) => {
if (!stateObj) {
return new Set<string>(["text", ...ENTITY_NAME_TYPES]);
}
if (!stateObj) {
return new Set<string>(["text", ...ENTITY_NAME_TYPES]);
}
const options = new Set<string>(["text", "entity"]);
const options = new Set<string>(["text", "entity"]);
const context = getEntityContext(
stateObj,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
if (context.device) options.add("device");
if (context.parentDevice) options.add("parent_device");
if (context.area) options.add("area");
if (context.floor) options.add("floor");
return options;
});
private _getItems = memoizeOne((entityId?: string) => {
const types = this._validTypes(entityId);
const stateObj = entityId ? this.hass.states[entityId] : undefined;
const items = ENTITY_NAME_TYPES.filter(
(name) => name !== "parent_device" || types.has(name)
).map<PickerComboBoxItem>((name) => {
const isValid = types.has(name);
const primary = this.hass.localize(
`ui.components.entity.entity-name-picker.types.${name}`
const context = getEntityContext(
stateObj,
registries.entities,
registries.devices,
registries.areas,
registries.floors
);
const secondary = stateObj
? (isValid
? this.hass.formatEntityName(stateObj, { type: name })
: this.hass.localize(
`ui.components.entity.entity-name-picker.types.${name}_missing` as LocalizeKeys
)) || "-"
: undefined;
const id = formatOptionValue({ type: name });
if (context.device) options.add("device");
if (context.parentDevice) options.add("parent_device");
if (context.area) options.add("area");
if (context.floor) options.add("floor");
return options;
}
);
return {
id,
primary,
secondary,
disabled: !isValid,
search_labels: {
primary,
secondary: secondary || null,
private _getItems = memoizeOne(
(
stateObj: HassEntity | undefined,
registries: ContextType<typeof registriesContext>,
localize: LocalizeFunc,
formatEntityName: HomeAssistantFormatters["formatEntityName"]
) => {
const types = this._validTypes(stateObj, registries);
const items = ENTITY_NAME_TYPES.filter(
(name) => name !== "parent_device" || types.has(name)
).map<PickerComboBoxItem>((name) => {
const isValid = types.has(name);
const primary = localize(
`ui.components.entity.entity-name-picker.types.${name}`
);
const secondary = stateObj
? (isValid
? formatEntityName(stateObj, { type: name })
: localize(
`ui.components.entity.entity-name-picker.types.${name}_missing` as LocalizeKeys
)) || "-"
: undefined;
const id = formatOptionValue({ type: name });
return {
id,
},
sorting_label: primary,
};
});
primary,
secondary,
disabled: !isValid,
search_labels: {
primary,
secondary: secondary || null,
id,
},
sorting_label: primary,
};
});
return items;
});
return items;
}
);
private _customNameOption = memoizeOne(
(text: string): PickerComboBoxItem => ({
(text: string, localize: LocalizeFunc): PickerComboBoxItem => ({
id: formatOptionValue({ type: "text", text }),
primary: this.hass.localize(
"ui.components.entity.entity-name-picker.custom_name"
),
primary: localize("ui.components.entity.entity-name-picker.custom_name"),
secondary: `"${text}"`,
search_labels: {
primary: text,
@@ -543,7 +562,12 @@ export class HaEntityNamePicker extends LitElement {
}
private _getFilteredItems = (): PickerComboBoxItem[] => {
const items = this._getItems(this.entityId);
const items = this._getItems(
this._stateObj,
this._registries,
this._localize,
this._formatEntityName
);
const currentItem =
this._editIndex != null ? this._items[this._editIndex] : undefined;
const currentValue = currentItem ? formatOptionValue(currentItem) : "";
@@ -560,7 +584,9 @@ export class HaEntityNamePicker extends LitElement {
// When editing an existing text item, include it in the base items
if (currentItem?.type === "text" && currentItem.text) {
filteredItems.push(this._customNameOption(currentItem.text));
filteredItems.push(
this._customNameOption(currentItem.text, this._localize)
);
}
return filteredItems;
@@ -574,7 +600,7 @@ export class HaEntityNamePicker extends LitElement {
this._editIndex != null ? this._items[this._editIndex] : undefined;
const currentId =
currentItem?.type === "text" && currentItem.text
? this._customNameOption(currentItem.text).id
? this._customNameOption(currentItem.text, this._localize).id
: undefined;
// Remove custom name option if search string is present to avoid duplicates
@@ -605,7 +631,7 @@ export class HaEntityNamePicker extends LitElement {
label {
display: block;
font-weight: 500;
font-weight: var(--ha-font-weight-medium);
}
.content {
@@ -698,7 +724,8 @@ export class HaEntityNamePicker extends LitElement {
ha-input-helper-text {
display: block;
margin: var(--ha-space-2) 0 0;
padding: 0;
margin: var(--ha-space-1) 0 0;
}
`;
}
+4 -1
View File
@@ -253,7 +253,10 @@ export class HaEntityPicker extends LitElement {
const { primary, secondary } = computeEntityPickerDisplay(
{
...this._registries,
entities: this._registries.entities,
devices: this._registries.devices,
areas: this._registries.areas,
floors: this._registries.floors,
language: this._i18n.language,
translationMetadata: this._i18n.translationMetadata,
},
@@ -1,18 +1,27 @@
import type { ContextType } from "@lit/context";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
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 { consume } from "../../common/decorators/consume";
import {
consumeEntityState,
consumeLocalize,
} from "../../common/decorators/consume-context-entry";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import type { LocalizeFunc } from "../../common/translations/localize";
import { formattersContext, registriesContext } from "../../data/context";
import {
STATE_DISPLAY_SPECIAL_CONTENT,
STATE_DISPLAY_SPECIAL_CONTENT_DOMAINS,
} from "../../state-display/state-display";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import type { HomeAssistantFormatters, ValueChangedEvent } from "../../types";
import "../chips/ha-assist-chip";
import "../chips/ha-chip-set";
import "../chips/ha-input-chip";
@@ -79,10 +88,11 @@ const HIDDEN_ATTRIBUTES = [
"wind_speed_unit",
];
type FormatEntityAttributeName =
HomeAssistantFormatters["formatEntityAttributeName"];
@customElement("ha-entity-state-content-picker")
export class HaStateContentPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId?: string;
// eslint-disable-next-line lit/no-native-attributes
@@ -106,34 +116,48 @@ export class HaStateContentPicker extends LitElement {
@query("ha-generic-picker", true) private _picker?: HaGenericPicker;
@state()
@consumeEntityState({ entityIdPath: ["entityId"] })
private _stateObj?: HassEntity;
@state()
@consume({ context: registriesContext, subscribe: true })
private _registries!: ContextType<typeof registriesContext>;
@state()
@consume({ context: formattersContext, subscribe: true })
@transform<HomeAssistantFormatters, FormatEntityAttributeName>({
transformer: (formatters) => formatters?.formatEntityAttributeName,
})
private _formatEntityAttributeName!: FormatEntityAttributeName;
@state() @consumeLocalize() private _localize!: LocalizeFunc;
private _editIndex?: number;
private _getItems = memoizeOne(
(
entityId?: string,
stateObj?: HassEntity,
allowName?: boolean,
allowContext?: boolean
entityId: string | undefined,
stateObj: HassEntity | undefined,
allowName: boolean,
allowContext: boolean,
localize: LocalizeFunc,
registries: ContextType<typeof registriesContext>,
formatEntityAttributeName: FormatEntityAttributeName
) => {
const domain = entityId ? computeDomain(entityId) : undefined;
const items: PickerComboBoxItem[] = [
{
id: "state",
primary: this.hass.localize(
"ui.components.state-content-picker.state"
),
sorting_label: this.hass.localize(
"ui.components.state-content-picker.state"
),
primary: localize("ui.components.state-content-picker.state"),
sorting_label: localize("ui.components.state-content-picker.state"),
},
...(allowName
? [
{
id: "name",
primary: this.hass.localize(
"ui.components.state-content-picker.name"
),
sorting_label: this.hass.localize(
primary: localize("ui.components.state-content-picker.name"),
sorting_label: localize(
"ui.components.state-content-picker.name"
),
} satisfies PickerComboBoxItem,
@@ -141,19 +165,15 @@ export class HaStateContentPicker extends LitElement {
: []),
{
id: "last_changed",
primary: this.hass.localize(
"ui.components.state-content-picker.last_changed"
),
sorting_label: this.hass.localize(
primary: localize("ui.components.state-content-picker.last_changed"),
sorting_label: localize(
"ui.components.state-content-picker.last_changed"
),
},
{
id: "last_updated",
primary: this.hass.localize(
"ui.components.state-content-picker.last_updated"
),
sorting_label: this.hass.localize(
primary: localize("ui.components.state-content-picker.last_updated"),
sorting_label: localize(
"ui.components.state-content-picker.last_updated"
),
},
@@ -161,19 +181,19 @@ export class HaStateContentPicker extends LitElement {
? (() => {
const context = getEntityContext(
stateObj,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
registries.entities,
registries.devices,
registries.areas,
registries.floors
);
const contextItems: PickerComboBoxItem[] = [];
if (context.device) {
contextItems.push({
id: "device_name",
primary: this.hass.localize(
primary: localize(
"ui.components.state-content-picker.device_name"
),
sorting_label: this.hass.localize(
sorting_label: localize(
"ui.components.state-content-picker.device_name"
),
});
@@ -181,10 +201,10 @@ export class HaStateContentPicker extends LitElement {
if (context.parentDevice) {
contextItems.push({
id: "parent_device_name",
primary: this.hass.localize(
primary: localize(
"ui.components.state-content-picker.parent_device_name"
),
sorting_label: this.hass.localize(
sorting_label: localize(
"ui.components.state-content-picker.parent_device_name"
),
});
@@ -192,10 +212,10 @@ export class HaStateContentPicker extends LitElement {
if (context.area) {
contextItems.push({
id: "area_name",
primary: this.hass.localize(
primary: localize(
"ui.components.state-content-picker.area_name"
),
sorting_label: this.hass.localize(
sorting_label: localize(
"ui.components.state-content-picker.area_name"
),
});
@@ -203,10 +223,10 @@ export class HaStateContentPicker extends LitElement {
if (context.floor) {
contextItems.push({
id: "floor_name",
primary: this.hass.localize(
primary: localize(
"ui.components.state-content-picker.floor_name"
),
sorting_label: this.hass.localize(
sorting_label: localize(
"ui.components.state-content-picker.floor_name"
),
});
@@ -221,10 +241,10 @@ export class HaStateContentPicker extends LitElement {
(content) =>
({
id: content,
primary: this.hass.localize(
primary: localize(
`ui.components.state-content-picker.${content}`
),
sorting_label: this.hass.localize(
sorting_label: localize(
`ui.components.state-content-picker.${content}`
),
}) satisfies PickerComboBoxItem
@@ -236,14 +256,8 @@ export class HaStateContentPicker extends LitElement {
(attribute) =>
({
id: attribute,
primary: this.hass.formatEntityAttributeName(
stateObj!,
attribute
),
sorting_label: this.hass.formatEntityAttributeName(
stateObj!,
attribute
),
primary: formatEntityAttributeName(stateObj!, attribute),
sorting_label: formatEntityAttributeName(stateObj!, attribute),
}) satisfies PickerComboBoxItem
),
];
@@ -251,13 +265,21 @@ export class HaStateContentPicker extends LitElement {
}
);
private _items() {
return this._getItems(
this.entityId,
this._stateObj,
this.allowName,
this.allowContext,
this._localize,
this._registries,
this._formatEntityAttributeName
);
}
protected render() {
const value = this._value;
const stateObj = this.entityId
? this.hass.states[this.entityId]
: undefined;
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-generic-picker
@@ -282,7 +304,7 @@ export class HaStateContentPicker extends LitElement {
this._value,
(item) => item,
(item: string, idx) => {
const label = this._getItemLabel(item, stateObj);
const label = this._getItemLabel(item);
const isValid = !!label;
return html`
<ha-input-chip
@@ -309,7 +331,7 @@ export class HaStateContentPicker extends LitElement {
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this.hass.localize(
label=${this._localize(
"ui.components.entity.entity-state-content-picker.add"
)}
class="add"
@@ -354,20 +376,9 @@ export class HaStateContentPicker extends LitElement {
return !this.value ? [] : ensureArray(this.value);
}
private _getItemLabel = memoizeOne(
(value: string, stateObj?: HassEntity): string | undefined => {
const stateObjForItems = this.entityId
? this.hass.states[this.entityId]
: stateObj;
const items = this._getItems(
this.entityId,
stateObjForItems,
this.allowName,
this.allowContext
);
return items.find((item) => item.id === value)?.primary;
}
);
private _getItemLabel(value: string): string | undefined {
return this._items().find((item) => item.id === value)?.primary;
}
private _toValue = memoizeOne((value: string[]): typeof this.value => {
if (value.length === 0) {
@@ -387,9 +398,9 @@ export class HaStateContentPicker extends LitElement {
}
private _customValueOption = memoizeOne(
(text: string): PickerComboBoxItem => ({
(text: string, localize: LocalizeFunc): PickerComboBoxItem => ({
id: text,
primary: this.hass.localize(
primary: localize(
"ui.components.entity.entity-state-content-picker.custom_attribute"
),
secondary: `"${text}"`,
@@ -403,15 +414,7 @@ export class HaStateContentPicker extends LitElement {
);
private _getFilteredItems = (): PickerComboBoxItem[] => {
const stateObj = this.entityId
? this.hass.states[this.entityId]
: undefined;
const items = this._getItems(
this.entityId,
stateObj,
this.allowName,
this.allowContext
);
const items = this._items();
const currentValue =
this._editIndex != null ? this._value[this._editIndex] : undefined;
@@ -423,7 +426,7 @@ export class HaStateContentPicker extends LitElement {
// When editing an existing custom value, include it in the base items
if (currentValue && !items.find((item) => item.id === currentValue)) {
filteredItems.push(this._customValueOption(currentValue));
filteredItems.push(this._customValueOption(currentValue, this._localize));
}
return filteredItems;
@@ -432,21 +435,13 @@ export class HaStateContentPicker extends LitElement {
private _getAdditionalItems = (
searchString?: string
): PickerComboBoxItem[] => {
const stateObj = this.entityId
? this.hass.states[this.entityId]
: undefined;
const items = this._getItems(
this.entityId,
stateObj,
this.allowName,
this.allowContext
);
const items = this._items();
// If the search string does not match with the id of any of the items,
// offer to add it as a custom attribute
const existingItem = items.find((item) => item.id === searchString);
if (searchString && !existingItem) {
return [this._customValueOption(searchString)];
return [this._customValueOption(searchString, this._localize)];
}
return [];
+57 -26
View File
@@ -1,18 +1,25 @@
import { LitElement, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { ContextType } from "@lit/context";
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { consume } from "../../common/decorators/consume";
import { consumeEntityStates } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import { getStates } from "../../common/entity/get_states";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import {
formattersContext,
internationalizationContext,
statesContext,
} from "../../data/context";
import type { HomeAssistantFormatters, ValueChangedEvent } from "../../types";
import "../ha-generic-picker";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
import type { PickerValueRenderer } from "../ha-picker-field";
@customElement("ha-entity-state-picker")
export class HaEntityStatePicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId?: string | string[];
@property() public attribute?: string;
@@ -38,9 +45,26 @@ export class HaEntityStatePicker extends LitElement {
@property() public helper?: string;
@state()
@consumeEntityStates({ entityIdPath: ["entityId"] })
private _stateObjs?: Record<string, HassEntity>;
@consume({ context: statesContext, subscribe: true })
private _states?: ContextType<typeof statesContext>;
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters!: ContextType<typeof formattersContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
private _getItems = memoizeOne(
(
hass: HomeAssistant,
states: HassEntities | null | undefined,
formatters: HomeAssistantFormatters,
language: string,
entityId: string | string[] | undefined,
attribute: string | undefined,
hideStates: string[] | undefined,
@@ -50,20 +74,22 @@ export class HaEntityStatePicker extends LitElement {
const entitiesOptions = entityIds.map<PickerComboBoxItem[]>(
(entityIdItem) => {
const stateObj = hass.states[entityIdItem] || {
entity_id: entityIdItem,
attributes: {},
};
const stateObj =
states?.[entityIdItem] ||
({ entity_id: entityIdItem, attributes: {} } as HassEntity);
const states = getStates(hass, stateObj, attribute).filter(
(s) => !hideStates?.includes(s)
);
const options = getStates(
states ?? {},
language,
stateObj,
attribute
).filter((s) => !hideStates?.includes(s));
return states
return options
.map((s) => {
const primary = attribute
? hass.formatEntityAttributeValue(stateObj, attribute, s)
: hass.formatEntityState(stateObj, s);
? formatters.formatEntityAttributeValue(stateObj, attribute, s)
: formatters.formatEntityState(stateObj, s);
return {
id: s,
primary,
@@ -102,7 +128,9 @@ export class HaEntityStatePicker extends LitElement {
_section?: string
): PickerComboBoxItem[] =>
this._getItems(
this.hass,
this._states,
this._formatters,
this._i18n.locale.language,
this.entityId,
this.attribute,
this.hideStates,
@@ -121,19 +149,20 @@ export class HaEntityStatePicker extends LitElement {
// entity to resolve the translated name; otherwise fall back to "State".
if (this.attribute && this.entityId) {
const entityId = ensureArray(this.entityId)[0];
const stateObj = entityId ? this.hass.states[entityId] : undefined;
const stateObj = entityId ? this._stateObjs?.[entityId] : undefined;
if (stateObj) {
return this.hass.formatEntityAttributeName(stateObj, this.attribute);
return this._formatters.formatEntityAttributeName(
stateObj,
this.attribute
);
}
}
return this.hass.localize("ui.components.entity.entity-state-picker.state");
return this._i18n.localize(
"ui.components.entity.entity-state-picker.state"
);
}
protected render() {
if (!this.hass) {
return nothing;
}
const noEntity = !ensureArray(this.entityId)?.length;
return html`
@@ -147,8 +176,10 @@ export class HaEntityStatePicker extends LitElement {
.value=${this.value}
.getItems=${this._getFilteredItems}
.valueRenderer=${this._valueRenderer}
.notFoundLabel=${this.hass.localize("ui.components.combo-box.no_match")}
.customValueLabel=${this.hass.localize(
.notFoundLabel=${this._i18n.localize(
"ui.components.combo-box.no_match"
)}
.customValueLabel=${this._i18n.localize(
"ui.components.entity.entity-state-picker.add_custom_state"
)}
@value-changed=${this._valueChanged}
@@ -6,13 +6,10 @@ import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../../common/dom/fire_event";
import { ANY_STATE_VALUE } from "./const";
import { ensureArray } from "../../common/array/ensure-array";
import type { HomeAssistant } from "../../types";
import "./ha-entity-state-picker";
@customElement("ha-entity-states-picker")
export class HaEntityStatesPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId?: string;
@property() public attribute?: string;
@@ -52,10 +49,6 @@ export class HaEntityStatesPicker extends LitElement {
}
protected render() {
if (!this.hass) {
return nothing;
}
const value = this.value || [];
const hide = [...(this.hideStates || []), ...value];
const hideValue = value.includes(ANY_STATE_VALUE);
@@ -68,7 +61,6 @@ export class HaEntityStatesPicker extends LitElement {
<div>
<ha-entity-state-picker
.index=${index}
.hass=${this.hass}
.entityId=${this.entityId}
.attribute=${this.attribute}
.extraOptions=${this.extraOptions}
@@ -94,7 +86,6 @@ export class HaEntityStatesPicker extends LitElement {
: keyed(
value.length,
html`<ha-entity-state-picker
.hass=${this.hass}
.entityId=${this.entityId}
.attribute=${this.attribute}
.extraOptions=${this.extraOptions}
@@ -47,7 +47,10 @@ export class HaFavoriteEntityListItem extends LitElement {
const { primary, secondary } = stateObj
? computeEntityPickerDisplay(
{
...this._registries,
entities: this._registries.entities,
devices: this._registries.devices,
areas: this._registries.areas,
floors: this._registries.floors,
language: this._i18n.language,
translationMetadata: this._i18n.translationMetadata,
},
+94 -33
View File
@@ -1,4 +1,5 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { ContextType } from "@lit/context";
import {
mdiChartLine,
mdiHelpCircleOutline,
@@ -7,21 +8,31 @@ import {
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { consume } from "../../common/decorators/consume";
import { transform } from "../../common/decorators/transform";
import { type HASSDomEvent, fireEvent } from "../../common/dom/fire_event";
import {
computeEntityPickerDisplay,
computeEntitySearchLabels,
} from "../../common/entity/compute_entity_name_display";
import {
apiContext,
configContext,
formattersContext,
internationalizationContext,
registriesContext,
statesContext,
} from "../../data/context";
import { domainToName } from "../../data/integration";
import {
getStatisticLabel,
type StatisticsMetaData,
} from "../../data/recorder";
import { getStatisticIds } from "../../data/recorder_statistic_ids";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import type { HomeAssistantFormatters, ValueChangedEvent } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import "../ha-combo-box-item";
import "../ha-generic-picker";
@@ -72,7 +83,34 @@ declare global {
@customElement("ha-statistic-picker")
export class HaStatisticPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@state()
@consume({ context: registriesContext, subscribe: true })
private _registries!: ContextType<typeof registriesContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: formattersContext, subscribe: true })
@transform<
HomeAssistantFormatters,
HomeAssistantFormatters["formatEntityName"]
>({
transformer: (formatters) => formatters?.formatEntityName,
})
private _formatEntityName!: HomeAssistantFormatters["formatEntityName"];
@state()
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@@ -163,14 +201,17 @@ export class HaStatisticPicker extends LitElement {
}
private async _getStatisticIds() {
this.statisticIds = await getStatisticIds(this.hass, this.statisticTypes);
this.statisticIds = await getStatisticIds(this._api, this.statisticTypes);
this._picker?.requestUpdate();
this._valueRenderer = this._makeValueRenderer();
}
private _getItems = () =>
this._getStatisticsItems(
this.hass,
this._states,
this._registries,
this._i18n,
this._formatEntityName,
this.statisticIds,
this.includeStatisticsUnitOfMeasurement,
this.includeUnitClass,
@@ -183,7 +224,7 @@ export class HaStatisticPicker extends LitElement {
private _getAdditionalItems = (): StatisticComboBoxItem[] => [
{
id: MISSING_ID,
primary: this.hass.localize(
primary: this._i18n.localize(
"ui.components.statistic-picker.missing_entity"
),
icon_path: mdiHelpCircleOutline,
@@ -192,7 +233,10 @@ export class HaStatisticPicker extends LitElement {
private _getStatisticsItems = memoizeOne(
(
hass: HomeAssistant,
states: ContextType<typeof statesContext>,
registries: ContextType<typeof registriesContext>,
i18n: ContextType<typeof internationalizationContext>,
formatEntityName: HomeAssistantFormatters["formatEntityName"],
statisticIds?: StatisticsMetaData[],
includeStatisticsUnitOfMeasurement?: string | string[],
includeUnitClass?: string | string[],
@@ -201,7 +245,7 @@ export class HaStatisticPicker extends LitElement {
excludeStatistics?: string[],
value?: string
): StatisticComboBoxItem[] | undefined => {
if (!statisticIds) {
if (!statisticIds || !states || !registries) {
return undefined;
}
@@ -224,7 +268,7 @@ export class HaStatisticPicker extends LitElement {
const includeDeviceClasses: (string | null)[] =
ensureArray(includeDeviceClass);
statisticIds = statisticIds.filter((meta) => {
const stateObj = this.hass.states[meta.statistic_id];
const stateObj = states[meta.statistic_id];
if (!stateObj) {
return true;
}
@@ -244,14 +288,14 @@ export class HaStatisticPicker extends LitElement {
) {
return;
}
const stateObj = this.hass.states[meta.statistic_id];
const stateObj = states[meta.statistic_id];
if (!stateObj) {
if (!entitiesOnly) {
const id = meta.statistic_id;
const label = getStatisticLabel(
this.hass.states,
this.hass.formatEntityName,
states,
formatEntityName,
meta.statistic_id,
meta
);
@@ -266,7 +310,7 @@ export class HaStatisticPicker extends LitElement {
output.push({
id,
primary: label,
secondary: this.hass.localize(
secondary: i18n.localize(
"ui.components.statistic-picker.no_state"
),
type,
@@ -275,7 +319,7 @@ export class HaStatisticPicker extends LitElement {
});
} else if (type === "external") {
const domain = id.split(":")[0];
const domainName = domainToName(this.hass.localize, domain);
const domainName = domainToName(i18n.localize, domain);
output.push({
id,
statistic_id: id,
@@ -293,10 +337,17 @@ export class HaStatisticPicker extends LitElement {
const id = meta.statistic_id;
const { primary, secondary } = computeEntityPickerDisplay(
hass,
{
entities: registries.entities,
devices: registries.devices,
areas: registries.areas,
floors: registries.floors,
language: i18n.language,
translationMetadata: i18n.translationMetadata,
},
stateObj
);
const searchLabels = computeEntitySearchLabels(stateObj, hass);
const searchLabels = computeEntitySearchLabels(stateObj, registries);
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
output.push({
@@ -361,7 +412,7 @@ export class HaStatisticPicker extends LitElement {
? html`<ha-icon-button
slot="end"
.value=${statisticId}
.label=${this.hass.localize("ui.common.edit")}
.label=${this._i18n.localize("ui.common.edit")}
.path=${mdiPencil}
@click=${this._editItem}
></ha-icon-button>`
@@ -383,14 +434,24 @@ export class HaStatisticPicker extends LitElement {
}
private _computeItem(statisticId: string): StatisticComboBoxItem {
const stateObj = this.hass.states[statisticId];
const stateObj = this._states?.[statisticId];
if (stateObj) {
if (stateObj && this._registries) {
const { primary, secondary } = computeEntityPickerDisplay(
this.hass,
{
entities: this._registries.entities,
devices: this._registries.devices,
areas: this._registries.areas,
floors: this._registries.floors,
language: this._i18n.language,
translationMetadata: this._i18n.translationMetadata,
},
stateObj
);
const searchLabels = computeEntitySearchLabels(stateObj, this.hass);
const searchLabels = computeEntitySearchLabels(
stateObj,
this._registries
);
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
return {
@@ -422,13 +483,13 @@ export class HaStatisticPicker extends LitElement {
if (type === "external") {
const sortingPrefix = `${TYPE_ORDER.indexOf("external")}`;
const label = getStatisticLabel(
this.hass.states,
this.hass.formatEntityName,
this._states ?? {},
this._formatEntityName,
statisticId,
statistic
);
const domain = statisticId.split(":")[0];
const domainName = domainToName(this.hass.localize, domain);
const domainName = domainToName(this._i18n.localize, domain);
return {
id: statisticId,
@@ -445,8 +506,8 @@ export class HaStatisticPicker extends LitElement {
const sortingPrefix = `${TYPE_ORDER.indexOf("external")}`;
const label = getStatisticLabel(
this.hass.states,
this.hass.formatEntityName,
this._states ?? {},
this._formatEntityName,
statisticId,
statistic
);
@@ -454,7 +515,7 @@ export class HaStatisticPicker extends LitElement {
return {
id: statisticId,
primary: label,
secondary: this.hass.localize("ui.components.statistic-picker.no_state"),
secondary: this._i18n.localize("ui.components.statistic-picker.no_state"),
type: "no_state",
sorting_label: [sortingPrefix, label].join("_"),
search_labels: { label, statisticId },
@@ -466,7 +527,7 @@ export class HaStatisticPicker extends LitElement {
item,
index
) => {
const showEntityId = this.hass.userData?.showEntityIdPicker;
const showEntityId = this._config?.userData?.showEntityIdPicker;
return html`
<ha-combo-box-item .borderTop=${index !== 0}>
${
@@ -507,7 +568,7 @@ export class HaStatisticPicker extends LitElement {
protected render() {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.statistic-picker.placeholder");
this._i18n.localize("ui.components.statistic-picker.placeholder");
return html`
<ha-generic-picker
@@ -522,7 +583,7 @@ export class HaStatisticPicker extends LitElement {
.placeholder=${placeholder}
.value=${this.value}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass.localize(
.emptyLabel=${this._i18n.localize(
"ui.components.statistic-picker.no_statistics"
)}
.rowRenderer=${this._rowRenderer}
@@ -533,7 +594,7 @@ export class HaStatisticPicker extends LitElement {
.valueRenderer=${this._valueRenderer}
.helper=${this.helper}
.searchKeys=${SEARCH_KEYS}
.unknownItemText=${this.hass.localize(
.unknownItemText=${this._i18n.localize(
"ui.components.statistic-picker.unknown"
)}
@value-changed=${this._valueChanged}
@@ -566,7 +627,7 @@ export class HaStatisticPicker extends LitElement {
if (value === MISSING_ID) {
window.open(
documentationUrl(this.hass, this.helpMissingEntityUrl),
documentationUrl(this._config, this.helpMissingEntityUrl),
"_blank"
);
return;
@@ -582,7 +643,7 @@ export class HaStatisticPicker extends LitElement {
}
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.statistic-picker.no_match", {
this._i18n.localize("ui.components.statistic-picker.no_match", {
term: html`<b>‘${search}’</b>`,
});
}
@@ -2,14 +2,12 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { type HASSDomEvent, fireEvent } from "../../common/dom/fire_event";
import type { ValueChangedEvent, HomeAssistant } from "../../types";
import type { ValueChangedEvent } from "../../types";
import "./ha-statistic-picker";
import type { StatisticElementChangedEvent } from "./ha-statistic-picker";
@customElement("ha-statistics-picker")
class HaStatisticsPicker extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ type: Array }) public value?: string[];
@property({ attribute: false }) public statisticIds?: string[];
@@ -63,10 +61,6 @@ class HaStatisticsPicker extends LitElement {
@property({ attribute: "can-edit", type: Boolean }) public canEdit?;
protected render() {
if (!this.hass) {
return nothing;
}
const ignoreRestriction =
this.ignoreRestrictionsOnFirstStatistic &&
this._currentStatistics.length <= 1;
@@ -93,7 +87,6 @@ class HaStatisticsPicker extends LitElement {
<div>
<ha-statistic-picker
.curValue=${statisticId}
.hass=${this.hass}
.includeStatisticsUnitOfMeasurement=${includeStatisticsUnitCurrent}
.includeUnitClass=${includeUnitClassCurrent}
.includeDeviceClass=${includeDeviceClassCurrent}
@@ -111,7 +104,6 @@ class HaStatisticsPicker extends LitElement {
)}
<div>
<ha-statistic-picker
.hass=${this.hass}
.includeStatisticsUnitOfMeasurement=${
this.includeStatisticsUnitOfMeasurement
}
+49 -24
View File
@@ -1,10 +1,11 @@
import type { ContextType } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import Fuse from "fuse.js";
import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
import { computeEntityPickerDisplay } from "../common/entity/compute_entity_name_display";
import type { LocalizeFunc } from "../common/translations/localize";
import {
multiTermSortedSearch,
type FuseWeightedKey,
@@ -14,7 +15,11 @@ import {
getAreaControlEntities,
type AreaControlDomain,
} from "../data/area/area_controls";
import type { HomeAssistant } from "../types";
import {
internationalizationContext,
registriesContext,
statesContext,
} from "../data/context";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import "./ha-combo-box-item";
import "./ha-domain-icon";
@@ -46,8 +51,6 @@ const AREA_CONTROL_DOMAINS = [
@customElement("ha-area-controls-picker")
export class HaAreaControlsPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "area-id" }) public areaId!: string;
@property({ type: Array, attribute: "exclude-entities" })
@@ -70,6 +73,17 @@ export class HaAreaControlsPicker extends LitElement {
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@state()
@consume({ context: registriesContext, subscribe: true })
private _registries!: ContextType<typeof registriesContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
private _domainSearchKeys: FuseWeightedKey[] = [
{
name: "primary",
@@ -111,28 +125,38 @@ export class HaAreaControlsPicker extends LitElement {
excludeEntities: string[] | undefined,
currentValue: string | undefined,
excludeValues: string[] | undefined,
localize: LocalizeFunc,
_entities: HomeAssistant["entities"],
_devices: HomeAssistant["devices"],
_areas: HomeAssistant["areas"]
registries: ContextType<typeof registriesContext>,
i18n: ContextType<typeof internationalizationContext>
): ((
searchString?: string,
section?: string
) => (AreaControlPickerItem | string)[]) =>
(searchString?: string, section?: string) => {
if (!this.hass) {
const states = this._states;
if (
!states ||
!registries?.entities ||
!registries.devices ||
!registries.areas ||
!registries.floors
) {
return [];
}
const { localize } = i18n;
const isSelected = (id: string): boolean =>
currentValue === id ||
(excludeValues !== undefined && excludeValues.includes(id));
const controlEntities = getAreaControlEntities(
states,
registries.entities,
registries.devices,
registries.areas,
registries.floors,
AREA_CONTROL_DOMAINS,
areaId,
excludeEntities,
this.hass
excludeEntities
);
const items: (AreaControlPickerItem | string)[] = [];
@@ -186,13 +210,20 @@ export class HaAreaControlsPicker extends LitElement {
if (isSelected(entityId)) {
return;
}
const stateObj = this.hass!.states[entityId];
const stateObj = states[entityId];
if (!stateObj) {
return;
}
const { primary, secondary } = computeEntityPickerDisplay(
this.hass!,
{
entities: registries.entities,
devices: registries.devices,
areas: registries.areas,
floors: registries.floors,
language: i18n.language,
translationMetadata: i18n.translationMetadata,
},
stateObj
);
@@ -275,10 +306,6 @@ export class HaAreaControlsPicker extends LitElement {
`;
protected render() {
if (!this.hass) {
return nothing;
}
return html`
<ha-generic-picker
.value=${this.value || ""}
@@ -293,22 +320,20 @@ export class HaAreaControlsPicker extends LitElement {
this.excludeEntities,
this.value,
this.excludeValues,
this.hass.localize,
this.hass.entities,
this.hass.devices,
this.hass.areas
this._registries,
this._i18n
)}
.rowRenderer=${this._rowRenderer as any}
.sections=${[
{
id: "domain",
label: this.hass.localize(
label: this._i18n.localize(
"ui.panel.lovelace.editor.features.types.area-controls.sections.domain"
),
},
{
id: "entity",
label: this.hass.localize(
label: this._i18n.localize(
"ui.panel.lovelace.editor.features.types.area-controls.sections.entity"
),
},
@@ -245,15 +245,15 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
const newOrder: string[] = [];
for (const floorId of floorIds) {
const isOnFloor = (areaId: string) =>
(this._areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR) === floorId;
if ((currentFloorId ?? UNASSIGNED_FLOOR) === floorId) {
newHidden.push(...(value.hidden ?? []));
// Reordering returns the hidden areas of every floor, not just this one
newHidden.push(...(value.hidden ?? []).filter(isOnFloor));
newOrder.push(...(value.order ?? []));
continue;
}
const hidden = oldHidden.filter((areaId) => {
const id = this._areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR;
return id === floorId;
});
const hidden = oldHidden.filter(isOnFloor);
if (hidden?.length) {
newHidden.push(...hidden);
}
+16 -9
View File
@@ -1,11 +1,14 @@
import type { ContextType } from "@lit/context";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consume } from "../common/decorators/consume";
import { fireEvent } from "../common/dom/fire_event";
import { formatLanguageCode } from "../common/language/format_language";
import type { AssistPipeline } from "../data/assist_pipeline";
import { listAssistPipelines } from "../data/assist_pipeline";
import type { HomeAssistant } from "../types";
import { apiContext, internationalizationContext } from "../data/context";
import type { HomeAssistantInternationalization } from "../types";
import type { HaSelectOption, HaSelectSelectEvent } from "./ha-select";
import "./ha-select";
@@ -18,8 +21,6 @@ export class HaAssistPipelinePicker extends LitElement {
@property() public label?: string;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public disabled = false;
@property({ type: Boolean }) public required = false;
@@ -30,6 +31,13 @@ export class HaAssistPipelinePicker extends LitElement {
@state() _preferredPipeline: string | null = null;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: HomeAssistantInternationalization;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
private get _default() {
return this.includeLastUsed ? LAST_USED : PREFERRED;
}
@@ -42,7 +50,7 @@ export class HaAssistPipelinePicker extends LitElement {
const options: HaSelectOption[] = [
{
value: PREFERRED,
label: this.hass.localize("ui.components.pipeline-picker.preferred", {
label: this._i18n.localize("ui.components.pipeline-picker.preferred", {
preferred: this._pipelines.find(
(pipeline) => pipeline.id === this._preferredPipeline
)?.name,
@@ -53,14 +61,14 @@ export class HaAssistPipelinePicker extends LitElement {
if (this.includeLastUsed) {
options.unshift({
value: LAST_USED,
label: this.hass.localize("ui.components.pipeline-picker.last_used"),
label: this._i18n.localize("ui.components.pipeline-picker.last_used"),
});
}
options.push(
...this._pipelines.map((pipeline) => ({
value: pipeline.id,
label: `${pipeline.name} (${formatLanguageCode(pipeline.language, this.hass.locale)})`,
label: `${pipeline.name} (${formatLanguageCode(pipeline.language, this._i18n.locale)})`,
}))
);
@@ -68,7 +76,7 @@ export class HaAssistPipelinePicker extends LitElement {
<ha-select
.label=${
this.label ||
this.hass!.localize("ui.components.pipeline-picker.pipeline")
this._i18n.localize("ui.components.pipeline-picker.pipeline")
}
.value=${value}
.required=${this.required}
@@ -82,7 +90,7 @@ export class HaAssistPipelinePicker extends LitElement {
protected firstUpdated(changedProperties: PropertyValues<this>): void {
super.firstUpdated(changedProperties);
listAssistPipelines(this.hass).then((pipelines) => {
listAssistPipelines(this._api.callWS).then((pipelines) => {
this._pipelines = pipelines.pipelines;
this._preferredPipeline = pipelines.preferred_pipeline;
});
@@ -97,7 +105,6 @@ export class HaAssistPipelinePicker extends LitElement {
private _changed(ev: HaSelectSelectEvent): void {
const value = ev.detail.value;
if (
!this.hass ||
value === "" ||
value === this.value ||
(this.value === undefined && value === this._default)
@@ -26,10 +26,7 @@ import "./ha-sortable";
type ClockDatePartSection = "weekday" | "day" | "month" | "year" | "separator";
type ClockDateSeparatorPart = Extract<
ClockCardDatePart,
"separator-dash" | "separator-slash" | "separator-dot" | "separator-new-line"
>;
type ClockDateSeparatorPart = Extract<ClockCardDatePart, `separator-${string}`>;
const CLOCK_DATE_PART_SECTION_ORDER: readonly ClockDatePartSection[] = [
"day",
@@ -43,6 +40,7 @@ const CLOCK_DATE_SEPARATOR_VALUES: Record<ClockDateSeparatorPart, string> = {
"separator-dash": "-",
"separator-slash": "/",
"separator-dot": ".",
"separator-comma": ",",
"separator-new-line": "",
};
+23 -13
View File
@@ -1,9 +1,14 @@
import type { ContextType } from "@lit/context";
import { mdiCog } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consume } from "../common/decorators/consume";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import { debounce } from "../common/util/debounce";
import { apiContext, configContext, entitiesContext } from "../data/context";
import type { Agent } from "../data/conversation";
import { listAgents } from "../data/conversation";
import type { EntityConfigFlow } from "../dialogs/config-flow/entity-config-flow";
@@ -11,7 +16,6 @@ import {
fetchEntityConfigFlow,
showEntityConfigFlow,
} from "../dialogs/config-flow/entity-config-flow";
import type { HomeAssistant } from "../types";
import "./ha-select";
import type { HaSelectOption, HaSelectSelectEvent } from "./ha-select";
@@ -25,8 +29,6 @@ export class HaConversationAgentPicker extends LitElement {
@property() public label?: string;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public disabled = false;
@property({ type: Boolean }) public required = false;
@@ -35,6 +37,17 @@ export class HaConversationAgentPicker extends LitElement {
@state() private _configFlow?: EntityConfigFlow;
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
@consume({ context: entitiesContext, subscribe: true })
private _entities!: ContextType<typeof entitiesContext>;
protected render() {
if (!this._agents) {
return nothing;
@@ -55,9 +68,7 @@ export class HaConversationAgentPicker extends LitElement {
if (!this.required) {
options.unshift({
value: NONE,
label: this.hass.localize(
"ui.components.conversation-agent-picker.none"
),
label: this._localize("ui.components.conversation-agent-picker.none"),
});
}
@@ -65,7 +76,7 @@ export class HaConversationAgentPicker extends LitElement {
<ha-select
.label=${
this.label ||
this.hass!.localize(
this._localize(
"ui.components.conversation-agent-picker.conversation_agent"
)
}
@@ -78,7 +89,7 @@ export class HaConversationAgentPicker extends LitElement {
>${
this._configFlow
? html`<ha-icon-button
.label=${this.hass.localize(
.label=${this._localize(
"ui.components.conversation-agent-picker.settings"
)}
.path=${mdiCog}
@@ -108,7 +119,7 @@ export class HaConversationAgentPicker extends LitElement {
let configFlow: EntityConfigFlow | undefined;
try {
configFlow = entityId
? await fetchEntityConfigFlow(this.hass, entityId)
? await fetchEntityConfigFlow(this._api, this._entities, entityId)
: undefined;
} catch (_err) {
configFlow = undefined;
@@ -122,9 +133,9 @@ export class HaConversationAgentPicker extends LitElement {
private async _updateAgents() {
const { agents } = await listAgents(
this.hass,
this._api.callWS,
this.language,
this.hass.config.country || undefined
this._config.config.country || undefined
);
this._agents = agents;
@@ -184,7 +195,7 @@ export class HaConversationAgentPicker extends LitElement {
private _openConfigFlow() {
if (this._configFlow) {
showEntityConfigFlow(this, this.hass, this._configFlow);
showEntityConfigFlow(this, this._api, this._configFlow);
}
}
@@ -204,7 +215,6 @@ export class HaConversationAgentPicker extends LitElement {
private _changed(ev: HaSelectSelectEvent): void {
const value = ev.detail.value;
if (
!this.hass ||
value === "" ||
value === this.value ||
(this.value === undefined && value === NONE)
+65 -33
View File
@@ -1,7 +1,9 @@
import type { ContextType } from "@lit/context";
import { mdiPlus } from "@mdi/js";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { repeat } from "lit/directives/repeat";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
@@ -26,6 +28,10 @@ import "./chips/ha-input-chip";
import "./ha-combo-box-item";
import "./ha-domain-icon";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-button";
import "./ha-svg-icon";
import "./ha-input-helper-text";
import type {
PickerComboBoxItem,
PickerComboBoxSearchFn,
@@ -82,6 +88,8 @@ export class HaEntityTypePicker extends LitElement {
@consume({ context: statesContext, subscribe: true })
private _states?: ContextType<typeof statesContext>;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
private _titlesLoaded = false;
protected willUpdate() {
@@ -237,40 +245,13 @@ export class HaEntityTypePicker extends LitElement {
const localize = this._i18n?.localize;
const value = this.value ?? [];
const chipLabels = localize ? this._chipLabels(localize, value) : undefined;
const addLabel = localize?.("ui.common.add");
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
${
value.length && chipLabels
? html`
<ha-chip-set>
${repeat(
value,
(key) => key,
(key) => {
const label = chipLabels.get(key)!;
return html`
<ha-input-chip
data-key=${key}
.label=${label}
.disabled=${this.disabled}
@remove=${this._removeItem}
selected
>
${this._renderIcon(key, "icon")} ${label}
</ha-input-chip>
`;
}
)}
</ha-chip-set>
`
: nothing
}
${this.label ? html`<label id="label">${this.label}</label>` : nothing}
<ha-generic-picker
.helper=${this.helper}
.label=${this.label}
.disabled=${this.disabled}
.value=${""}
.addButtonLabel=${localize?.("ui.components.entity-type-picker.add")}
.getItems=${this._getItems}
.rowRenderer=${this._rowRenderer}
.searchFn=${this._searchFn}
@@ -278,10 +259,53 @@ export class HaEntityTypePicker extends LitElement {
.emptyLabel=${localize?.("ui.components.entity-type-picker.no_types")}
no-sort
@value-changed=${this._itemAdded}
></ha-generic-picker>
>
<ha-chip-set
slot="field"
aria-labelledby=${ifDefined(this.label ? "label" : undefined)}
>
${repeat(
value,
(key) => key,
(key) => {
const label = chipLabels?.get(key) ?? key;
return html`
<ha-input-chip
data-key=${key}
.label=${label}
.disabled=${this.disabled}
@remove=${this._removeItem}
selected
>
${this._renderIcon(key, "icon")} ${label}
</ha-input-chip>
`;
}
)}
<ha-button
size="s"
appearance="filled"
.disabled=${this.disabled}
@click=${this._openPicker}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${addLabel}
</ha-button>
</ha-chip-set>
</ha-generic-picker>
${
this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing
}
`;
}
private _openPicker(ev: Event) {
ev.stopPropagation();
this._picker?.open();
}
private _itemAdded(ev: CustomEvent<{ value?: string }>) {
ev.stopPropagation();
const key = ev.detail.value;
@@ -320,7 +344,15 @@ export class HaEntityTypePicker extends LitElement {
width: 100%;
}
ha-chip-set {
padding: var(--ha-space-2) 0;
background-color: var(--mdc-text-field-fill-color);
border-bottom: 1px solid var(--ha-color-border-neutral-normal);
border-top-right-radius: var(--ha-border-radius-sm);
border-top-left-radius: var(--ha-border-radius-sm);
padding: var(--ha-space-3);
}
ha-input-helper-text {
padding: 0;
margin-top: var(--ha-space-1);
}
`;
}
+40 -32
View File
@@ -1,5 +1,4 @@
import type { ContextType } from "@lit/context";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -12,6 +11,11 @@ import {
import { consume } from "../common/decorators/consume";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { arrayWith, arrayWithout } from "../common/array/array-with";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
} from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event";
import { deepEqual } from "../common/util/deep-equal";
import type { Blueprints } from "../data/blueprint";
@@ -19,11 +23,12 @@ import { fetchBlueprints } from "../data/blueprint";
import { apiContext } from "../data/context";
import type { RelatedResult } from "../data/search";
import { findRelated } from "../data/search";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-icon-button";
import "./ha-list";
import "./item/ha-list-item-option";
import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-selectable";
import type { HaListSelectable } from "./list/ha-list-selectable";
@customElement("ha-filter-blueprints")
export class HaFilterBlueprints extends LitElement {
@@ -88,23 +93,30 @@ export class HaFilterBlueprints extends LitElement {
${
this._blueprints
? html`
<ha-list
@selected=${this._blueprintsSelected}
<ha-list-selectable
multi
class="ha-scrollbar"
aria-label=${this._localize(
"ui.panel.config.blueprint.caption"
)}
@ha-list-item-selected=${this._handleItemToggled}
@ha-list-item-deselected=${this._handleItemToggled}
>
${Object.entries(this._blueprints).map(
([id, blueprint]) =>
"error" in blueprint
? nothing
: html`<ha-check-list-item
: html`<ha-list-item-option
appearance="checkbox"
selection-position="end"
.value=${id}
.selected=${(this.value || []).includes(id)}
.selected=${this.value?.includes(id) ?? false}
>
${blueprint.metadata.name || id}
</ha-check-list-item>`
<span slot="headline"
>${blueprint.metadata.name || id}</span
>
</ha-list-item-option>`
)}
</ha-list>
</ha-list-selectable>
`
: nothing
}
@@ -126,28 +138,20 @@ export class HaFilterBlueprints extends LitElement {
this.expanded = ev.detail.expanded;
}
private async _blueprintsSelected(
ev: CustomEvent<SelectedDetail<Set<number>>>
private _handleItemToggled(
ev: HASSDomEvent<number> & HASSDomCurrentTargetEvent<HaListSelectable>
) {
const blueprints = this._blueprints!;
if (!ev.detail.index.size) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
const blueprintId = (
ev.currentTarget.items[ev.detail] as HaListItemOption | undefined
)?.value;
if (!blueprintId) {
return;
}
const value: string[] = [];
for (const index of ev.detail.index) {
const blueprintId = Object.keys(blueprints)[index];
value.push(blueprintId);
}
this.value = value;
this.value =
ev.type === "ha-list-item-selected"
? arrayWith(this.value, blueprintId)
: arrayWithout(this.value, blueprintId);
}
private async _findRelated() {
@@ -202,10 +206,14 @@ export class HaFilterBlueprints extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list {
ha-list-selectable {
display: flex;
flex: 1;
min-height: 0;
}
ha-list-selectable::part(base) {
flex: 1;
min-height: 0;
}
+11 -7
View File
@@ -35,12 +35,12 @@ import { showConfirmationDialog } from "../dialogs/generic/show-dialog-box";
import { showCategoryRegistryDetailDialog } from "../panels/config/category/show-dialog-category-registry-detail";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistantConnection } from "../types";
import "./ha-button";
import "./ha-dropdown";
import type { HaDropdownSelectEvent } from "./ha-dropdown";
import "./ha-dropdown-item";
import "./ha-expansion-panel";
import "./ha-icon";
import "./ha-list-item";
import "./item/ha-list-item-option";
import "./list/ha-list-selectable";
@@ -226,10 +226,14 @@ export class HaFilterCategories extends LitElement {
</ha-list-item-option>`
)}
</ha-list-selectable>
<ha-list-item graphic="icon" @click=${this._addCategory}>
<ha-svg-icon slot="graphic" .path=${mdiPlus}></ha-svg-icon>
<ha-button
appearance="filled"
size="s"
@click=${this._addCategory}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this._localize("ui.panel.config.category.editor.add")}
</ha-list-item>
</ha-button>
</div>
`
: nothing
@@ -386,12 +390,12 @@ export class HaFilterCategories extends LitElement {
ha-list-item-option {
--ha-row-item-padding-block: var(--ha-space-2);
}
ha-button {
margin: var(--ha-space-2) var(--ha-space-4);
}
ha-list-item-option ha-icon-button {
--ha-icon-button-size: 32px;
}
ha-list-item {
--mdc-list-item-graphic-margin: var(--ha-space-4);
}
ha-dropdown-item {
font-size: var(--ha-font-size-m);
}
+9 -1
View File
@@ -85,6 +85,15 @@ export class HaFilterDevices extends LitElement {
}
}
protected updated(properties: PropertyValues<this>) {
super.updated(properties);
// The rows don't change with the value, so render the selection again
if (properties.has("value")) {
this._listElement?.requestRowsUpdate();
}
}
protected render() {
return html`
<ha-expansion-panel
@@ -269,7 +278,6 @@ export class HaFilterDevices extends LitElement {
value: undefined,
items: undefined,
});
this._listElement?.clearSelection();
}
static styles = [
+44 -38
View File
@@ -1,5 +1,4 @@
import type { ContextType } from "@lit/context";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -13,17 +12,23 @@ import {
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { arrayWith, arrayWithout } from "../common/array/array-with";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
} from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext, statesContext } from "../data/context";
import { domainToName } from "../data/integration";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-check-list-item";
import "./ha-domain-icon";
import "./ha-expansion-panel";
import "./ha-list";
import "./item/ha-list-item-option";
import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-selectable";
import type { HaListSelectable } from "./list/ha-list-selectable";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
@@ -82,35 +87,38 @@ export class HaFilterDomains extends LitElement {
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@selected=${this._handleItemSelected}
<ha-list-selectable
multi
aria-label=${this._localize("ui.panel.config.domains.caption")}
@ha-list-item-selected=${this._handleItemToggled}
@ha-list-item-deselected=${this._handleItemToggled}
>
${repeat(
this._domains(
this._states,
this._localize,
this._i18n.locale.language,
this._filter,
this.value
this._filter
),
(i) => i,
(domain) =>
html`<ha-check-list-item
html`<ha-list-item-option
appearance="checkbox"
selection-position="end"
.value=${domain}
.selected=${(this.value || []).includes(domain)}
graphic="icon"
.selected=${this.value?.includes(domain) ?? false}
>
<ha-domain-icon
slot="graphic"
slot="start"
.domain=${domain}
brand-fallback
></ha-domain-icon>
${domainToName(this._localize, domain)}
</ha-check-list-item>`
<span slot="headline"
>${domainToName(this._localize, domain)}</span
>
</ha-list-item-option>`
)}
</ha-list>
</ha-list-selectable>
</div>`
: nothing
}
@@ -122,8 +130,7 @@ export class HaFilterDomains extends LitElement {
states: ContextType<typeof statesContext>,
localize: LocalizeFunc,
language: string | undefined,
filter: string | undefined,
_value
filter: string | undefined
) => {
const domains = new Set<string>();
Object.keys(states).forEach((entityId) => {
@@ -150,22 +157,20 @@ export class HaFilterDomains extends LitElement {
this.expanded = ev.detail.expanded;
}
private _handleItemSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
const domains = this._domains(
this._states,
this._localize,
this._i18n.locale.language,
this._filter,
this.value
);
private _handleItemToggled(
ev: HASSDomEvent<number> & HASSDomCurrentTargetEvent<HaListSelectable>
) {
const domain = (
ev.currentTarget.items[ev.detail] as HaListItemOption | undefined
)?.value;
if (!domain) {
return;
}
const visibleDomains = new Set(domains);
const preserved = (this.value || []).filter((d) => !visibleDomains.has(d));
const selected = [...ev.detail.index]
.map((i) => domains[i])
.filter((d): d is string => !!d);
this.value = [...preserved, ...selected];
this.value =
ev.type === "ha-list-item-selected"
? arrayWith(this.value, domain)
: arrayWithout(this.value, domain);
fireEvent(this, "data-table-filter-changed", {
value: this.value.length ? this.value : undefined,
@@ -189,10 +194,14 @@ export class HaFilterDomains extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list {
ha-list-selectable {
display: flex;
flex: 1;
min-height: 0;
}
ha-list-selectable::part(base) {
flex: 1;
min-height: 0;
}
@@ -204,9 +213,6 @@ export class HaFilterDomains extends LitElement {
margin-inline-start: auto;
margin-inline-end: 8px;
}
ha-check-list-item {
--mdc-list-item-graphic-margin: var(--ha-space-4);
}
.badge {
display: inline-block;
margin-left: 8px;
+58 -62
View File
@@ -3,7 +3,7 @@ import type { HassEntity } from "home-assistant-js-websocket";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
@@ -12,6 +12,7 @@ import {
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { arrayWith, arrayWithout } from "../common/array/array-with";
import { fireEvent } from "../common/dom/fire_event";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { stringCompare } from "../common/string/compare";
@@ -25,16 +26,16 @@ import {
} from "../data/context";
import type { RelatedResult } from "../data/search";
import { findRelated } from "../data/search";
import { haStyleScrollbar } from "../resources/styles";
import { loadVirtualizer } from "../resources/virtualizer";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-list";
import "./ha-state-icon";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
import "./item/ha-list-item-option";
import "./list/ha-list-selectable-virtualized";
import type { HaListSelectableVirtualized } from "./list/ha-list-selectable-virtualized";
import type { HaListVirtualizedItem } from "./list/ha-list-virtualized";
interface EntityItem {
interface EntityItem extends HaListVirtualizedItem {
stateObj: HassEntity;
name: string;
}
@@ -74,15 +75,14 @@ export class HaFilterEntities extends LitElement {
private _panel = new FilterPanelController(this, this._content);
@query("ha-list-selectable-virtualized")
private _list?: HaListSelectableVirtualized;
private _relatedRequestId = 0;
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
if (!this.hasUpdated) {
loadVirtualizer();
}
if (
properties.has("value") &&
!deepEqual(this.value, properties.get("value"))
@@ -91,6 +91,15 @@ export class HaFilterEntities extends LitElement {
}
}
protected updated(properties: PropertyValues<this>) {
super.updated(properties);
// The rows don't change with the value, so render the selection again
if (properties.has("value")) {
this._list?.requestRowsUpdate();
}
}
protected render() {
return html`
<ha-expansion-panel
@@ -121,23 +130,14 @@ export class HaFilterEntities extends LitElement {
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list class="ha-scrollbar" multi>
<lit-virtualizer
.items=${this._entities(
this._states,
this._formatters,
this.type,
this._filter || "",
this._i18n.locale.language,
this.value
)}
.keyFunction=${this._keyFunction}
.renderItem=${this._renderItem}
@click=${this._handleItemClick}
@keydown=${this._handleItemKeydown}
>
</lit-virtualizer>
</ha-list>
<ha-list-selectable-virtualized
multi
aria-label=${this._localize("ui.panel.config.entities.caption")}
.rows=${this._currentEntities()}
.rowRenderer=${this._renderItem}
@ha-list-item-selected=${this._handleItemToggled}
@ha-list-item-deselected=${this._handleItemToggled}
></ha-list-selectable-virtualized>
</div>
`
: nothing
@@ -145,43 +145,43 @@ export class HaFilterEntities extends LitElement {
`;
}
private _keyFunction = (item?: EntityItem) => item?.stateObj.entity_id;
private _renderItem = (item?: EntityItem) =>
!item
? nothing
: html`<ha-check-list-item
tabindex="0"
.value=${item.stateObj.entity_id}
.selected=${this.value?.includes(item.stateObj.entity_id) ?? false}
graphic="icon"
: html`<ha-list-item-option
style="width: 100%;"
appearance="checkbox"
selection-position="end"
.value=${item.id}
.selected=${this.value?.includes(item.id) ?? false}
>
<ha-state-icon
slot="graphic"
slot="start"
.stateObj=${item.stateObj}
></ha-state-icon>
${item.name}
</ha-check-list-item>`;
<span slot="headline">${item.name}</span>
</ha-list-item-option>`;
private _handleItemKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
this._handleItemClick(ev);
}
}
private _handleItemClick(ev) {
const listItem = ev.target.closest("ha-check-list-item");
const value = listItem?.value;
if (!value) {
private _handleItemToggled(ev: CustomEvent<number>) {
const entityId = this._currentEntities()[ev.detail]?.id;
if (!entityId) {
return;
}
if (this.value?.includes(value)) {
this.value = this.value?.filter((val) => val !== value);
} else {
this.value = [...(this.value || []), value];
}
listItem.selected = this.value?.includes(value);
this.value =
ev.type === "ha-list-item-selected"
? arrayWith(this.value, entityId)
: arrayWithout(this.value, entityId);
}
private _currentEntities() {
return this._entities(
this._states,
this._formatters,
this.type,
this._filter || "",
this._i18n.locale.language
);
}
private _expandedChanged(ev) {
@@ -199,12 +199,13 @@ export class HaFilterEntities extends LitElement {
formatters: ContextType<typeof formattersContext>,
type: this["type"],
filter: string,
language: string | undefined,
_value
language: string | undefined
): EntityItem[] =>
Object.values(states)
.filter((stateObj) => !type || computeStateDomain(stateObj) !== type)
.map((stateObj) => ({
id: stateObj.entity_id,
interactive: true,
stateObj,
name: formatters.formatEntityName(stateObj),
}))
@@ -267,10 +268,9 @@ export class HaFilterEntities extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list {
ha-list-selectable-virtualized {
flex: 1;
min-height: 0;
}
@@ -298,10 +298,6 @@ export class HaFilterEntities extends LitElement {
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-check-list-item {
--mdc-list-item-graphic-margin: 16px;
width: 100%;
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
+43 -40
View File
@@ -1,4 +1,3 @@
import type { SelectedDetail } from "@material/mwc-list";
import type { ContextType } from "@lit/context";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup } from "lit";
@@ -13,17 +12,23 @@ import {
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { arrayWith, arrayWithout } from "../common/array/array-with";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
} from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext, manifestsContext } from "../data/context";
import type { IntegrationManifest } from "../data/integration";
import { domainToName } from "../data/integration";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-check-list-item";
import "./ha-domain-icon";
import "./ha-expansion-panel";
import "./ha-list";
import "./item/ha-list-item-option";
import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-selectable";
import type { HaListSelectable } from "./list/ha-list-selectable";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
@@ -92,37 +97,41 @@ export class HaFilterIntegrations extends LitElement {
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@selected=${this._itemSelected}
<ha-list-selectable
multi
aria-label=${this._localize(
"ui.panel.config.integrations.caption"
)}
@ha-list-item-selected=${this._handleItemToggled}
@ha-list-item-deselected=${this._handleItemToggled}
>
${repeat(
this._integrations(
this._localize,
manifests,
this._filter,
this.value,
this._i18n.locale.language
),
(i) => i.domain,
(integration) =>
html`<ha-check-list-item
html`<ha-list-item-option
appearance="checkbox"
selection-position="end"
.value=${integration.domain}
.selected=${(this.value || []).includes(
integration.domain
)}
graphic="icon"
.selected=${
this.value?.includes(integration.domain) ??
false
}
>
<ha-domain-icon
slot="graphic"
slot="start"
.domain=${integration.domain}
brand-fallback
></ha-domain-icon>
${integration.name}
</ha-check-list-item>`
<span slot="headline">${integration.name}</span>
</ha-list-item-option>`
)}
</ha-list>`
</ha-list-selectable>`
: nothing
}
</div>`
@@ -144,7 +153,6 @@ export class HaFilterIntegrations extends LitElement {
localize: LocalizeFunc,
manifest: IntegrationManifest[],
filter: string | undefined,
_value: string[] | undefined,
language: string
) =>
manifest
@@ -165,26 +173,20 @@ export class HaFilterIntegrations extends LitElement {
.sort((a, b) => stringCompare(a.name, b.name, language))
);
private _itemSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
if (!this._manifests) {
private _handleItemToggled(
ev: HASSDomEvent<number> & HASSDomCurrentTargetEvent<HaListSelectable>
) {
const domain = (
ev.currentTarget.items[ev.detail] as HaListItemOption | undefined
)?.value;
if (!domain) {
return;
}
const integrations = this._integrations(
this._localize,
this._manifestList(this._manifests),
this._filter,
this.value,
this._i18n.locale.language
);
const visibleDomains = new Set(integrations.map((i) => i.domain));
const preserved = (this.value || []).filter((d) => !visibleDomains.has(d));
const selected = [...ev.detail.index]
.map((i) => integrations[i]?.domain)
.filter((d): d is string => !!d);
this.value = [...preserved, ...selected];
this.value =
ev.type === "ha-list-item-selected"
? arrayWith(this.value, domain)
: arrayWithout(this.value, domain);
fireEvent(this, "data-table-filter-changed", {
value: this.value.length ? this.value : undefined,
@@ -208,10 +210,14 @@ export class HaFilterIntegrations extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list {
ha-list-selectable {
display: flex;
flex: 1;
min-height: 0;
}
ha-list-selectable::part(base) {
flex: 1;
min-height: 0;
}
@@ -223,9 +229,6 @@ export class HaFilterIntegrations extends LitElement {
margin-inline-start: auto;
margin-inline-end: 8px;
}
ha-check-list-item {
--mdc-list-item-graphic-margin: var(--ha-space-4);
}
.badge {
display: inline-block;
margin-left: 8px;
+51 -49
View File
@@ -1,5 +1,4 @@
import type { ContextType } from "@lit/context";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiCog, mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
@@ -13,22 +12,29 @@ import {
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { arrayWith, arrayWithout } from "../common/array/array-with";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
} from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event";
import { navigate } from "../common/navigate";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext, labelsContext } from "../data/context";
import type { LabelRegistryEntry } from "../data/label/label_registry";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-icon";
import "./ha-icon-button";
import "./ha-label";
import "./ha-list";
import "./ha-list-item";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
import "./item/ha-list-item-button";
import "./item/ha-list-item-option";
import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-base";
import "./list/ha-list-selectable";
import type { HaListSelectable } from "./list/ha-list-selectable";
@customElement("ha-filter-labels")
export class HaFilterLabels extends LitElement {
@@ -57,12 +63,10 @@ export class HaFilterLabels extends LitElement {
private _panel = new FilterPanelController(this, this._content);
private _filteredLabels = memoizeOne(
// `_value` used to recalculate the memoization when the selection changes
(
labels: LabelRegistryEntry[],
filter: string | undefined,
language: string | undefined,
_value
language: string | undefined
) =>
labels
.filter(
@@ -105,26 +109,28 @@ export class HaFilterLabels extends LitElement {
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
@selected=${this._labelSelected}
class="ha-scrollbar"
<ha-list-selectable
multi
aria-label=${this._localize("ui.panel.config.labels.caption")}
@ha-list-item-selected=${this._handleItemToggled}
@ha-list-item-deselected=${this._handleItemToggled}
>
${repeat(
this._filteredLabels(
this._labels || [],
this._filter,
this._i18n.locale.language,
this.value
this._i18n.locale.language
),
(label) => label.label_id,
(label) =>
html`<ha-check-list-item
html`<ha-list-item-option
appearance="checkbox"
selection-position="end"
.value=${label.label_id}
.selected=${(this.value || []).includes(label.label_id)}
hasMeta
.selected=${this.value?.includes(label.label_id) ?? false}
>
<ha-label
slot="headline"
.color=${label.color}
.description=${label.description}
>
@@ -138,13 +144,17 @@ export class HaFilterLabels extends LitElement {
}
${label.name}
</ha-label>
</ha-check-list-item>`
</ha-list-item-option>`
)}
</ha-list>
<ha-list-item graphic="icon" @click=${this._manageLabels}>
<ha-svg-icon slot="graphic" .path=${mdiCog}></ha-svg-icon>
${this._localize("ui.panel.config.labels.manage_labels")}
</ha-list-item>
</ha-list-selectable>
<ha-list-base>
<ha-list-item-button @click=${this._manageLabels}>
<ha-svg-icon slot="start" .path=${mdiCog}></ha-svg-icon>
<span slot="headline"
>${this._localize("ui.panel.config.labels.manage_labels")}</span
>
</ha-list-item-button>
</ha-list-base>
</div>`
: nothing
}
@@ -164,35 +174,23 @@ export class HaFilterLabels extends LitElement {
this._filter = value.toLowerCase();
}
private async _labelSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
const filteredLabels = this._filteredLabels(
this._labels || [],
this._filter,
this._i18n.locale.language,
this.value
);
const filteredLabelIds = new Set(filteredLabels.map((l) => l.label_id));
// Keep previously selected labels that are not in the current filtered view
const preservedLabels = (this.value || []).filter(
(id) => !filteredLabelIds.has(id)
);
// Build the new selection from the filtered labels based on selected indices
const newlySelectedLabels: string[] = [];
for (const index of ev.detail.index) {
const labelId = filteredLabels[index]?.label_id;
if (labelId) {
newlySelectedLabels.push(labelId);
}
private _handleItemToggled(
ev: HASSDomEvent<number> & HASSDomCurrentTargetEvent<HaListSelectable>
) {
const labelId = (
ev.currentTarget.items[ev.detail] as HaListItemOption | undefined
)?.value;
if (!labelId) {
return;
}
const value = [...preservedLabels, ...newlySelectedLabels];
this.value = value.length ? value : [];
this.value =
ev.type === "ha-list-item-selected"
? arrayWith(this.value, labelId)
: arrayWithout(this.value, labelId);
fireEvent(this, "data-table-filter-changed", {
value: value.length ? value : undefined,
value: this.value.length ? this.value : undefined,
items: undefined,
});
}
@@ -208,10 +206,14 @@ export class HaFilterLabels extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list {
ha-list-selectable {
display: flex;
flex: 1;
min-height: 0;
}
ha-list-selectable::part(base) {
flex: 1;
min-height: 0;
}
+39 -31
View File
@@ -1,20 +1,26 @@
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { createRef, ref } from "lit/directives/ref";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { arrayWith, arrayWithout } from "../common/array/array-with";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
} from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-icon";
import "./ha-icon-button";
import "./ha-list";
import "./item/ha-list-item-option";
import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-selectable";
import type { HaListSelectable } from "./list/ha-list-selectable";
@customElement("ha-filter-states")
export class HaFilterStates extends LitElement {
@@ -40,7 +46,6 @@ export class HaFilterStates extends LitElement {
if (!this.states) {
return nothing;
}
const hasIcon = this.states.find((item) => item.icon);
return html`
<ha-expansion-panel
left-chevron
@@ -64,30 +69,32 @@ export class HaFilterStates extends LitElement {
this._panel.showContent
? html`
<div class="content" ${ref(this._content)}>
<ha-list
@selected=${this._statesSelected}
<ha-list-selectable
multi
class="ha-scrollbar"
aria-label=${ifDefined(this.label)}
@ha-list-item-selected=${this._handleItemToggled}
@ha-list-item-deselected=${this._handleItemToggled}
>
${this.states.map(
(item) =>
html`<ha-check-list-item
html`<ha-list-item-option
appearance="checkbox"
selection-position="end"
.value=${item.value}
.selected=${this.value?.includes(item.value) ?? false}
.graphic=${hasIcon ? "icon" : null}
>
${
item.icon
? html`<ha-icon
slot="graphic"
slot="start"
.icon=${item.icon}
></ha-icon>`
: nothing
}
${item.label}
</ha-check-list-item>`
<span slot="headline">${item.label}</span>
</ha-list-item-option>`
)}
</ha-list>
</ha-list-selectable>
</div>
`
: nothing
@@ -99,26 +106,23 @@ export class HaFilterStates extends LitElement {
this.expanded = ev.detail.expanded;
}
private async _statesSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
if (!ev.detail.index.size) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
private _handleItemToggled(
ev: HASSDomEvent<number> & HASSDomCurrentTargetEvent<HaListSelectable>
) {
const state = (
ev.currentTarget.items[ev.detail] as HaListItemOption | undefined
)?.value;
if (!state) {
return;
}
const value: string[] = [];
for (const index of ev.detail.index) {
const val = this.states![index].value;
value.push(val);
}
this.value = value;
this.value =
ev.type === "ha-list-item-selected"
? arrayWith(this.value, state)
: arrayWithout(this.value, state);
fireEvent(this, "data-table-filter-changed", {
value,
value: this.value,
items: undefined,
});
}
@@ -134,10 +138,14 @@ export class HaFilterStates extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list {
ha-list-selectable {
display: flex;
flex: 1;
min-height: 0;
}
ha-list-selectable::part(base) {
flex: 1;
min-height: 0;
}
+41 -35
View File
@@ -1,4 +1,3 @@
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
@@ -11,15 +10,19 @@ import {
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { arrayWith, arrayWithout } from "../common/array/array-with";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
} from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-icon";
import "./ha-icon-button";
import "./ha-label";
import "./ha-list";
import "./ha-list-item";
import "./item/ha-list-item-option";
import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-selectable";
import type { HaListSelectable } from "./list/ha-list-selectable";
import "./voice-assistant-brand-icon";
import { voiceAssistants } from "../data/expose";
@@ -65,30 +68,35 @@ export class HaFilterVoiceAssistants extends LitElement {
${
this._panel.showContent
? html`<div class="content" ${ref(this._content)}>
<ha-list
@selected=${this._assistantsSelected}
class="ha-scrollbar"
<ha-list-selectable
multi
aria-label=${this._localize(
"ui.panel.config.dashboard.voice_assistants.main"
)}
@ha-list-item-selected=${this._handleItemToggled}
@ha-list-item-deselected=${this._handleItemToggled}
>
${repeat(
this._voiceAssistantOptions,
(voiceAssistantId) => voiceAssistantId,
(voiceAssistantId) =>
html`<ha-check-list-item
html`<ha-list-item-option
appearance="checkbox"
selection-position="end"
.value=${voiceAssistantId}
.selected=${(this.value || []).includes(voiceAssistantId)}
hasMeta
graphic="icon"
.selected=${this.value?.includes(voiceAssistantId) ?? false}
>
<voice-assistant-brand-icon
slot="graphic"
slot="start"
.voiceAssistantId=${voiceAssistantId}
>
</voice-assistant-brand-icon>
${voiceAssistants[voiceAssistantId].name}
</ha-check-list-item>`
<span slot="headline"
>${voiceAssistants[voiceAssistantId].name}</span
>
</ha-list-item-option>`
)}
</ha-list>
</ha-list-selectable>
</div>`
: nothing
}
@@ -104,23 +112,20 @@ export class HaFilterVoiceAssistants extends LitElement {
this.expanded = ev.detail.expanded;
}
private async _assistantsSelected(
ev: CustomEvent<SelectedDetail<Set<number>>>
private _handleItemToggled(
ev: HASSDomEvent<number> & HASSDomCurrentTargetEvent<HaListSelectable>
) {
if (!ev.detail.index) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
const voiceAssistantId = (
ev.currentTarget.items[ev.detail] as HaListItemOption | undefined
)?.value;
if (!voiceAssistantId) {
return;
}
const newvalue: string[] = [];
for (const index of ev.detail.index) {
newvalue.push(this._voiceAssistantOptions![index]);
}
this.value = newvalue;
this.value =
ev.type === "ha-list-item-selected"
? arrayWith(this.value, voiceAssistantId)
: arrayWithout(this.value, voiceAssistantId);
fireEvent(this, "data-table-filter-changed", {
value: this.value,
@@ -139,10 +144,14 @@ export class HaFilterVoiceAssistants extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
ha-list {
ha-list-selectable {
display: flex;
flex: 1;
min-height: 0;
}
ha-list-selectable::part(base) {
flex: 1;
min-height: 0;
}
@@ -154,9 +163,6 @@ export class HaFilterVoiceAssistants extends LitElement {
margin-inline-start: auto;
margin-inline-end: 8px;
}
ha-check-list-item {
--mdc-list-item-graphic-margin: var(--ha-space-4);
}
.badge {
display: inline-block;
margin-left: 8px;
+72 -36
View File
@@ -1,3 +1,4 @@
import type { ContextType } from "@lit/context";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiPlus, mdiTextureBox } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
@@ -5,10 +6,20 @@ import { LitElement, html } from "lit";
import type { TemplateResult, PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { updateAreaRegistryEntry } from "../data/area/area_registry";
import {
apiContext,
areasContext,
devicesContext,
entitiesContext,
floorsContext,
internationalizationContext,
statesContext,
} from "../data/context";
import type {
DeviceEntityDisplayLookup,
DeviceRegistryEntry,
@@ -22,7 +33,7 @@ import {
} from "../data/floor_registry";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { showFloorRegistryDetailDialog } from "../panels/config/areas/show-dialog-floor-registry-detail";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { ValueChangedEvent } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-combo-box-item";
import "./ha-floor-icon";
@@ -47,8 +58,6 @@ interface FloorComboBoxItem extends PickerComboBoxItem {
@customElement("ha-floor-picker")
export class HaFloorPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property() public value?: string;
@@ -102,16 +111,38 @@ export class HaFloorPicker extends LitElement {
@property({ type: Boolean }) public required = false;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@consume({ context: entitiesContext, subscribe: true })
private _entities!: ContextType<typeof entitiesContext>;
@consume({ context: devicesContext, subscribe: true })
private _devices!: ContextType<typeof devicesContext>;
@consume({ context: areasContext, subscribe: true })
private _areas!: ContextType<typeof areasContext>;
@state()
@consume({ context: floorsContext, subscribe: true })
private _floors!: ContextType<typeof floorsContext>;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@state() private _pendingFloorId?: string;
protected willUpdate(changedProperties: PropertyValues<this>) {
protected willUpdate(changedProperties: PropertyValues) {
if (
this._pendingFloorId &&
changedProperties.has("hass") &&
this.hass.floors !== changedProperties.get("hass")?.floors &&
this.hass.floors[this._pendingFloorId]
changedProperties.has("_floors") &&
this._floors[this._pendingFloorId]
) {
this._setValue(this._pendingFloorId);
this._pendingFloorId = undefined;
@@ -123,11 +154,11 @@ export class HaFloorPicker extends LitElement {
await this._picker?.open();
}
// Recompute value renderer when the areas change
// Recompute value renderer when the floors change
private _computeValueRenderer = memoizeOne(
(_haAreas: HomeAssistant["floors"]): PickerValueRenderer =>
(haFloors: ContextType<typeof floorsContext>): PickerValueRenderer =>
(value) => {
const floor = this.hass.floors[value];
const floor = haFloors[value];
if (!floor) {
return html`
@@ -147,10 +178,11 @@ export class HaFloorPicker extends LitElement {
private _getFloors = memoizeOne(
(
haFloors: HomeAssistant["floors"],
haAreas: HomeAssistant["areas"],
haDevices: HomeAssistant["devices"],
haEntities: HomeAssistant["entities"],
haFloors: ContextType<typeof floorsContext>,
haAreas: ContextType<typeof areasContext>,
haDevices: ContextType<typeof devicesContext>,
haEntities: ContextType<typeof entitiesContext>,
haStates: ContextType<typeof statesContext>,
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"],
@@ -217,7 +249,7 @@ export class HaFloorPicker extends LitElement {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
const stateObj = haStates[entity.entity_id];
if (!stateObj) {
return false;
}
@@ -228,7 +260,7 @@ export class HaFloorPicker extends LitElement {
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
const stateObj = haStates[entity.entity_id];
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
@@ -249,7 +281,7 @@ export class HaFloorPicker extends LitElement {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
const stateObj = haStates[entity.entity_id];
if (!stateObj) {
return false;
}
@@ -257,7 +289,7 @@ export class HaFloorPicker extends LitElement {
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
const stateObj = haStates[entity.entity_id];
if (!stateObj) {
return false;
}
@@ -342,10 +374,11 @@ export class HaFloorPicker extends LitElement {
private _getItems = () =>
this._getFloors(
this.hass.floors,
this.hass.areas,
this.hass.devices,
this.hass.entities,
this._floors,
this._areas,
this._devices,
this._entities,
this._states,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
@@ -355,7 +388,7 @@ export class HaFloorPicker extends LitElement {
);
private _allFloorNames = memoizeOne(
(floors: HomeAssistant["floors"]) =>
(floors: ContextType<typeof floorsContext>) =>
Object.values(floors)
.map((floor) => computeFloorName(floor)?.toLowerCase())
.filter(Boolean) as string[]
@@ -368,13 +401,13 @@ export class HaFloorPicker extends LitElement {
return [];
}
const allFloors = this._allFloorNames(this.hass.floors);
const allFloors = this._allFloorNames(this._floors);
if (searchString && !allFloors.includes(searchString.toLowerCase())) {
return [
{
id: ADD_NEW_ID + searchString,
primary: this.hass.localize(
primary: this._i18n.localize(
"ui.components.floor-picker.add_new_suggestion",
{
name: searchString,
@@ -388,7 +421,7 @@ export class HaFloorPicker extends LitElement {
return [
{
id: ADD_NEW_ID,
primary: this.hass.localize("ui.components.floor-picker.add_new"),
primary: this._i18n.localize("ui.components.floor-picker.add_new"),
icon_path: mdiPlus,
},
];
@@ -397,9 +430,9 @@ export class HaFloorPicker extends LitElement {
protected render(): TemplateResult {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.floor-picker.floor");
this._i18n.localize("ui.components.floor-picker.floor");
const valueRenderer = this._computeValueRenderer(this.hass.floors);
const valueRenderer = this._computeValueRenderer(this._floors);
return html`
<ha-generic-picker
@@ -409,7 +442,7 @@ export class HaFloorPicker extends LitElement {
.helper=${this.helper}
.placeholder=${placeholder}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass.localize(
.emptyLabel=${this._i18n.localize(
"ui.components.floor-picker.no_floors"
)}
.value=${this.value}
@@ -418,7 +451,7 @@ export class HaFloorPicker extends LitElement {
.valueRenderer=${valueRenderer}
.rowRenderer=${this._rowRenderer}
.searchKeys=${SEARCH_KEYS}
.unknownItemText=${this.hass.localize(
.unknownItemText=${this._i18n.localize(
"ui.components.floor-picker.unknown"
)}
@value-changed=${this._valueChanged}
@@ -437,7 +470,7 @@ export class HaFloorPicker extends LitElement {
}
if (value.startsWith(ADD_NEW_ID)) {
this.hass.loadFragmentTranslation("config");
this._i18n.loadFragmentTranslation("config");
const suggestedName = value.substring(ADD_NEW_ID.length);
@@ -445,20 +478,23 @@ export class HaFloorPicker extends LitElement {
suggestedName: suggestedName,
createEntry: async (values, addedAreas) => {
try {
const floor = await createFloorRegistryEntry(this.hass, values);
const floor = await createFloorRegistryEntry(
this._api.callWS,
values
);
addedAreas.forEach((areaId) => {
updateAreaRegistryEntry(this.hass, areaId, {
updateAreaRegistryEntry(this._api.callWS, areaId, {
floor_id: floor.floor_id,
});
});
if (this.hass.floors[floor.floor_id]) {
if (this._floors[floor.floor_id]) {
this._setValue(floor.floor_id);
} else {
this._pendingFloorId = floor.floor_id;
}
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
title: this._i18n.localize(
"ui.components.floor-picker.failed_create_floor"
),
text: err.message,
@@ -479,7 +515,7 @@ export class HaFloorPicker extends LitElement {
}
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.floor-picker.no_match", {
this._i18n.localize("ui.components.floor-picker.no_match", {
term: html`<b>‘${search}’</b>`,
});
}
+2 -12
View File
@@ -1,16 +1,12 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import type { HomeAssistant } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-floor-picker";
@customElement("ha-floors-picker")
export class HaFloorsPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
export class HaFloorsPicker extends LitElement {
@property() public label?: string;
@property({ type: Array }) public value?: string[];
@@ -63,10 +59,6 @@ export class HaFloorsPicker extends SubscribeMixin(LitElement) {
@property({ type: Boolean }) public required = false;
protected render() {
if (!this.hass) {
return nothing;
}
const currentFloors = this._currentFloors;
return html`
${currentFloors.map(
@@ -75,7 +67,6 @@ export class HaFloorsPicker extends SubscribeMixin(LitElement) {
<ha-floor-picker
.curValue=${floor}
.noAdd=${this.noAdd}
.hass=${this.hass}
.value=${floor}
.label=${this.pickedFloorLabel}
.includeDomains=${this.includeDomains}
@@ -92,7 +83,6 @@ export class HaFloorsPicker extends SubscribeMixin(LitElement) {
<div>
<ha-floor-picker
.noAdd=${this.noAdd}
.hass=${this.hass}
.label=${this.pickFloorLabel}
.helper=${this.helper}
.includeDomains=${this.includeDomains}
+45 -19
View File
@@ -1,3 +1,4 @@
import type { ContextType } from "@lit/context";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiPlus } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
@@ -15,7 +16,13 @@ import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
import { computeCssColor } from "../common/color/compute-color";
import { fireEvent } from "../common/dom/fire_event";
import { labelsContext } from "../data/context";
import {
apiContext,
internationalizationContext,
labelsContext,
registriesContext,
statesContext,
} from "../data/context";
import {
getLabels,
labelComboBoxKeys,
@@ -27,7 +34,7 @@ import {
} from "../data/label/label_registry";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { showLabelDetailDialog } from "../panels/config/labels/show-dialog-label-detail";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { ValueChangedEvent } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
@@ -53,8 +60,6 @@ export const renderLabelColorBadge = (color: string | undefined) =>
@customElement("ha-label-picker")
export class HaLabelPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property() public value?: string;
@@ -112,6 +117,19 @@ export class HaLabelPicker extends LitElement {
@state()
private _labels?: LabelRegistryEntry[];
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: statesContext, subscribe: true })
private _states?: ContextType<typeof statesContext>;
@consume({ context: registriesContext, subscribe: true })
private _registries?: ContextType<typeof registriesContext>;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@queryAssignedElements({ flatten: true })
private _slotNodes?: NodeListOf<HTMLElement>;
@@ -149,12 +167,16 @@ export class HaLabelPicker extends LitElement {
private _getLabelsMemoized = memoizeOne(getLabels);
private _getItems = () =>
this._getLabelsMemoized(
this.hass.states,
this.hass.areas,
this.hass.devices,
this.hass.entities,
private _getItems = () => {
const { areas, devices, entities } = this._registries ?? {};
if (!this._states || !areas || !devices || !entities) {
return [];
}
return this._getLabelsMemoized(
this._states,
areas,
devices,
entities,
this._labels,
this.includeDomains,
this.excludeDomains,
@@ -163,6 +185,7 @@ export class HaLabelPicker extends LitElement {
this.entityFilter,
this.excludeLabels
);
};
private _allLabelNames = memoizeOne((labels?: LabelRegistryEntry[]) => {
if (!labels) {
@@ -190,7 +213,7 @@ export class HaLabelPicker extends LitElement {
return [
{
id: ADD_NEW_ID + searchString,
primary: this.hass.localize(
primary: this._i18n.localize(
"ui.components.label-picker.add_new_suggestion",
{
name: searchString,
@@ -204,7 +227,7 @@ export class HaLabelPicker extends LitElement {
return [
{
id: ADD_NEW_ID,
primary: this.hass.localize("ui.components.label-picker.add_new"),
primary: this._i18n.localize("ui.components.label-picker.add_new"),
icon_path: mdiPlus,
},
];
@@ -213,7 +236,7 @@ export class HaLabelPicker extends LitElement {
protected render(): TemplateResult {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.label-picker.label");
this._i18n.localize("ui.components.label-picker.label");
return html`
<ha-generic-picker
@@ -222,10 +245,10 @@ export class HaLabelPicker extends LitElement {
.label=${this.label}
.helper=${this.helper}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass.localize(
.emptyLabel=${this._i18n.localize(
"ui.components.label-picker.no_labels"
)}
.addButtonLabel=${this.hass.localize("ui.components.label-picker.add")}
.addButtonLabel=${this._i18n.localize("ui.components.label-picker.add")}
.placeholder=${placeholder}
.value=${this.value}
.getItems=${this._getItems}
@@ -253,7 +276,7 @@ export class HaLabelPicker extends LitElement {
}
if (value.startsWith(ADD_NEW_ID)) {
this.hass.loadFragmentTranslation("config");
this._i18n.loadFragmentTranslation("config");
const suggestedName = value.substring(ADD_NEW_ID.length);
@@ -261,7 +284,10 @@ export class HaLabelPicker extends LitElement {
suggestedName: suggestedName,
createEntry: async (values) => {
try {
const label = await createLabelRegistryEntry(this.hass, values);
const label = await createLabelRegistryEntry(
this._api.callWS,
values
);
if (this._labels?.some((l) => l.label_id === label.label_id)) {
this._setValue(label.label_id);
} else {
@@ -269,7 +295,7 @@ export class HaLabelPicker extends LitElement {
}
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
title: this._i18n.localize(
"ui.components.label-picker.failed_create_label"
),
text: err.message,
@@ -292,7 +318,7 @@ export class HaLabelPicker extends LitElement {
}
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.label-picker.no_match", {
this._i18n.localize("ui.components.label-picker.no_match", {
term: html`<b>‘${search}’</b>`,
});
+21 -8
View File
@@ -1,3 +1,4 @@
import type { ContextType } from "@lit/context";
import { mdiPlaylistPlus } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
@@ -8,11 +9,15 @@ import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
import { labelsContext } from "../data/context";
import {
apiContext,
internationalizationContext,
labelsContext,
} from "../data/context";
import type { LabelRegistryEntry } from "../data/label/label_registry";
import { updateLabelRegistryEntry } from "../data/label/label_registry";
import { showLabelDetailDialog } from "../panels/config/labels/show-dialog-label-detail";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { ValueChangedEvent } from "../types";
import "./chips/ha-chip-set";
import "./chips/ha-input-chip";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
@@ -23,8 +28,6 @@ import "./ha-tooltip";
@customElement("ha-labels-picker")
export class HaLabelsPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property({ attribute: false }) public value?: string[];
@@ -82,6 +85,13 @@ export class HaLabelsPicker extends LitElement {
@state()
private _labels?: LabelRegistryEntry[];
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@query("ha-label-picker", true) public labelPicker!: HaLabelPicker;
public async open() {
@@ -114,12 +124,11 @@ export class HaLabelsPicker extends LitElement {
const labels = this._sortedLabels(
this.value,
this._labels,
this.hass.locale.language
this._i18n.locale.language
);
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-label-picker
.hass=${this.hass}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
@@ -175,7 +184,7 @@ export class HaLabelsPicker extends LitElement {
.disabled=${this.disabled}
>
<ha-svg-icon .path=${mdiPlaylistPlus} slot="start"></ha-svg-icon>
${this.hass.localize("ui.components.label-picker.add")}
${this._i18n.localize("ui.components.label-picker.add")}
</ha-button>
</ha-chip-set>
</ha-label-picker>
@@ -196,7 +205,11 @@ export class HaLabelsPicker extends LitElement {
showLabelDetailDialog(this, {
entry: label,
updateEntry: async (values) => {
await updateLabelRegistryEntry(this.hass, label.label_id, values);
await updateLabelRegistryEntry(
this._api.callWS,
label.label_id,
values
);
},
});
}
-1
View File
@@ -166,7 +166,6 @@ export class HaMediaItemPicker extends LitElement {
image"
>
<ha-media-browser-thumbnail
.hass=${this.hass}
.url=${this.value.metadata.thumbnail}
></ha-media-browser-thumbnail>
</div>
+28 -18
View File
@@ -1,11 +1,18 @@
import type { ContextType } from "@lit/context";
import { mdiBackupRestore, mdiFolder, mdiHarddisk, mdiPlayBox } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import { consume } from "../common/decorators/consume";
import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import {
apiContext,
configContext,
internationalizationContext,
} from "../data/context";
import type { SupervisorMounts } from "../data/supervisor/mounts";
import {
fetchSupervisorMounts,
@@ -13,9 +20,7 @@ import {
SupervisorMountUsage,
supervisorMountDescription,
} from "../data/supervisor/mounts";
import type { HomeAssistant } from "../types";
import "./ha-alert";
import "./ha-list-item";
import "./ha-select";
import type { HaSelectOption, HaSelectSelectEvent } from "./ha-select";
@@ -23,8 +28,6 @@ const _BACKUP_DATA_DISK_ = "/backup";
@customElement("ha-mount-picker")
class HaMountPicker extends LitElement {
public hass!: HomeAssistant;
@property() public label?: string;
@property() public value?: string;
@@ -41,6 +44,16 @@ class HaMountPicker extends LitElement {
@state() private _error?: string;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
protected firstUpdated() {
this._getMounts();
}
@@ -55,7 +68,8 @@ class HaMountPicker extends LitElement {
const options: HaSelectOption[] = this._filterMounts(
this._mounts,
this.usage
this.usage,
this._i18n.locale.language
).map((mount) => ({
value: mount.name,
label: mount.name,
@@ -73,7 +87,7 @@ class HaMountPicker extends LitElement {
value: _BACKUP_DATA_DISK_,
iconPath: mdiHarddisk,
label:
this.hass.localize("ui.components.mount-picker.use_datadisk") ||
this._i18n.localize("ui.components.mount-picker.use_datadisk") ||
"Use data disk for backup",
};
if (
@@ -89,8 +103,8 @@ class HaMountPicker extends LitElement {
return html`
<ha-select
.label=${
this.label === undefined && this.hass
? this.hass.localize("ui.components.mount-picker.mount")
this.label === undefined
? this._i18n.localize("ui.components.mount-picker.mount")
: this.label
}
.value=${this.value}
@@ -105,7 +119,7 @@ class HaMountPicker extends LitElement {
}
private _filterMounts = memoizeOne(
(mounts: SupervisorMounts, usage: this["usage"]) => {
(mounts: SupervisorMounts, usage: this["usage"], language: string) => {
let filteredMounts = mounts.mounts.filter((mount) =>
[
SupervisorMountType.CIFS,
@@ -125,29 +139,25 @@ class HaMountPicker extends LitElement {
if (mountB.name === mounts.default_backup_mount) {
return 1;
}
return caseInsensitiveStringCompare(
mountA.name,
mountB.name,
this.hass.locale.language
);
return caseInsensitiveStringCompare(mountA.name, mountB.name, language);
});
}
);
private async _getMounts() {
try {
if (isComponentLoaded(this.hass.config, "hassio")) {
this._mounts = await fetchSupervisorMounts(this.hass);
if (isComponentLoaded(this._config.config, "hassio")) {
this._mounts = await fetchSupervisorMounts(this._api.callWS);
if (this.usage === SupervisorMountUsage.BACKUP && !this.value) {
this.value = this._mounts.default_backup_mount || _BACKUP_DATA_DISK_;
}
} else {
this._error = this.hass.localize(
this._error = this._i18n.localize(
"ui.components.mount-picker.error.no_supervisor"
);
}
} catch (_err: any) {
this._error = this.hass.localize(
this._error = this._i18n.localize(
"ui.components.mount-picker.error.fetch_mounts"
);
}
+91 -30
View File
@@ -3,6 +3,8 @@ import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import { consume } from "../common/decorators/consume";
import { transform } from "../common/decorators/transform";
import { fireEvent } from "../common/dom/fire_event";
import { subscribeOneCollection } from "../common/util/subscribe-one";
import { caseInsensitiveStringCompare } from "../common/string/compare";
@@ -13,12 +15,30 @@ import { SYSTEM_PANELS } from "../data/panel";
import {
CONFIG_SUB_ROUTES,
computeNavigationPathInfo,
type NavigationPathInfoOptions,
} from "../data/compute-navigation-path-info";
import {
apiContext,
configContext,
connectionContext,
internationalizationContext,
registriesContext,
uiContext,
} from "../data/context";
import { findRelated, type RelatedResult } from "../data/search";
import { PANEL_DASHBOARDS } from "../panels/config/lovelace/dashboards/ha-config-lovelace-dashboards";
import { computeAreaPath } from "../panels/lovelace/strategies/areas/helpers/areas-strategy-helper";
import { multiTermSortedSearch } from "../resources/fuseMultiTerm";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type {
HomeAssistantApi,
HomeAssistantConfig,
HomeAssistantConnection,
HomeAssistantInternationalization,
HomeAssistantRegistries,
HomeAssistantUI,
Panels,
ValueChangedEvent,
} from "../types";
import type { ActionRelatedContext } from "../panels/lovelace/components/hui-action-editor";
import "./ha-generic-picker";
import "./ha-domain-icon";
@@ -50,8 +70,6 @@ interface NavigationItem extends PickerComboBoxItem {
@customElement("ha-navigation-picker")
export class HaNavigationPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property() public value?: string;
@@ -70,6 +88,28 @@ export class HaNavigationPicker extends LitElement {
@property({ attribute: false }) public context?: ActionRelatedContext;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: HomeAssistantInternationalization;
@consume({ context: uiContext, subscribe: true })
@transform<HomeAssistantUI, Panels | undefined>({
transformer: (ui) => ui?.panels ?? undefined,
})
private _panels?: Panels;
@consume({ context: registriesContext, subscribe: true })
private _registries?: HomeAssistantRegistries;
@consume({ context: configContext, subscribe: true })
private _hassConfig?: HomeAssistantConfig;
@consume({ context: connectionContext, subscribe: true })
private _connection?: HomeAssistantConnection;
@consume({ context: apiContext, subscribe: true })
private _api?: HomeAssistantApi;
protected firstUpdated() {
this._loadNavigationItems();
}
@@ -98,7 +138,7 @@ export class HaNavigationPicker extends LitElement {
? [
{
id: "related",
label: this.hass.localize(
label: this._i18n.localize(
"ui.components.navigation-picker.related"
),
},
@@ -106,23 +146,27 @@ export class HaNavigationPicker extends LitElement {
: []),
{
id: "dashboards",
label: this.hass.localize("ui.components.navigation-picker.dashboards"),
label: this._i18n.localize(
"ui.components.navigation-picker.dashboards"
),
},
{
id: "views",
label: this.hass.localize("ui.components.navigation-picker.views"),
label: this._i18n.localize("ui.components.navigation-picker.views"),
},
...(this._navigationGroups.apps.length
? [
{
id: "apps",
label: this.hass.localize("ui.components.navigation-picker.apps"),
label: this._i18n.localize(
"ui.components.navigation-picker.apps"
),
},
]
: []),
{
id: "other_routes",
label: this.hass.localize(
label: this._i18n.localize(
"ui.components.navigation-picker.other_routes"
),
},
@@ -140,7 +184,7 @@ export class HaNavigationPicker extends LitElement {
.valueRenderer=${this._valueRenderer}
.rowRenderer=${this._rowRenderer}
.sections=${sections}
.customValueLabel=${this.hass.localize(
.customValueLabel=${this._i18n.localize(
"ui.components.navigation-picker.add_custom_path"
)}
.addButtonLabel=${this.addButtonLabel}
@@ -268,7 +312,7 @@ export class HaNavigationPicker extends LitElement {
}
if (!section && groupItems.length) {
items.push(
this.hass.localize(`ui.components.navigation-picker.${group}`)
this._i18n.localize(`ui.components.navigation-picker.${group}`)
);
}
items.push(...groupItems);
@@ -290,22 +334,25 @@ export class HaNavigationPicker extends LitElement {
caseInsensitiveStringCompare(
itemA.sorting_label!,
itemB.sorting_label!,
this.hass.locale.language
this._i18n.locale.language
);
private async _loadNavigationItems() {
await this._loadConfigEntries();
const panels = Object.entries(this.hass!.panels).map(([id, panel]) => ({
id,
...panel,
}));
const pathInfoOptions = this._pathInfoOptions();
const panels = Object.entries(pathInfoOptions.panels).map(
([id, panel]) => ({
id,
...panel,
})
);
const lovelacePanels = panels.filter(
(panel) => panel.component_name === "lovelace"
);
const viewConfigs = await Promise.all(
lovelacePanels.map((panel) =>
fetchConfig(this.hass!.connection, panel.url_path, true)
fetchConfig(this._connection!.connection, panel.url_path, true)
.then((config) => [panel.id, config] as [string, typeof config])
.catch((_) => [panel.id, undefined] as [string, undefined])
)
@@ -324,7 +371,7 @@ export class HaNavigationPicker extends LitElement {
// Skip app panels — they are handled by the ingress panels fetch below
if (panel.component_name === "app") continue;
const path = `/${panel.url_path}`;
const resolved = computeNavigationPathInfo(this.hass!, path);
const resolved = computeNavigationPathInfo(pathInfoOptions, path);
const isDashboardPanel =
panel.component_name === "lovelace" ||
PANEL_DASHBOARDS.includes(panel.id);
@@ -350,7 +397,7 @@ export class HaNavigationPicker extends LitElement {
config.views.forEach((view, index) => {
const viewPath = `/${panel.url_path}/${view.path ?? index}`;
const viewInfo = computeNavigationPathInfo(
this.hass!,
pathInfoOptions,
viewPath,
config
);
@@ -366,15 +413,18 @@ export class HaNavigationPicker extends LitElement {
}
// Fetch all ingress add-on panels
if (isComponentLoaded(this.hass!.config, "hassio")) {
if (
this._hassConfig?.config &&
isComponentLoaded(this._hassConfig.config, "hassio")
) {
try {
const ingressPanels = await subscribeOneCollection(
getIngressPanelInfoCollection(this.hass!.connection)
getIngressPanelInfoCollection(this._connection!.connection)
);
for (const slug of Object.keys(ingressPanels)) {
const path = `/app/${slug}`;
const resolved = computeNavigationPathInfo(
this.hass!,
pathInfoOptions,
path,
undefined,
ingressPanels
@@ -396,7 +446,7 @@ export class HaNavigationPicker extends LitElement {
for (const [subPath, route] of Object.entries(CONFIG_SUB_ROUTES)) {
const path = `/config/${subPath}`;
const label = this.hass!.localize(route.translationKey) || subPath;
const label = this._i18n.localize(route.translationKey) || subPath;
otherRoutes.push({
id: path,
primary: label,
@@ -445,9 +495,10 @@ export class HaNavigationPicker extends LitElement {
...this._navigationGroups.apps,
...this._navigationGroups.other_routes,
];
this.requestUpdate();
};
if (!this.hass || (!this.context?.entity_id && !this.context?.area_id)) {
if (!this.context?.entity_id && !this.context?.area_id) {
updateRelatedItems([]);
return;
}
@@ -472,8 +523,8 @@ export class HaNavigationPicker extends LitElement {
let relatedResult: RelatedResult | undefined;
try {
relatedResult = context.entity_id
? await findRelated(this.hass, "entity", context.entity_id)
: await findRelated(this.hass, "area", context.area_id!);
? await findRelated(this._api!, "entity", context.entity_id)
: await findRelated(this._api!, "area", context.area_id!);
} catch (err) {
// eslint-disable-next-line no-console
console.error("Error fetching related items for navigation picker", err);
@@ -486,11 +537,12 @@ export class HaNavigationPicker extends LitElement {
relatedAreaIds.add(context.area_id);
}
const pathInfoOptions = this._pathInfoOptions();
const relatedItems: NavigationItem[] = [];
for (const deviceId of relatedDeviceIds) {
const device = this.hass.devices[deviceId];
const device = pathInfoOptions.devices[deviceId];
const path = `/config/devices/device/${deviceId}`;
const resolved = computeNavigationPathInfo(this.hass, path);
const resolved = computeNavigationPathInfo(pathInfoOptions, path);
relatedItems.push({
id: path,
primary: resolved.label,
@@ -512,7 +564,7 @@ export class HaNavigationPicker extends LitElement {
for (const areaId of relatedAreaIds) {
// Area dashboard view
const viewPath = `/home/${computeAreaPath(areaId)}`;
const resolvedArea = computeNavigationPathInfo(this.hass, viewPath);
const resolvedArea = computeNavigationPathInfo(pathInfoOptions, viewPath);
relatedItems.push({
id: viewPath,
primary: resolvedArea.label,
@@ -531,7 +583,7 @@ export class HaNavigationPicker extends LitElement {
const path = `/config/areas/area/${areaId}`;
relatedItems.push({
id: path,
primary: this.hass.localize(
primary: this._i18n.localize(
"ui.components.navigation-picker.area_settings",
{ area: resolvedArea.label }
),
@@ -550,13 +602,22 @@ export class HaNavigationPicker extends LitElement {
return relatedItems;
}
private _pathInfoOptions(): NavigationPathInfoOptions {
return {
localize: this._i18n.localize,
panels: this._panels ?? {},
areas: this._registries?.areas ?? {},
devices: this._registries?.devices ?? {},
};
}
private async _loadConfigEntries() {
if (Object.keys(this._configEntryLookup).length) {
return;
}
try {
const configEntries = await getConfigEntries(this.hass);
const configEntries = await getConfigEntries(this._api!);
this._configEntryLookup = Object.fromEntries(
configEntries.map((entry) => [entry.entry_id, entry])
);
+16 -8
View File
@@ -1,10 +1,14 @@
import type { ContextType } from "@lit/context";
import type { TemplateResult } from "lit";
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consume } from "../common/decorators/consume";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { apiContext } from "../data/context";
import { getAppKey } from "../data/notify_html5";
import { showPromptDialog } from "../dialogs/generic/show-dialog-box";
import type { HaSwitch } from "./ha-switch";
import type { HomeAssistant } from "../types";
import { fireEvent, type HASSDomTargetEvent } from "../common/dom/fire_event";
import "./ha-switch";
@@ -17,10 +21,14 @@ export const pushSupported =
@customElement("ha-push-notifications-toggle")
class HaPushNotificationsToggle extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public disabled!: boolean;
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@state() private _pushChecked: boolean =
"Notification" in window && Notification.permission === "granted";
@@ -81,10 +89,10 @@ class HaPushNotificationsToggle extends LitElement {
}
const name = await showPromptDialog(this, {
title: this.hass.localize(
title: this._localize(
"ui.panel.profile.push_notifications.add_device_prompt.title"
),
inputLabel: this.hass.localize(
inputLabel: this._localize(
"ui.panel.profile.push_notifications.add_device_prompt.input_label"
),
});
@@ -95,7 +103,7 @@ class HaPushNotificationsToggle extends LitElement {
let applicationServerKey: Uint8Array<ArrayBuffer> | null;
try {
applicationServerKey = await getAppKey(this.hass);
applicationServerKey = await getAppKey(this._api.callWS);
} catch (_err) {
applicationServerKey = null;
}
@@ -109,7 +117,7 @@ class HaPushNotificationsToggle extends LitElement {
sub = await reg.pushManager.subscribe({ userVisibleOnly: true });
}
await this.hass.callApi("POST", "notify.html5", {
await this._api.callApi("POST", "notify.html5", {
subscription: sub,
browser: browserName,
name,
@@ -136,7 +144,7 @@ class HaPushNotificationsToggle extends LitElement {
if (!sub) return;
await this.hass.callApi("DELETE", "notify.html5", { subscription: sub });
await this._api.callApi("DELETE", "notify.html5", { subscription: sub });
await sub.unsubscribe();
} catch (err: any) {
const message =
-52
View File
@@ -1,52 +0,0 @@
import { css } from "lit";
import { RadioListItemBase } from "@material/mwc-list/mwc-radio-list-item-base";
import { styles as controlStyles } from "@material/mwc-list/mwc-control-list-item.css";
import { styles } from "@material/mwc-list/mwc-list-item.css";
import { customElement } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
@customElement("ha-radio-list-item")
export class HaRadioListItem extends RadioListItemBase {
async onChange(event) {
super.onChange(event);
fireEvent(this, event.type);
}
static override styles = [
styles,
controlStyles,
css`
:host {
--mdc-theme-secondary: var(--primary-color);
}
:host([graphic="avatar"]) .mdc-deprecated-list-item__graphic,
:host([graphic="medium"]) .mdc-deprecated-list-item__graphic,
:host([graphic="large"]) .mdc-deprecated-list-item__graphic,
:host([graphic="control"]) .mdc-deprecated-list-item__graphic {
margin-inline-end: var(--mdc-list-item-graphic-margin, 16px);
margin-inline-start: 0px;
direction: var(--direction);
}
.mdc-deprecated-list-item__meta {
flex-shrink: 0;
direction: var(--direction);
margin-inline-start: auto;
margin-inline-end: 0;
}
.mdc-deprecated-list-item__graphic {
margin-top: var(--radio-list-item-graphic-margin-top);
}
:host([graphic="icon"]) .mdc-deprecated-list-item__graphic {
margin-inline-start: 0;
margin-inline-end: var(--mdc-list-item-graphic-margin, 32px);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-radio-list-item": HaRadioListItem;
}
}
@@ -1,13 +1,10 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { AssistPipelineSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-assist-pipeline-picker";
@customElement("ha-selector-assist_pipeline")
export class HaAssistPipelineSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: AssistPipelineSelector;
@property() public value?: any;
@@ -23,7 +20,6 @@ export class HaAssistPipelineSelector extends LitElement {
protected render() {
return html`
<ha-assist-pipeline-picker
.hass=${this.hass}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
@@ -6,13 +6,10 @@ import { ensureArray } from "../../common/array/ensure-array";
import { consumeEntityStates } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import type { AttributeSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../entity/ha-entity-attribute-picker";
@customElement("ha-selector-attribute")
export class HaSelectorAttribute extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: AttributeSelector;
@property() public value?: any;
@@ -36,7 +33,6 @@ export class HaSelectorAttribute extends LitElement {
protected render() {
return html`
<ha-entity-attribute-picker
.hass=${this.hass}
.entityId=${
this.selector.attribute?.entity_id || this.context?.filter_entity
}
@@ -1,13 +1,10 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { BackupLocationSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-mount-picker";
@customElement("ha-selector-backup_location")
export class HaBackupLocationSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: BackupLocationSelector;
@property() public value?: any;
@@ -22,7 +19,6 @@ export class HaBackupLocationSelector extends LitElement {
protected render() {
return html`<ha-mount-picker
.hass=${this.hass}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
@@ -1,13 +1,10 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { ConversationAgentSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-conversation-agent-picker";
@customElement("ha-selector-conversation_agent")
export class HaConversationAgentSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: ConversationAgentSelector;
@property() public value?: any;
@@ -26,7 +23,6 @@ export class HaConversationAgentSelector extends LitElement {
protected render() {
return html`<ha-conversation-agent-picker
.hass=${this.hass}
.value=${this.value}
.language=${
this.selector.conversation_agent?.language || this.context?.language
@@ -1,12 +1,15 @@
import type { ContextType } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { consume } from "../../common/decorators/consume";
import { fireEvent } from "../../common/dom/fire_event";
import type { ConfigEntry } from "../../data/config_entries";
import { getConfigEntries } from "../../data/config_entries";
import { apiContext, registriesContext } from "../../data/context";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import { getDeviceIntegrationLookup } from "../../data/device/device_registry";
import type { DeviceSelector } from "../../data/selector";
@@ -20,10 +23,14 @@ import "../device/ha-devices-picker";
@customElement("ha-selector-device")
export class HaDeviceSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: DeviceSelector;
@consume({ context: registriesContext, subscribe: true })
private _registries!: ContextType<typeof registriesContext>;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@state() private _configEntries?: ConfigEntry[];
private _fetchedConfigEntries = false;
@@ -82,7 +89,7 @@ export class HaDeviceSelector extends LitElement {
super.updated(changedProperties);
if (!this._fetchedConfigEntries && this._hasIntegration(this.selector)) {
this._fetchedConfigEntries = true;
getConfigEntries(this.hass)
getConfigEntries(this._api)
.then((entries) => {
this._configEntries = entries;
})
@@ -100,7 +107,6 @@ export class HaDeviceSelector extends LitElement {
if (!this.selector.device?.multiple) {
return html`
<ha-device-picker
.hass=${this.hass}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
@@ -118,7 +124,6 @@ export class HaDeviceSelector extends LitElement {
return html`
${this.label ? html`<label>${this.label}</label>` : ""}
<ha-devices-picker
.hass=${this.hass}
.value=${this.value}
.helper=${this.helper}
.deviceFilter=${this._filterDevices}
@@ -137,8 +142,8 @@ export class HaDeviceSelector extends LitElement {
}
const deviceIntegrations = this._hasIntegration(this.selector)
? this._deviceIntegrationLookup(
this.hass.entities,
this.hass.devices,
this._registries.entities,
this._registries.devices,
this._configEntries
)
: undefined;
@@ -154,8 +159,8 @@ export class HaDeviceSelector extends LitElement {
filter,
entity,
undefined,
this.hass.entities,
this.hass.devices
this._registries.entities,
this._registries.devices
)
);
}
@@ -1,14 +1,10 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { EntityNameSelector } from "../../data/selector";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../types";
import "../entity/ha-entity-name-picker";
@customElement("ha-selector-entity_name")
export class HaSelectorEntityName extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
export class HaSelectorEntityName extends LitElement {
@property({ attribute: false }) public selector!: EntityNameSelector;
@property() public value?: string | string[];
@@ -28,7 +24,6 @@ export class HaSelectorEntityName extends SubscribeMixin(LitElement) {
protected render() {
return html`
<ha-entity-name-picker
.hass=${this.hass}
.entityId=${
this.selector.entity_name?.entity_id || this.context?.entity
}
@@ -1,12 +1,21 @@
import type { ContextType } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { consume } from "../../common/decorators/consume";
import { fireEvent } from "../../common/dom/fire_event";
import type { ConfigEntry } from "../../data/config_entries";
import { getConfigEntries } from "../../data/config_entries";
import {
apiContext,
connectionContext,
devicesContext,
entitiesContext,
statesContext,
} from "../../data/context";
import { getDeviceIntegrationLookup } from "../../data/device/device_registry";
import type { EntitySources } from "../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources";
@@ -15,14 +24,12 @@ import {
computeCreateDomains,
filterSelectorEntities,
} from "../../data/selector";
import type { HomeAssistant } from "../../types";
import type { HomeAssistant, HomeAssistantConnection } from "../../types";
import "../entity/ha-entities-picker";
import "../entity/ha-entity-picker";
@customElement("ha-selector-entity")
export class HaEntitySelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: EntitySelector;
@state() private _entitySources?: EntitySources;
@@ -43,15 +50,31 @@ export class HaEntitySelector extends LitElement {
@state() private _createDomains: string[] | undefined;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@state()
@consume({ context: connectionContext, subscribe: true })
private _connection?: ContextType<typeof connectionContext>;
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@consume({ context: entitiesContext, subscribe: true })
private _entities!: ContextType<typeof entitiesContext>;
@consume({ context: devicesContext, subscribe: true })
private _devices!: ContextType<typeof devicesContext>;
private _deviceIntegrationLookup = memoizeOne(
(
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
entities: HomeAssistant["entities"] | null,
devices: HomeAssistant["devices"] | null,
configEntries?: ConfigEntry[]
) =>
getDeviceIntegrationLookup(
Object.values(entities),
Object.values(devices),
Object.values(entities ?? {}),
Object.values(devices ?? {}),
configEntries
)
);
@@ -71,6 +94,8 @@ export class HaEntitySelector extends LitElement {
private _fetchedConfigEntries = false;
private _fetchedEntitySources = false;
protected willUpdate(changedProperties: PropertyValues<this>): void {
if (changedProperties.get("selector") && this.value !== undefined) {
if (this.selector.entity?.multiple && !Array.isArray(this.value)) {
@@ -101,7 +126,11 @@ export class HaEntitySelector extends LitElement {
.includeEntities=${this.selector.entity?.include_entities}
.excludeEntities=${this.selector.entity?.exclude_entities}
.extraOptions=${this.selector.entity?.extra_options}
.entityFilter=${this._filterEntities}
.entityFilter=${this._filterEntities(
this.selector,
this._entitySources,
this._configEntries
)}
.createDomains=${this._createDomains}
.disabled=${this.disabled}
.required=${this.required}
@@ -117,7 +146,11 @@ export class HaEntitySelector extends LitElement {
.includeEntities=${this.selector.entity.include_entities}
.excludeEntities=${this.selector.entity.exclude_entities}
.reorder=${this.selector.entity.reorder ?? false}
.entityFilter=${this._filterEntities}
.entityFilter=${this._filterEntities(
this.selector,
this._entitySources,
this._configEntries
)}
.createDomains=${this._createDomains}
.disabled=${this.disabled}
.required=${this.required}
@@ -125,36 +158,44 @@ export class HaEntitySelector extends LitElement {
`;
}
protected updated(changedProps: PropertyValues<this>): void {
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
// The connection changed (e.g. reconnect); refetch config entries.
const oldHass = changedProps.get("hass");
if (oldHass && oldHass.connection !== this.hass.connection) {
const oldConnection = changedProps.get("_connection") as
HomeAssistantConnection | undefined;
if (
oldConnection &&
this._connection &&
(oldConnection.connection !== this._connection.connection ||
(!oldConnection.connected && this._connection.connected))
) {
this._fetchedEntitySources = false;
this._fetchedConfigEntries = false;
this._configEntries = undefined;
}
const needs = this._dataNeeds(this.selector);
if (needs.entitySources && !this._entitySources) {
fetchEntitySourcesWithCache(this.hass).then((sources) => {
this._entitySources = sources;
});
if (needs.entitySources && !this._fetchedEntitySources) {
this._fetchedEntitySources = true;
fetchEntitySourcesWithCache(this._api.callWS, this._states ?? {})
.then((sources) => {
this._entitySources = sources;
})
.catch(() => {
this._entitySources ??= {};
});
}
if (needs.configEntries && !this._fetchedConfigEntries) {
this._fetchedConfigEntries = true;
getConfigEntries(this.hass)
getConfigEntries(this._api)
.then((entries) => {
this._configEntries = entries;
})
.catch(() => {
// Fall back to no entries so the picker still renders. We keep
// `_fetchedConfigEntries` set so the failed fetch is not retried on
// every re-render; the connection-change handler above retries on
// reconnect.
this._configEntries = [];
// Keep what we have, or fall back to no entries so the picker still
// renders. A reconnect fetches again.
this._configEntries ??= [];
});
}
@@ -163,29 +204,36 @@ export class HaEntitySelector extends LitElement {
}
}
private _filterEntities = (entity: HassEntity): boolean => {
if (!this.selector?.entity?.filter) {
return true;
}
const deviceIntegrationLookup = this._dataNeeds(this.selector).configEntries
? this._deviceIntegrationLookup(
this.hass.entities,
this.hass.devices,
this._configEntries
)
: undefined;
private _filterEntities = memoizeOne(
(
selector: EntitySelector,
entitySources: EntitySources | undefined,
configEntries: ConfigEntry[] | undefined
) =>
(entity: HassEntity): boolean => {
if (!selector.entity?.filter) {
return true;
}
const deviceIntegrationLookup = this._dataNeeds(selector).configEntries
? this._deviceIntegrationLookup(
this._entities,
this._devices,
configEntries
)
: undefined;
return ensureArray(this.selector.entity.filter).some((filter) =>
filterSelectorEntities(
filter,
entity,
this._entitySources,
this.hass.entities,
this.hass.devices,
deviceIntegrationLookup
)
);
};
return ensureArray(selector.entity.filter).some((filter) =>
filterSelectorEntities(
filter,
entity,
entitySources,
this._entities ?? undefined,
this._devices ?? undefined,
deviceIntegrationLookup
)
);
}
);
}
declare global {
@@ -1,12 +1,15 @@
import type { ContextType } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { consume } from "../../common/decorators/consume";
import { fireEvent } from "../../common/dom/fire_event";
import type { ConfigEntry } from "../../data/config_entries";
import { getConfigEntries } from "../../data/config_entries";
import { apiContext, registriesContext } from "../../data/context";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import { getDeviceIntegrationLookup } from "../../data/device/device_registry";
import type { FloorSelector } from "../../data/selector";
@@ -20,7 +23,11 @@ import "../ha-floors-picker";
@customElement("ha-selector-floor")
export class HaFloorSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@consume({ context: registriesContext, subscribe: true })
private _registries!: ContextType<typeof registriesContext>;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@property({ attribute: false }) public selector!: FloorSelector;
@@ -77,7 +84,7 @@ export class HaFloorSelector extends LitElement {
protected updated(): void {
if (!this._fetchedConfigEntries && this._hasIntegration(this.selector)) {
this._fetchedConfigEntries = true;
getConfigEntries(this.hass)
getConfigEntries(this._api)
.then((entries) => {
this._configEntries = entries;
})
@@ -95,7 +102,6 @@ export class HaFloorSelector extends LitElement {
if (!this.selector.floor?.multiple) {
return html`
<ha-floor-picker
.hass=${this.hass}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
@@ -114,7 +120,6 @@ export class HaFloorSelector extends LitElement {
return html`
<ha-floors-picker
.hass=${this.hass}
.value=${this.value}
.helper=${this.helper}
.pickFloorLabel=${this.label}
@@ -141,8 +146,8 @@ export class HaFloorSelector extends LitElement {
filter,
entity,
undefined,
this.hass.entities,
this.hass.devices
this._registries.entities,
this._registries.devices
)
);
};
@@ -154,8 +159,8 @@ export class HaFloorSelector extends LitElement {
const deviceIntegrations = this._hasIntegration(this.selector)
? this._deviceIntegrationLookup(
this.hass.entities,
this.hass.devices,
this._registries.entities,
this._registries.devices,
this._configEntries
)
: undefined;
@@ -3,13 +3,10 @@ import { customElement, property } from "lit/decorators";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import type { LabelSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-labels-picker";
@customElement("ha-selector-label")
export class HaLabelSelector extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property() public value?: string | string[];
@property() public name?: string;
@@ -31,7 +28,6 @@ export class HaLabelSelector extends LitElement {
return html`
<ha-labels-picker
no-add
.hass=${this.hass}
.value=${ensureArray(this.value ?? [])}
.required=${this.required}
.disabled=${this.disabled}
@@ -45,7 +41,6 @@ export class HaLabelSelector extends LitElement {
return html`
<ha-label-picker
no-add
.hass=${this.hass}
.value=${this.value}
.required=${this.required}
.disabled=${this.disabled}
@@ -186,7 +186,7 @@ export class HaLocationSelector extends LitElement {
static styles = css`
ha-locations-editor {
display: block;
height: 400px;
--ha-locations-editor-map-height: 400px;
margin-bottom: 16px;
}
p {
@@ -2,14 +2,11 @@ import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { NavigationSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import type { ActionRelatedContext } from "../../panels/lovelace/components/hui-action-editor";
import "../ha-navigation-picker";
@customElement("ha-selector-navigation")
export class HaNavigationSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: NavigationSelector;
@property() public value?: string;
@@ -27,7 +24,6 @@ export class HaNavigationSelector extends LitElement {
protected render() {
return html`
<ha-navigation-picker
.hass=${this.hass}
.label=${this.label}
.value=${this.value}
.required=${this.required}
@@ -5,15 +5,31 @@ import {
mdiPencil,
} from "@mdi/js";
import deepClone from "deep-clone-simple";
import type { HassEntities } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { consume } from "../../common/decorators/consume";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import {
formattersContext,
internationalizationContext,
registriesContext,
statesContext,
} from "../../data/context";
import type { ObjectSelector } from "../../data/selector";
import { formatSelectorValue } from "../../data/selector/format_selector_value";
import {
formatSelectorValue,
type FormatSelectorValueOptions,
} from "../../data/selector/format_selector_value";
import { showFormDialog } from "../../dialogs/form/show-form-dialog";
import type { HomeAssistant } from "../../types";
import type {
HomeAssistantFormatters,
HomeAssistantInternationalization,
HomeAssistantRegistries,
} from "../../types";
import { computeInitialHaFormData } from "../ha-form/compute-initial-ha-form-data";
import type { HaFormSchema } from "../ha-form/types";
import "../ha-input-helper-text";
@@ -26,8 +42,6 @@ import { deepEqual } from "../../common/util/deep-equal";
@customElement("ha-selector-object")
export class HaObjectSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: ObjectSelector;
@property() public value?: any;
@@ -47,6 +61,28 @@ export class HaObjectSelector extends LitElement {
values?: Record<string, string>
) => string;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: HomeAssistantInternationalization;
@state()
@consume({ context: registriesContext, subscribe: true })
private _registries!: HomeAssistantRegistries;
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: HassEntities;
@state()
@consume({ context: formattersContext, subscribe: true })
@transform<
HomeAssistantFormatters,
HomeAssistantFormatters["formatEntityName"]
>({
transformer: (formatters) => formatters?.formatEntityName,
})
private _formatEntityName!: HomeAssistantFormatters["formatEntityName"];
@query("ha-yaml-editor", true) private _yamlEditor?: HaYamlEditor;
private _valueChangedFromChild = false;
@@ -83,7 +119,12 @@ export class HaObjectSelector extends LitElement {
private _formatField(item: any, field: string): string {
const fields = this.selector.object!.fields!;
const value = formatSelectorValue(
this.hass,
this._formatOptions(
this._i18n,
this._registries,
this._states,
this._formatEntityName
),
item[field],
fields[field]?.selector
);
@@ -148,14 +189,14 @@ export class HaObjectSelector extends LitElement {
slot="end"
.item=${item}
.index=${index}
.label=${this.hass.localize("ui.common.edit")}
.label=${this._i18n.localize("ui.common.edit")}
.path=${mdiPencil}
@click=${this._editItem}
></ha-icon-button>
<ha-icon-button
slot="end"
.index=${index}
.label=${this.hass.localize("ui.common.delete")}
.label=${this._i18n.localize("ui.common.delete")}
.path=${multiple ? mdiDelete : mdiClose}
@click=${this._deleteItem}
></ha-icon-button>
@@ -180,7 +221,7 @@ export class HaObjectSelector extends LitElement {
</ha-list-base>
</ha-sortable>
<ha-button appearance="filled" @click=${this._addItem}>
${this.hass.localize("ui.common.add")}
${this._i18n.localize("ui.common.add")}
</ha-button>
</div>
`;
@@ -196,7 +237,7 @@ export class HaObjectSelector extends LitElement {
</ha-list-base>`
: html`
<ha-button appearance="filled" @click=${this._addItem}>
${this.hass.localize("ui.common.add")}
${this._i18n.localize("ui.common.add")}
</ha-button>
`
}
@@ -219,6 +260,23 @@ export class HaObjectSelector extends LitElement {
} `;
}
private _formatOptions = memoizeOne(
(
i18n: HomeAssistantInternationalization,
registries: HomeAssistantRegistries,
states: HassEntities,
formatEntityName: HomeAssistantFormatters["formatEntityName"]
): FormatSelectorValueOptions => ({
locale: i18n.locale,
localize: i18n.localize,
states,
devices: registries.devices,
areas: registries.areas,
floors: registries.floors,
formatEntityName,
})
);
private _schema = memoizeOne((selector: ObjectSelector) => {
if (!selector.object || !selector.object.fields) {
return [];
@@ -262,12 +320,12 @@ export class HaObjectSelector extends LitElement {
};
const newItem = await showFormDialog(this, {
title: this.hass.localize("ui.common.add"),
title: this._i18n.localize("ui.common.add"),
schema,
data,
computeLabel: this._computeLabel,
computeHelper: this._computeHelper,
submitText: this.hass.localize("ui.common.add"),
submitText: this._i18n.localize("ui.common.add"),
});
if (newItem === null) {
@@ -290,12 +348,12 @@ export class HaObjectSelector extends LitElement {
const index = ev.currentTarget.index;
const updatedItem = await showFormDialog(this, {
title: this.hass.localize("ui.common.edit"),
title: this._i18n.localize("ui.common.edit"),
schema: this._schema(this.selector),
data: item,
computeLabel: this._computeLabel,
computeHelper: this._computeHelper,
submitText: this.hass.localize("ui.common.save"),
submitText: this._i18n.localize("ui.common.save"),
});
if (updatedItem === null) {
+105 -65
View File
@@ -1,26 +1,28 @@
import { mdiDragHorizontalVariant } from "@mdi/js";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consume } from "../../common/decorators/consume";
import { ensureArray } from "../../common/array/ensure-array";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { internationalizationContext } from "../../data/context";
import type { SelectOption, SelectSelector } from "../../data/selector";
import type { FrontendLocaleData } from "../../data/translation";
import type { HomeAssistantInternationalization } from "../../types";
import "../chips/ha-chip-set";
import "../chips/ha-input-chip";
import "../ha-checkbox";
import "../ha-dropdown-item";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-button";
import "../ha-input-helper-text";
import "../ha-select";
import "../ha-select-box";
import "../ha-sortable";
import "../ha-svg-icon";
import "../radio/ha-radio-group";
import "../radio/ha-radio-option";
@@ -28,10 +30,7 @@ import "../radio/ha-radio-option";
export class HaSelectSelector extends LitElement {
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale!: FrontendLocaleData;
private _i18n!: HomeAssistantInternationalization;
@property({ attribute: false }) public selector!: SelectSelector;
@@ -48,6 +47,8 @@ export class HaSelectSelector extends LitElement {
@property({ type: Boolean }) public required = true;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
private _itemMoved(ev: CustomEvent): void {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
@@ -83,7 +84,11 @@ export class HaSelectSelector extends LitElement {
if (this.selector.select?.sort) {
options.sort((a, b) =>
caseInsensitiveStringCompare(a.label, b.label, this._locale.language)
caseInsensitiveStringCompare(
a.label,
b.label,
this._i18n.locale.language
)
);
}
@@ -136,8 +141,8 @@ export class HaSelectSelector extends LitElement {
const value =
!this.value || this.value === "" ? [] : ensureArray(this.value);
return html`
<div>
${this.label}
<div class="checkbox-list">
${this.label} ${this._renderHelper()}
${options.map(
(item: SelectOption) => html`
<ha-checkbox
@@ -151,7 +156,6 @@ export class HaSelectSelector extends LitElement {
`
)}
</div>
${this._renderHelper()}
`;
}
@@ -159,67 +163,80 @@ export class HaSelectSelector extends LitElement {
const value =
!this.value || this.value === "" ? [] : ensureArray(this.value);
const addLabel = this._i18n.localize("ui.common.add");
return html`
${
value?.length
? html`
<ha-sortable
no-style
.disabled=${!this.selector.select.reorder}
@item-moved=${this._itemMoved}
handle-selector="button.primary.action"
>
<ha-chip-set>
${repeat(
value,
(item) => item,
(item, idx) => {
const label =
options.find((option) => option.value === item)
?.label || item;
return html`
<ha-input-chip
.idx=${idx}
@remove=${this._removeItem}
.label=${label}
.title=${label}
selected
>
${
this.selector.select?.reorder
? html`
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
`
: nothing
}
${
options.find((option) => option.value === item)
?.label || item
}
</ha-input-chip>
`;
}
)}
</ha-chip-set>
</ha-sortable>
`
this.label
? html`<span id="label" class="label chips-label"
>${this.label}</span
>`
: nothing
}
<ha-generic-picker
no-sort
.helper=${this.helper}
.label=${this.label}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.value=${""}
.addButtonLabel=${this.label}
.getItems=${this._getItems(options, value, true)}
.allowCustomValue=${this.selector.select.custom_value ?? false}
@value-changed=${this._comboBoxValueChanged}
></ha-generic-picker>
>
<ha-sortable
slot="field"
no-style
.disabled=${!this.selector.select.reorder}
@item-moved=${this._itemMoved}
draggable-selector="ha-input-chip"
handle-selector="button.primary.action"
>
<ha-chip-set
aria-labelledby=${ifDefined(this.label ? "label" : undefined)}
>
${repeat(
value,
(item) => item,
(item, idx) => {
const label =
options.find((option) => option.value === item)?.label ||
item;
return html`
<ha-input-chip
.idx=${idx}
@remove=${this._removeItem}
.label=${label}
.title=${label}
selected
>
${
this.selector.select?.reorder
? html`
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
`
: nothing
}
${label}
</ha-input-chip>
`;
}
)}
<ha-button
size="s"
appearance="filled"
.disabled=${this.disabled}
@click=${this._openPicker}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${addLabel}
</ha-button>
</ha-chip-set>
</ha-sortable>
</ha-generic-picker>
${this._renderHelper()}
`;
}
@@ -264,6 +281,11 @@ export class HaSelectSelector extends LitElement {
: "";
}
private _openPicker(ev: Event) {
ev.stopPropagation();
this._picker?.open();
}
private _getOptions = memoizeOne(
(selector: SelectSelector) =>
selector.select?.options?.map((option) =>
@@ -404,19 +426,37 @@ export class HaSelectSelector extends LitElement {
ha-dropdown-item[disabled] {
--mdc-theme-text-primary-on-background: var(--disabled-text-color);
}
ha-chip-set {
padding: 8px 0;
}
.label {
display: block;
margin: 0 0 8px;
}
.chips-label {
font-weight: var(--ha-font-weight-medium);
}
ha-sortable ha-chip-set {
background-color: var(--mdc-text-field-fill-color);
border-bottom: 1px solid var(--ha-color-border-neutral-normal);
border-top-right-radius: var(--ha-border-radius-sm);
border-top-left-radius: var(--ha-border-radius-sm);
padding: var(--ha-space-3);
}
ha-select-box + ha-input-helper-text {
margin-top: 4px;
}
ha-generic-picker + ha-input-helper-text {
padding: 0;
margin-top: 4px;
}
.checkbox-list ha-input-helper-text {
padding: 0;
margin-top: 4px;
}
.sortable-fallback {
display: none;
opacity: 0;
@@ -1,3 +1,4 @@
import type { ContextType } from "@lit/context";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiClose, mdiConnection, mdiMemory, mdiPencil, mdiUsb } from "@mdi/js";
import Fuse from "fuse.js";
@@ -6,9 +7,17 @@ import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { consume } from "../../common/decorators/consume";
import { fireEvent } from "../../common/dom/fire_event";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import {
apiContext,
areasContext,
configContext,
devicesContext,
internationalizationContext,
} from "../../data/context";
import type { SerialPortSelector } from "../../data/selector";
import { listSerialPorts, type SerialPort } from "../../data/usb";
import { mdiEsphomeLogo } from "../../resources/esphome-logo-svg";
@@ -110,8 +119,6 @@ const getPortType = (
@customElement("ha-selector-serial_port")
export class HaSerialPortSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: SerialPortSelector;
@property() public value?: string;
@@ -132,6 +139,25 @@ export class HaSerialPortSelector extends LitElement {
@state() private _manualEntry = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: configContext, subscribe: true })
private _config?: ContextType<typeof configContext>;
@state()
@consume({ context: devicesContext, subscribe: true })
private _devices?: ContextType<typeof devicesContext>;
@state()
@consume({ context: areasContext, subscribe: true })
private _areas?: ContextType<typeof areasContext>;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@query("ha-input") private _input?: HTMLElement;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@@ -155,9 +181,8 @@ export class HaSerialPortSelector extends LitElement {
private _canLoadPorts(): boolean {
return Boolean(
this.hass &&
this.hass.user?.is_admin &&
isComponentLoaded(this.hass.config, "usb")
this._config?.user?.is_admin &&
isComponentLoaded(this._config.config, "usb")
);
}
@@ -174,7 +199,7 @@ export class HaSerialPortSelector extends LitElement {
}
this._loadInFlight = true;
try {
this._serialPorts = await listSerialPorts(this.hass);
this._serialPorts = await listSerialPorts(this._api.callWS);
} catch (err: unknown) {
// eslint-disable-next-line no-console
console.error(err);
@@ -213,8 +238,8 @@ export class HaSerialPortSelector extends LitElement {
(
ports: SerialPort[],
language: string,
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
devices: HomeAssistant["devices"] | undefined,
areas: HomeAssistant["areas"] | undefined,
localize: HomeAssistant["localize"],
recommendedDomains: Set<string>
): Record<SerialPortType, SerialPickerItem[]> => {
@@ -246,13 +271,13 @@ export class HaSerialPortSelector extends LitElement {
const url = new URL(port.device);
primary = url.searchParams.get("port_name") || port.device;
const configEntryId = url.pathname.replace(/^\/+/, "");
const device = Object.values(devices).find(
const device = Object.values(devices ?? {}).find(
(d) => d.primary_config_entry === configEntryId
);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName =
device && device.area_id
? areas[device.area_id]?.name
? areas?.[device.area_id]?.name
: undefined;
if (deviceName && areaName) {
secondary = localize(
@@ -343,11 +368,11 @@ export class HaSerialPortSelector extends LitElement {
private _sectionLabel(type: SerialPortType): string {
const key = `ui.components.selectors.serial_port.type.${type}` as const;
if (type === "recommended" && this._selectorDomain) {
return this.hass.localize(key, {
integration: integrationName(this.hass.localize, this._selectorDomain),
return this._i18n.localize(key, {
integration: integrationName(this._i18n.localize, this._selectorDomain),
});
}
return this.hass.localize(key);
return this._i18n.localize(key);
}
private get _selectorDomain(): string | undefined {
@@ -388,10 +413,10 @@ export class HaSerialPortSelector extends LitElement {
const grouped = this._buildGroupedItems(
this._serialPorts,
this.hass.locale.language,
this.hass.devices,
this.hass.areas,
this.hass.localize,
this._i18n.locale.language,
this._devices,
this._areas,
this._i18n.localize,
this._recommendedDomains
);
@@ -425,7 +450,7 @@ export class HaSerialPortSelector extends LitElement {
private _getAdditionalItems = (): PickerComboBoxItem[] => [
{
id: MANUAL_ENTRY_ID,
primary: this.hass.localize(
primary: this._i18n.localize(
"ui.components.selectors.serial_port.enter_manually"
),
icon_path: mdiPencil,
@@ -482,7 +507,8 @@ export class HaSerialPortSelector extends LitElement {
};
protected render() {
const usbLoaded = this.hass && isComponentLoaded(this.hass.config, "usb");
const usbLoaded =
!!this._config && isComponentLoaded(this._config.config, "usb");
if (!usbLoaded || !this._serialPorts || this._manualEntry) {
return html`
@@ -539,10 +565,10 @@ export class HaSerialPortSelector extends LitElement {
const item = Object.values(
this._buildGroupedItems(
this._serialPorts,
this.hass.locale.language,
this.hass.devices,
this.hass.areas,
this.hass.localize,
this._i18n.locale.language,
this._devices,
this._areas,
this._i18n.localize,
this._recommendedDomains
)
)
@@ -562,10 +588,10 @@ export class HaSerialPortSelector extends LitElement {
}
const grouped = this._buildGroupedItems(
this._serialPorts,
this.hass.locale.language,
this.hass.devices,
this.hass.areas,
this.hass.localize,
this._i18n.locale.language,
this._devices,
this._areas,
this._i18n.localize,
this._recommendedDomains
);
return SECTION_ORDER.filter((type) => grouped[type].length).map((type) => ({
Loaded 100 of 509 files, more files were not shown because too many files have changed in this diff. Show more