Compare commits

...
Author SHA1 Message Date
Petar Petrov aec4b70eab Translate common YAML parse error reasons in the editor 2026-11-10 20:15:39 +02:00
Maarten Lakerveld 5b1ecdb0b0 Let browsers below the legacy floor continue at their own risk (#55829)
* Let browsers below the legacy floor continue at their own risk

Browsers below the floor get a "Continue anyway" button on the launch
screen instead of a dead end, e.g. for a wall dashboard. The warning returns
monthly, and from the first reminder on offers "Don't show again".

* Space the launch screen without max() on Chrome below 79

The launch screen's top spacer and OHF logo margins use max() with var()
inside. Chrome below 79 drops those after the cascade, leaving no margin,
so the logo touched the bottom edge (Chrome 67 to 78 are in the legacy
floor). Plain margins now come first, the max() rules behind @supports.

* Keep the browser warning up while the app loads

Once the user continued, the launch screen went blank until the legacy
build took over, which can take a while on these devices. Keep the message
and only drop the buttons.

* Say the browser is no longer supported instead of too old

With the option to continue it does run, so "too old to run Home Assistant"
contradicted the button. The same text now reads right before and after
continuing.

* Use a plain color for the launch screen buttons

Theme variables are not set yet while the launch screen shows, so
var(--primary-color) always fell back anyway.
2026-11-10 16:53:24 +01:00
Zedas ee35dd8cf6 Send the final media player volume only once (#55828) 2026-11-10 15:04:46 +01:00
Daan VervackeandAidan Timson 42ce434a94 Add selection mode with bulk remove to the Marketplace repositories page (#55435)
* Add selection mode with bulk remove to the Marketplace repositories page

* Guard bulk remove against double clicks and drop stale selections

* Clear the table selection when a selected repository gets installed

* Restore the comments that still apply

* Use a Marketplace string for the bulk action menu

* Drop rendering-only tests

* Uncheck data table rows that can no longer be selected

* Use one Remove chip and let the table drop installed rows

---------

Co-authored-by: Aidan Timson <[email protected]>
2026-11-10 14:40:47 +01:00
Zedas 06d564dbd8 End the ZHA permit subscription when the search ends (#55822)
* End the ZHA permit subscription when the search ends

* End the ZHA search when the connection drops

* Don't send a stale ZHA search after a reconnect
2026-11-10 14:19:10 +01:00
Petar Petrov 93f0c2c45a Hold the last value across gaps in history graphs (#55817)
* Hold the last value across gaps in history graphs

* Hold values across gaps in every graph, without an opt-in

* Hold the real last state across gaps and keep their curves tight

* Only hold stretches the raw history was quiet for

* Keep the temperature forecast curving across gaps

* Decide which stretches to hold once, from the raw history

* Hold neither across unavailable states nor below the sampling frame
2026-11-10 13:53:49 +01:00
renovate[bot] fb40d07621 Update dependency @html-eslint/eslint-plugin to v0.68.0 (#55825)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-10 11:53:04 +00:00
Zedas a6073e44f7 Translate the fold markers in the code editor (#55821) 2026-11-10 11:44:59 +00:00
Paulus SchoutsenandClaude Opus 5.5 df69f3c7ee Show which integrations use an integration loaded as a dependency (#55499)
* Show which integrations use an integration loaded as a dependency

Some integrations, like Modbus, have no config flow and are loaded
because another integration lists them in its manifest dependencies.
The frontend treated these as YAML integrations. The integration page
told the user to configure it in configuration.yaml, and the dashboard
showed a YAML card for it.

The integration page now fetches the loaded manifests for integrations
without a config flow. If a loaded integration depends on the domain,
the page shows "Used by <integrations>" instead of the YAML text. The
dashboard does not show a YAML card for a domain that a loaded
integration depends on.

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

* Show the integrations that use a dependency as linked cards

Replace the "Used by" text with a "Used by" section. It lists each
loaded integration that depends on this integration, with its logo and
a link to its integration page. The entries section shows "No entries"
instead of the YAML text for such an integration.

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

* Show only the "Used by" section for a dependency

An integration that other loaded integrations depend on, and that has no
config entries, now shows only the "Used by" section. The page does not
show the empty entries section. It also does not show the "Add entry"
button, which only opens the "set up in YAML" dialog for these
integrations.

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

* Match the demo Modbus and LoRaWAN manifests to core

Modbus and LoRaWAN have no config flow, and the integrations that use
them list them as dependencies. The demo now does the same, so their
integration pages show the "Used by" section.

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

* Hide system integrations from the "Used by" section

System integrations, like Default Configuration, depend on many
integrations. Listing them in "Used by" does not help the user.

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

* Read the dependents from the manifests context

The integration page now finds the integrations that depend on it from
the shared manifests context, instead of its own manifest/list call.
It subscribes to the context only for an integration without a config
flow, so other integration pages do not make the context fetch the
manifests.

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

* Link the integration page to the integration's settings panel

The Connectivity settings panels, like Zigbee and Modbus, are not
reachable from the integration page unless the integration has a config
entry. A user that opens the integration page looks for these settings.

The integration page now shows a button that opens the panel of the
integration. The button uses the panel name from the Connectivity page.
LoRaWAN, Modbus, Radio frequency, and USB (Serial) are added to the map
of integrations with a panel.

An integration with a panel and no config flow does not show the
"Add entry" button. For these integrations, it only opens the "set up
in YAML" dialog, and the panel is where to set them up.

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

* Hide the YAML texts for integrations set up in their panel

An integration with a settings panel and no config flow, like Infrared,
is set up in that panel. Its integration page told the user to set it up
in configuration.yaml. The page now shows only the settings button for
such an integration when it has no entries.

The demo Infrared and Radio frequency manifests now match core, so these
pages show it.

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

* Make the settings button primary when it is the only action

An integration that is set up in its panel shows no "Add entry" or
"Add device" button, so the settings button is its only action.

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

* Show the settings button only when there are no entries

Each config entry row of an integration with a panel already has a
link to that panel, so the button repeats it on pages like Zigbee.

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

* Link the MCP integration pages to the AI settings

The AI settings show how to connect to the MCP server and the URL of
each of its APIs. The MCP client and MCP server integration pages now
have an "AI settings" button that opens them. The button is the
primary action when the page has no "Add entry" button.

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

* Scroll the AI settings to the MCP card from the MCP pages

The AI settings page now scrolls to the section named in the URL hash,
like the backup settings page. The "AI settings" button on the MCP
integration pages links to the MCP card, which is below the AI task
card.

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

* Keep the demo route when opening the AI settings

The demo routes with the URL hash. The AI settings page cleared the
hash even when it did not name a section, so a reload of the demo lost
the page. It now clears the hash only after it scrolls to a section.

The "AI settings" button links to the page without a section in the
demo, because a link with a hash skips the demo route conversion.

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

* Move the settings panel and MCP buttons to their own PRs

This PR keeps the "Used by" section for integrations that other
integrations depend on. The settings panel button and the MCP link to
the AI settings move to separate PRs.

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

* Load the Modbus dependents in the demo

The dashboard reads the dependencies of loaded integrations only. The
demo listed Modbus as loaded, but not Flexit, SolarEdge Modbus, and
Fronius, so it showed a YAML card for Modbus.

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

* Sort the "Used by" list and wait for it before the YAML texts

The manifests come from a set in core, so their order can change
between restarts. The "Used by" list is now sorted by name.

Until the manifests load, the page does not know the dependents. It
showed the YAML texts and "Add entry" first and then replaced them with
"Used by". The page now holds back those texts and the button for an
integration without a config flow until the manifests load.

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

* Apply review feedback

- Make the settings button primary whenever it is the only action,
  like the MCP button. Thread, or MQTT with its one entry, have no
  "Add entry" button.
- Set the demo LoRaWAN manifest to the service type, like core, so the
  demo dashboard checks its dependents.
- Fix the punctuation of the YAML text.

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

* Show the entries while the dependents load

The page waits for the manifests of the loaded integrations before it
shows the YAML texts. If that request fails, the wait never ends. Only
a page without entries can show "Used by", so only such a page waits.
A page with entries now shows them even when the manifests do not load.

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

* Clear the manifest when the integration page changes domain

The integration page is reused when you follow a "Used by" link. It
kept the previous integration's manifest until the new one loaded, so
going back to Modbus from Flexit briefly showed "Add entry" and the
empty entries section. The page now clears the manifest on a domain
change, and a page without entries waits for the manifest before it
shows the YAML texts and "Add entry".

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

---------

Co-authored-by: Claude <[email protected]>
2026-11-10 10:55:56 +01:00
Markus TuominenandClaude Fable 5.1 95a009641f Fix integration icons being clipped into circles on the area page (#55820)
Stop rounding and distorting integration icons on the area page

The generic img rule meant for the area picture also applied to the 24px
brand icons in the device list, clipping square integration logos (for
example Awair) into circles. Scope that rule to the area picture and size
the icons with width/height attributes like the device page does.

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-11-10 10:17:22 +01:00
Wendelin 36f59f8495 Pass callWS to entity and device registry update helpers (#55819) 2026-11-10 11:02:12 +02:00
Stefan AgnerandClaude Opus 5.5 8a13bba521 Only match an OTBR to a Thread network it is part of (#55803)
* Only match an OTBR to a Thread network it is part of

findOTBRInfoForDataset returned the dataset's preferred border router
without checking that it is still on that network. When the border
router had moved to another network, the Thread panel offered to reset
it from the card of a network it no longer belongs to, which would
replace the border router's current network.

Require the extended PAN ID to match for the preferred border router
too, and drop the active dataset check in the network information page
that worked around the same problem.

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

* Update findOTBRInfoForDataset tests for the network check

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

* Keep findOTBRInfoForDataset fallback tests independent of key order

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-10 11:00:30 +02:00
Maarten Lakerveld cc7b1952c0 End slider and color wheel interaction when a drag is cancelled (#55804)
End slider and color wheel interaction when a pan is cancelled

ha-control-slider and ha-hs-color-picker restored their value on
pancancel without firing an event, so consumers never learned that the
interaction ended. The light color pickers then kept ignoring state
updates until the next interaction. Fire the same end-of-interaction
event as panend (and as ha-control-scrubber already does), and restore
the wheel's local value along with its cursor position.
2026-11-10 10:59:38 +02:00
karwosts 2edf6f91d0 Allow configurable time format for timer.finishes_at in state-display (#55813)
Allow custom time format for timer.finishes_at
2026-11-10 09:26:27 +01:00
jimjaeger 9d52517ae9 Retry translation fragments after a failed request (#55716)
* Retry translation fragments after a failed request

* Clear translation mock before each test
2026-11-10 08:51:58 +02:00
renovate[bot] 27a7f6335f Update dependency @vvo/tzdb to v6.203.0 (#55814)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-10 08:25:58 +02:00
renovate[bot] cd44bfcebc Update dependency eslint-plugin-import-x to v4.18.0 (#55815)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-10 08:25:55 +02:00
Elias Lecomte 8819844740 Fix single-day selection in the date range picker (#55718)
* Fix single-day selection in the date range picker

* Limit single-tap date selection to devices without hover
2026-11-10 08:23:21 +02:00
WendelinandClaude Opus 5.5 008adf1533 Move the create helper dialog and helper forms to contexts (#55794)
* Narrow helper create and area update data functions to the API they use

The create helper dialog can then pass the API context instead of the
full hass object.

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

* Consume contexts in helper forms instead of hass

The helper forms only read translations, locale and config, so they now
consume the internationalization and config contexts. This avoids
rerendering them on every unrelated hass change.

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

* Consume contexts in the create helper dialog instead of hass

The dialog reads translations, config, the API and the connection from
their contexts and no longer passes hass to the helper forms.

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

* Refactor API calls to use HomeAssistantApi for improved type safety and consistency across data handling

---------

Co-authored-by: Claude <[email protected]>
2026-11-09 20:54:31 +01:00
renovate[bot] 8c3d0f2f1e Update dependency serve to v14.2.7 (#55810)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-09 20:52:23 +01:00
renovate[bot] 3f4f89480f Update tsparticles to v4.5.3 (#55811)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-09 19:51:19 +00:00
Matthias de BaatandClaude Opus 5.5 432762a2ea Add design to the AI policy (#55795)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-09 20:45:01 +01:00
WendelinandClaude Opus 5.5 013cf8d7e6 Give automation editor rows a config path (#55801)
* Give automation editor rows a config path

Each list and row in the automation and script editor extends the path
of its ancestor with its own segment through a Lit context, e.g.
["actions", 0, "then", 1]. Rows that open in the sidebar register in a
per-editor registry by path, as groundwork to find the row a config path
belongs to, e.g. to point at an error in it.

Move the generic path type and helpers from the Lovelace editor to
common/util/config-path and use ConfigPath everywhere.

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

* Test config path helpers on their own

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-09 20:17:34 +02:00
Joost LekkerkerkerandClaude 1de489cca9 Sort device connections by type in device info card (#55797)
Claude-Session: https://claude.ai/code/session_01SLHDn4zYGvMHi4C8v3m45c

Co-authored-by: Claude <[email protected]>
2026-11-09 16:58:30 +01:00
Maarten Lakerveld 97af661779 Use the Backups menu caption as the backup page title (#55799)
The Settings > System menu said "Backups" while the page header said
"Backup". Reuse the caption key like the other system pages do, so
the two can't drift apart again.
2026-11-09 16:41:09 +01:00
Erwin Douna f702e369fc Add a health dialog to config entries (#55736)
* Add a health dialog to config entries

* Address review feedback

* Address review feedback

* Address review feedback

* Address review feedback

* Use the integration filter for the logs link
2026-11-09 16:40:04 +01:00
Maarten Lakerveld 78e0a92ddd Include disabled helpers in the helpers area filter (#55796)
The helpers table lists disabled helpers, but its area filter left them
out, so a disabled helper without an area disappeared when picking
No area, and one in an area disappeared when picking that area. Match
the entities page, which already passes include-disabled-entities.
2026-11-09 16:24:11 +01:00
Andreas d742259444 Fix stale color and slider jump after quick drag in light color pickers (#55780)
* Fix stale color sent after quick drag in light color pickers

When the color temperature slider is dragged and released within the
500 ms throttle window, the release handler skipped sending because the
picker value already equalled the final value, relying on the pending
throttled call. A state update from the leading throttled call then
reset the picker value (no longer interacting), so the trailing call
sent the stale initial value and the light ended up at the wrong color
temperature.

Cancel the pending throttled call on release and always send the final
value. Apply the same cancel to the RGB picker, where a pending trailing
call could send a stale color after the final one.

* Keep released value in light color pickers until state settles

After release, keep showing the released value for a short time and
ignore state updates in between, so that late state updates from calls
sent while dragging no longer make the slider jump back before the
final value is reported.

* Don't sync light color pickers while a new drag is active

If a new drag starts before the release hold of the previous one ends,
the hold timer must not overwrite the picker value with the reported
state, as that makes the control jump and lets a pending throttled call
send the older value. The release of the new drag starts a new hold.

* Address review: keep brightness normalization in sync, hold native input

Keep the brightness normalization of RGB-only lights in sync with the
reported state while the picker values are held (during a drag or the
release hold), as it is applied to the next color sent. Otherwise
successive color selections could dim the light repeatedly.

The native color input has no release event and sends throttled, so
refresh the release hold on every input. This keeps state updates from
calls sent while picking from overwriting the pending value.

* Use PendingValueSync in light color pickers and end interaction on release

Replace the fixed release hold timer with PendingValueSync, so state
updates are ignored only while a call is queued or in flight. Also clear
_isInteracting in the release handlers, so a keyboard adjustment or a
cancelled pan no longer stops the picker from following the state.
2026-11-09 16:22:09 +01:00
OllieandClaude Opus 5.5 623ca14356 Configure remote Bluetooth adapters from the adapters page (#55742)
* Configure remote Bluetooth adapters from the adapters page

Remote scanners such as ESPHome Bluetooth proxies have their scanning
mode and other settings in the options flow of the integration that
provides them. Show the configure button for remote adapters on the
Bluetooth adapters page and open that integration's options flow, found
through the scanner device's via device, so users no longer have to find
the device under Devices & services.

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

* Prefer the providing integration's device when finding the source entry

Other integrations may add config entries to the scanner's own device,
so look at the via device first and only fall back to the scanner's
device.

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

* Only use the via device and skip disabled source entries

Drop the fallback to the scanner's own device, which could pick up an
unrelated integration's entry, and don't offer to configure a remote
adapter whose providing config entry is disabled or has no options flow.

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

* Check for a configurable source entry in the template

Keep _getSourceConfigEntry a plain lookup, as suggested in review, and
skip the cog for disabled or non-configurable source entries where it
is rendered.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-09 16:10:41 +01:00
Maarten Lakerveld 49e5220634 Let users hide entities from the maintenance page (#55610)
* Let users hide entities from the maintenance page

Battery and problem sensors land on Maintenance purely by device class, so a car's target charge level showed up with no way out short of hiding the entity everywhere.

Store a hidden_entities list in maintenance frontend system data and let the resolver take it, so the Home dashboard summary badge and the overview check drop the same entities as the page. The pencil enters an inline edit mode: the page regenerates with every tile wrapped in a new entity-visibility card that dims hidden tiles with a dotted border and toggles them on tap, saving at once.

In edit mode every entity is shown anyway, so a tap only flips the wrapper's own hidden flag and the panel just saves; the view is regenerated when edit mode ends. Nothing is rebuilt per tap and focus stays on the tile that fired. A failed save reloads the configuration and forces a rebuild so the wrappers show the stored state again.

Saves go through a queue shared by all panel instances, and a panel loads its config only after that queue drains, so leaving and reopening the page while a save is in flight cannot reload a snapshot that lacks it. A failed save shows a toast and reloads, unless a newer save is already queued on top of it.

* Test hidden entities in the maintenance strategy

Cover both modes of the generated view: hidden entities are left out
normally, and while editing they are kept as entity-visibility wrappers
with hidden set so users can show them again.
2026-11-09 15:47:17 +01:00
Josh Leubecher-Robinson ccd2a4dead Check for child devices when building Z-Wave network visualization and ignore. (#55692)
Check for child devices when building map and ignore.
2026-11-09 15:00:43 +01:00
Przemysław Szypowicz fead96eb9c Hide day names in hourly weather forecasts (#55688)
* Add option to hide day names in the weather forecast card

Hourly and twice daily forecasts show the weekday where a new day
starts. In the compact layout it takes a separate column with no icon
or temperature, which pushes the last requested slot under the edge
fade of the scroll strip. The show_day_names option, on by default,
hides the label in both the compact and the tall layout.

The editor offers the switch for hourly and twice daily forecasts only,
because daily forecasts already label every item with its day.

* Hide day names in hourly weather forecasts

Hourly forecasts already show 0:00 where a new day starts, so the
weekday label adds nothing there. In the compact layout it also took a
column of its own and pushed the last slot under the edge fade.
Twice daily forecasts keep their day names, because their items only
say day or night.

This replaces the show_day_names card option, because the card is due
for a rework and should not gain new options before it.
2026-11-09 15:00:18 +01:00
Petar Petrov 4e480e7e8c Share the base styles with the demo and tidy the launch screen partial (#55791)
The demo kept its own copy of the html/body rules that
_style_base.html.template owns, and the copies had already drifted. That is
the same duplication that let the demo miss the launch screen overlay styles.

Make _style_base.html.template reusable by moving its viewport meta into
_meta_viewport.html.template, included wherever _style_base was emitting it,
so every existing page renders exactly as before. The demo can then include
_style_base and keep only the rules that are really its own, along with its
own viewport meta, which does not set user-scalable=no.

Also drop a short-viewport override in the launch screen partial that
restated the base value, and spell out why the media queries carry a body
prefix.
2026-11-09 14:16:55 +01:00
Wendelin fc4d83195b Fix Netlify deployment command to include --no-build option (#55790) 2026-11-09 14:34:49 +02:00
Christian Wiedel 0a6adf5290 Keep the demo launch screen from pushing the app down while it fades out (#55707) 2026-11-09 14:31:34 +02:00
Clément Notin 3f8c806095 Show YAML editor shortcut in automation and script sidebar menus (#55726)
* Show YAML editor shortcut in sidebar menus

* Remove unused platform import from field selector sidebar

* Show YAML editor shortcut in automation row menus

* Show YAML shortcut in script field row menus

* Fix YAML shortcut hint scope and styling
2026-11-09 13:26:29 +01:00
Christian Wiedel 283c335878 Wrap long text in Z-Wave, Zigbee, Matter and MQTT settings lists (#55711) 2026-11-09 12:14:15 +01:00
Josef Zweck 54d42767b5 Use unsaved name and area when regenerating an entity ID (#55682) 2026-11-09 12:01:10 +01:00
Andreas 9cfb6ce1bd Fix hs color picker position on non-square canvas (#55787)
The color wheel is drawn as a centered square (object-fit: contain), but
the pointer position was mapped using the full width and height of the
canvas box. In Safari the canvas box is wider than it is tall, so the
horizontal position was off and the cursor did not follow the pointer.

Map the pointer position relative to the center of the canvas box and
its shorter side, which is the drawn wheel.
2026-11-09 10:46:24 +00:00
Maarten Lakerveld 56a4dee395 Keep dialogs on the remaining default-route redirects (#55670)
* Keep dialogs on the remaining default-route redirects

The energy panel, the energy config tabs and the media browser fill in
their default route with a replace, which closed a dialog restored from
the URL and dropped the query the restore needs. They now pass
keepDialogs and keep the query, like the router and dashboard redirects.

The more-info restore runs on every location change, so those redirects
re-showed the dialog that was already open. That hoisted it above any
dialog opened on top of it and reset its view. Skip the restore while
the more-info dialog is open.

* Only skip the more-info restore for the entity already shown

The restore skipped whenever a more-info dialog was open. A deep link
that arrived while the previous dialog was still closing after
navigate's wait was dropped, and when two deep links queued up the
older one won and the newer was never opened.

The dialog manager now exposes the params a dialog was shown with. The
restore skips only when the open dialog already shows the URL's entity,
and a queued restore bails when the URL moved on to another entity.

* Drop a queued more-info restore when the URL's view moved on

Two deep links for the same entity with different views queued in one
frame let the older restore open the dialog and write its view back to
the URL. The queued restore now also bails when the URL's view differs
from the one it captured, so the newest deep link wins.
2026-11-09 11:40:31 +01:00
WendelinandClaude Opus 5.5 8c49159b2f Remove ha-list and ha-list-item (#55783)
* Remove ha-list and ha-list-item

Nothing uses the mwc-based list components anymore, so remove them
together with the @material/mwc-list dependency, its patch and the
test mocks that still stubbed them.

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

* Remove ha-list and ha-list-item

Nothing uses the mwc-based list components anymore, so remove them
together with the @material/mwc-list dependency, its patch and the
test mocks that still stubbed them. Also drop the --mdc-list-* custom
properties that nothing reads anymore.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-09 11:36:38 +01:00
1754b1087f Add a No area option to the area filter (#55629)
* Add a No area option to the area filter

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

* Fix HTML syntax in ha-filter-floor-areas.ts

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* Match the No area filter to each table's area column

Use the effective device area so child devices that take their parent's
area aren't listed, include entities that only have a state, and only
count the device's area on the entities and helpers tables.

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

* Only refresh the No area filter when entities are added or removed

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-11-09 11:33:17 +01:00
iluvdata 5a4ac066ed Fix config_subentries_flow previews (#55721) 2026-11-09 11:31:48 +01:00
neoyarus 85398e787c Fix masonry cards disappearing during layout updates (#55774) 2026-11-09 11:28:41 +01:00
Wendelin 8f5a45c673 Fix Netlify deploys with the new workspace package (#55785)
Add filter option to Netlify deployment command
2026-11-09 12:00:25 +02:00
Clément Notin 3461566576 Fix entity selection for numeric thresholds (#55772)
Fix numeric threshold entity choice
2026-11-09 10:45:39 +01:00
Clément Notin 2a49102fae Show loading state for automation targets (#55782) 2026-11-09 10:43:04 +01:00
Wendelin 594e46a7b3 Keep semver ranges for peer dependencies (#55779)
Add package rule to widen semver ranges for peer dependencies
2026-11-09 11:36:49 +02:00
Luca Félix b013970dcd Round the volume shown in the siren advanced controls (#55764)
* Round the volume shown in the siren advanced controls

The volume is stored as a fraction and was shown as volume * 100, so a
typed 7 came back as 7.000000000000001.

* Drop the siren controls rendering test

Rendering tests are out of scope for the frontend tests.
2026-11-09 11:33:46 +02:00
Luca Félix c05f3a66c1 Don't re-enter dashboard edit mode when going back after leaving it (#55762)
While editing, the dashboard keeps ?edit=1 in the URL of the current
history entry, so a view left in edit mode stays in history with it.
Leaving edit mode only cleans the current entry, and going back to the
earlier one turned edit mode on again. A history pop now clears the
param when edit mode is off instead of entering it.
2026-11-09 11:32:39 +02:00
Petar Petrov a62448bc9a Name energy devices after their entity when a device has several meters (#55778)
* Name energy devices after their entity when a device has several meters

* Keep the device name as placeholder until an energy statistic is picked

* Compute the picker ambiguity set from the memoized arguments

* Count user-set energy device names towards a label collision
2026-11-09 09:57:07 +01:00
Simon LamonandWendelin 024cd8687e ha-media-player-browse migrate list (#55476)
* ha-media-player-browse migrate list

* Feedback

* Refactor HaMediaPlayerBrowseListItem to extend HaListItemButton and enhance rendering logic

* copilot

---------

Co-authored-by: Wendelin <[email protected]>
2026-11-09 10:44:03 +02:00
Luca Félix bb53939e14 Keep a plain value under its choice in the choose selector (#55766)
* Keep a plain value under its choice in the choose selector

The change handlers spread a plain object value, like a duration, into
the wrapper, so its old keys ended up in the saved value and switching
choices lost it.

* Test the choose selector's value wrapping as a pure function

Rendering tests are out of scope for the frontend tests, so the wrapping
moves to computeChooseValues and is tested directly.
2026-11-09 09:33:29 +01:00
Luca Félix 19294a3e1f Use the suggested value on a required integer slider without data (#55763)
* Use the suggested value on a required integer slider without data

The fallback chained a boolean check with ||, so the slider got true
instead of the suggested value, and a default of 0 was skipped.

* Test the integer slider's value logic as a pure function

Rendering tests are out of scope for the frontend tests, so the value
resolution moves to computeIntegerSliderValue and is tested directly.
2026-11-09 09:28:29 +01:00
renovate[bot] a2186a3a73 Update dependency @swc/helpers to v0.5.24 (#55728) 2026-11-09 09:05:07 +01:00
renovate[bot] 54abec98dc Update dependency glob to v14.0.4 (#55777)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-09 09:22:42 +02:00
dependabot[bot] ccdac789fd Bump shell-quote from 1.10.0 to 1.12.0 (#55759)
Bumps [shell-quote](https://github.com/ljharb/shell-quote) from 1.10.0 to 1.12.0.
- [Changelog](https://github.com/ljharb/shell-quote/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/shell-quote/compare/v1.10.0...v1.12.0)

---
updated-dependencies:
- dependency-name: shell-quote
  dependency-version: 1.12.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-09 06:54:44 +01:00
dependabot[bot] 87471c164d Bump launch-editor from 2.14.1 to 2.14.2 (#55760)
Bumps [launch-editor](https://github.com/vitejs/launch-editor) from 2.14.1 to 2.14.2.
- [Commits](https://github.com/vitejs/launch-editor/compare/v2.14.1...v2.14.2)

---
updated-dependencies:
- dependency-name: launch-editor
  dependency-version: 2.14.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-09 06:54:27 +01:00
renovate[bot] cbd8653a88 Update Playwright (#55771)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-09 06:54:08 +01:00
renovate[bot] f7a23f5c47 Update tsparticles to v4.5.2 (#55776)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-09 06:51:59 +01:00
dependabot[bot] afcf7d4e08 Bump serialize-javascript from 7.1.0 to 7.1.4 (#55758)
Bumps [serialize-javascript](https://github.com/yahoo/serialize-javascript) from 7.1.0 to 7.1.4.
- [Release notes](https://github.com/yahoo/serialize-javascript/releases)
- [Commits](https://github.com/yahoo/serialize-javascript/compare/v7.1.0...v7.1.4)

---
updated-dependencies:
- dependency-name: serialize-javascript
  dependency-version: 7.1.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-08 20:16:17 +02:00
dependabot[bot] ef62989c77 Bump undici from 6.29.0 to 6.29.1 (#55757)
Bumps [undici](https://github.com/nodejs/undici) from 6.29.0 to 6.29.1.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.29.0...v6.29.1)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 6.29.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-08 20:15:38 +02:00
dependabot[bot] faa1844ca3 Bump fast-uri from 3.1.8 to 3.1.9 (#55756)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.8 to 3.1.9.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.8...v3.1.9)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-08 20:15:28 +02:00
renovate[bot] ff1c7fc106 Update dependency jsdom to v31 (#55755)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-08 17:57:43 +01:00
renovate[bot] 18d660d29f Update dependency @rslib/core to v1.0.6 (#55754)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-08 17:57:35 +01:00
renovate[bot] ac6928622e Update dependency vite to v8.3.6 (#55751)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-08 11:12:21 +02:00
renovate[bot] 4fde55b03e Update dependency temporal-polyfill to v1.0.8 (#55749)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-08 08:24:05 +02:00
renovate[bot] 7ccc4826ea Update pnpm to v12.18.0 (#55750)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-08 08:23:51 +02:00
Petar PetrovandClaude Opus 5 eebc57d89a Open the battery entity from the device page header (#55734)
Co-authored-by: Claude Opus 5 <[email protected]>
2026-11-07 13:09:55 +01:00
renovate[bot] 63b474f43c Update dependency fullcalendar to v7.1.4 (#55730)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-07 10:18:04 +01:00
renovate[bot] d5cc973c22 Update dependency @versatiles/style to v8.1.1 (#55715)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-07 11:17:46 +02:00
renovate[bot] 00e76021ce Update dependency temporal-polyfill to v1.0.7 (#55731)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-07 10:08:44 +02:00
Maarten Lakerveld fb88d66421 Add My redirect for the action history page (#55705)
Lets my.home-assistant.io link to Settings > Tools > Action history
via tools_action_history.
2026-11-06 16:36:31 +01:00
renovate[bot] 23b161d257 Update dependency @rslib/core to v1.0.5 (#55710)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-06 17:28:14 +02:00
123a32dd81 Add experimental @home-assistant/components package (#55652)
* Add hac-card and hac-header as first components of the hac library

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

* Move hac components into a publishable workspace package built with Rslib

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

* Rework hac package: tree-shakable entry points, safe registration, standalone theming

- Separate component classes from registration: the root entry exports
  classes only, ./card, ./header and ./all register elements. Named
  imports from the root are now tree-shaken, and sideEffects lists only
  the registering modules (dist and src, as the repo resolves to src).
- Skip registration when a tag is already defined, so Home Assistant and
  custom cards can ship the library side by side.
- Fix hac-header empty-slot handling: :has(::slotted()) is not supported
  by browsers, so empty icon and action slots added gaps.
- Expose the header title as a heading with a configurable level.
- Add fallbacks for all theme variables so components render outside
  Home Assistant, lay out footer actions, drop overflow clipping.
- Resolve the package to its sources via a hac-source export condition
  instead of hand-written aliases, add it as a workspace dependency and
  include it in lit-analyzer.

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

* Add hac as a workspace dependency of the frontend

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

* Add global, scoped and dynamic loading modes to hac components

Each component now lives in components/<name>/ with its class, a
version.ts, a CHANGELOG.md and three entry points:

- @home-assistant/hac/<name>: registers globally, as Home Assistant does
- <name>/scoped: registers the bundled copy in a scoped registry
- <name>/dynamic: uses Home Assistant's copy when its version is
  compatible, the bundled copy otherwise

ScopedElementsMixin (@home-assistant/hac/scoped) gives a Lit element its
own registry and supports both the standard API and the older API of the
scoped registry polyfill that Home Assistant loads. Without either, it
falls back to global registration.

Home Assistant publishes its components with versions and lazy loaders
at startup. The HTML creates a pending slot first, so extra modules that
load in parallel with the app wait for it instead of missing it.

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

* Support plain custom elements in scoped mode and require scoped elements

- Add createScopedElementRegistry, attachScopedShadow and
  createScopedElement, so plain custom elements can use scoped and
  dynamic components, not just Lit elements.
- ScopedElementsMixin builds on them and now takes the elements as a
  required argument: forgetting them is a type error instead of silently
  rendering unknown elements. Plain JavaScript gets a clear runtime error.

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

* Add button group and close option for hac-header

- Add the button group with hac-button and hac-button-close, a plain
  icon button with a close icon.
- hac-header gets show-close and close-label; the close button fires
  hac-close.
- Components declare the hac elements they render as static
  dependencies. global registers them globally, so Home Assistant uses
  no scoped registries; scoped and dynamic use a variant with its own
  registry of bundled dependencies, so a component and its dependencies
  always come from the same copy.
- Exclude generated package output from the root type check.

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

* Show a dismissable greeting card on the profile page using hac

Trial use of the hac components inside Home Assistant: a hac-card with
a hac-header that has show-close enabled, localized title and close
label. Dismissing hides it until the page is reloaded.

Using it surfaced two things:
- A card with only a header still rendered the padding of its empty
  content area, so hac-card now omits it.
- lit-analyzer only follows imports one level into node_modules, where
  the workspace package resolves, so files using hac entry points failed
  its missing-import check when linted alone, as the pre-commit hook
  does. Allow one more level.

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

* Keep the patches comment next to PATCHES_DIR

The hac alias block was inserted between the comment and the constant it
describes.

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

* Remove the hac pages from the gallery

The library is a concept for now and should get its own documentation
later.

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

* Give every hac element its own entry points and drop the all export

Groups now only organize elements: a group's directory holds its default
element and variants live in subdirectories, mirroring the import paths.
@home-assistant/hac/button registers hac-button only, and
@home-assistant/hac/button/close registers hac-button-close, each with
its own version, changelog, scoped and dynamic entry points.

The all export registered every bundled copy globally, which a custom
card could use to replace Home Assistant's own components, and nothing
used it.

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

* Rename the package to @home-assistant/components

The package name says what it is; hac- stays the prefix of the element
tags and classes. The export condition that resolves workspace packages
to their sources is renamed from hac-source to the neutral ha-source, so
future packages can use it too.

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

* Load bundled components in dynamic mode only when they are needed

dynamic entry points now import only the element's version up front and
load the bundled component with a dynamic import when Home Assistant
cannot provide a compatible copy, so bundlers split it into a chunk that
is not downloaded otherwise.

The static version on the component classes is removed: the host
publishes its versions in the provider list, so version.ts is the only
source of truth.

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

* Fall back to the scoped entry point in dynamic mode

Each dynamic entry point loads its bundled element through a named
loadScoped function that imports the element's scoped entry point, so
dynamic uses exactly what scoped mode registers and createDynamicLoader
no longer builds the scoped variant itself.

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

* Drop the registration note from the component docs

How elements are registered is the same for every component and is
documented once in the package README.

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

* Move the slot checks of hac-card and hac-header into a shared helper

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

* Group the utils of the components package by purpose

registration/ holds what defines elements (global, scoped, dynamic),
element/ the helpers components use themselves.

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

* Define each element once with its tag name next to its version

- version.ts becomes info.ts with TAG_NAME and VERSION, the cheap module
  that dynamic, global, HA's provider and dependent components read the
  tag name from. Only HTMLElementTagNameMap and Lit templates keep the
  literal, as the tooling needs it there.
- scoped and dynamic entry points export one definition object, e.g.
  hacCard = { tagName, element }, instead of a single-entry map.
  ScopedElementsMixin, createScopedElementRegistry and static
  dependencies take lists of definitions.

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

* Drop the pending provider slot from the HTML

Only extra modules can run before the app publishes its components, and
then they simply use their bundled copies. Not worth the inline script
while the library is a concept: the host now just publishes the list.

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

* Remove the root entry point of the components package

Nothing used it and it exposed the registration internals as public API.
Every element, the scoped helpers and the provider have their own entry
points; class types come from an element's scoped entry point with
import type.

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

* Clean up the components package API and naming

- Drop the Hac prefix from library-level names, which are not elements:
  ElementDefinition, ElementLoader, ComponentClass, ProvidedComponent(s),
  PROVIDER_KEY. HA's provider file becomes components-provider.ts.
- /scoped no longer exports scopedRegistrySupport, and the mixin no
  longer adds a static scopedRegistry; both were internals.
- scopedVariant uses the mixin's subclass instead of wrapping it again.
- Rename utils/registration/version.ts to compatibility.ts, as elements
  keep their version in info.ts, and keep ComponentClass with the other
  definition types.
- Update the outdated Rslib config comment: only global.ts registers.

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

* Clarify where to create the scoped registry

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

* Remove the per-element changelogs from the concept

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

* Remove the dynamic loading mode from the components package

The concept starts with global and scoped only. Dynamic mode, with the
provider list HA publishes, the version compatibility check and loader
support in the registry helper, can be added later.

Elements keep their VERSION in info.ts.

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

* Regenerate the lockfile on top of dev for the components package

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

* Use the semantic color tokens in the components

The components use --ha-color-* tokens (surface, on-surface, text,
border, primary fill, focus) instead of the legacy theme variables, with
the light values as fallback outside Home Assistant. User theme colors
still apply because HA derives the --ha-color-primary-* palette from
them.

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

* Reduce the components package to the card for the concept

Remove the button group and the header. The profile greeting is a plain
hac-card now, as it had no close button to dismiss it anymore. The
dependency mechanism stays, documented with a generic example, for the
first element that renders other elements.

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

* Remove the dependency mechanism from the components package

Nothing uses it since the header and buttons are gone. The card now
registers itself with define, and its scoped entry point exports the
class directly. It can come back with the first element that renders
other elements.

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

* Use hac-card for the long-lived access tokens in the profile

The card header is a heading in the header slot, the content needs no
wrapper, and the create button sits in the footer slot.

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

* refactor: update pnpm workspace settings and improve profile dashboard layout

- Excluded 'rs-module-lexer' from allowed builds in pnpm workspace configuration.
- Adjusted footer alignment in the long-lived access tokens card for better layout.
- Removed unnecessary 'hac-card' from the profile dashboard, simplifying the structure.
- Updated CSS to target only 'ha-card' for styling, enhancing consistency.
- Removed greeting title translation to streamline user experience.

* fix: update caching paths and improve long-lived tokens card styles

* fix: update theming fallback order and improve hac-card styles

* fix: replace `replace` with `replaceAll` for consistent wildcard handling in component source aliases

* copilot

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-11-06 16:04:12 +01:00
WendelinandClaude Opus 5.5 4dc713abe6 Add categories and descriptions to the create helper dialog (#55701)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-11-06 13:55:47 +01:00
WendelinandClaude Opus 5.5 b9182e3ea6 Fix bright card outlines on Shelly Wall Display (#55704)
* Keep rgba() colors in the legacy CSS build

Raising the legacy floor to Chrome 67 lets lightningcss rewrite rgba()
to 8-digit hex (#rrggbbaa), since Chrome 62 supports it. Kiosk webviews
like the Shelly Wall Display get the legacy build but can't parse that,
so var(--divider-color) borders on cards, toggles and buttons fell back
to currentColor and showed as bright outlines, the same symptom as
#52986. Always compile hex alpha colors to rgba() in legacy builds, for
both css`` templates and imported stylesheets. Modern builds are
unchanged.

Also drop the loader's redundant /* global */ header, which ESLint now
rejects because module and require are already built-in globals in
.cjs files.

Fixes #55699

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

* Also keep rgba() colors in the legacy service worker build

The template loader runs with the legacy-sw browserslist env for the
service worker layer, which an exact match on legacy missed.

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

---------

Co-authored-by: Claude <[email protected]>
2026-11-06 12:06:46 +01:00
Christian Wiedel 9bd57fa122 Fix grouped data tables stuttering when scrolling in Safari (#55702)
* Fix grouped data tables stuttering when scrolling in Safari

Group header actions are rendered by a callback instead of a slot per group
header. The virtualizer adds and removes header rows while scrolling, and
WebKit is slow to add and remove slots.

Fixes #55636

* Keep group header actions when the data or the pinned group changes

Group header rows are keyed by their group, so an open category menu stays open when the data refreshes. The sticky header is keyed by the pinned group, so its actions don't act on the next group's category.

* Keep group header keys apart from row keys

A group named like a row's id gave the header and the row the same virtualizer key. Prefix the keys so they can't collide.

* Don't call the group header actions slotted in the styles
2026-11-06 11:31:48 +01:00
renovate[bot] 4af7503112 Update dependency @rspack/core to v2.3.1 (#55703) 2026-11-06 10:02:58 +00:00
Jump-333 984e63dcd0 Individual devices to gas in energy dashboard (#55533)
* Add individual devices to gas in energy dashboard

* Fix copilot issues

* Fix conflicts

* Fix issues

* Fix issues

* Fix more issues

* Centralize gas and water dialogs and sankey cards

* Merge conflicts in energy-overview-view-strategy

* Merge conflicts

* Merge conflicts

* Add name and description

* Fix issues
2026-11-06 12:00:52 +02:00
Paul Bottein f90ed06080 Fix form field visibility depending on field order (#55681) 2026-11-06 10:52:25 +01:00
b176b8226b Update agents file to mention how to handle CSS on onboarding screens (#55631)
Co-authored-by: Wendelin <[email protected]>
Co-authored-by: Maarten Lakerveld <[email protected]>
Co-authored-by: bgoncal <[email protected]>
Co-authored-by: maartenla <[email protected]>
Co-authored-by: Bruno Pantaleão Gonçalves <[email protected]>
2026-11-06 09:24:58 +00:00
Zubir Jamal 3f3e9297f7 Add Malay (Bahasa Melayu) to the frontend (#55693) 2026-11-06 10:11:02 +01:00
Markus TuominenandClaude Fable 5.1 83028028e8 Add icons to the lists on the area page (#55700)
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-11-06 09:02:03 +00:00
Petar Petrov 37d7c6144c Add expand_legend option to the distribution card (#55698) 2026-11-06 08:31:22 +00:00
Petar Petrov f469f3eeae Fit narrow chart legends to three rows instead of two (#55697) 2026-11-06 08:30:51 +00:00
WendelinandClaude Opus 5.5 069e87c913 Don't show the via device as area in the devices table (#55674)
* Don't show via device as area in devices table

The Area and Floor columns fell back to the via device's area or name
when a device had no area. For protocol integrations like ZHA this put
the coordinator's name in the Area column of every unassigned end
device. Only use the device's own area or the one inherited from its
parent device. The device picker keeps the via device hint.

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

* Add 'connected via' translation and update device dashboard to display via device information

* remove comment

---------

Co-authored-by: Claude <[email protected]>
2026-11-06 08:16:18 +02:00
renovate[bot] 46bdbee8f3 Update dependency marked to v18.1.3 (#55684)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-06 06:50:46 +01:00
renovate[bot] 68def911a6 Update tsparticles to v4.5.1 (#55685)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-06 06:50:30 +01:00
renovate[bot] a8fdb99317 Update pnpm to v12.17.0 (#55695)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-06 06:50:06 +01:00
renovate[bot] 3d1bf61683 Update dependency @bundle-stats/plugin-webpack-filter to v4.23.1 (#55694)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-06 06:49:45 +01:00
Simon Lamon 5cfdff0e21 Google Assistant => Google Home (#55683) 2026-11-05 16:17:46 -05:00
Christian Wiedel 54cefeb816 Fix wrapped flow dialog titles overlapping the subtitle (#55661) 2026-11-05 21:09:50 +01:00
renovate[bot] 4458205273 Update vitest monorepo to v5.1.0 (#55680)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-05 19:28:39 +01:00
Paul Bottein 0c2de46f11 Fix truncated buttons in the voice assistant setup (#55677) 2026-11-05 18:36:57 +01:00
Aidan Timson 6bef900455 Fix the max-age of the demo and cast cache headers (#55676) 2026-11-05 18:36:36 +01:00
Aidan Timson 438bfa2710 Let embed entities set their entity registry options (#55675) 2026-11-05 17:26:47 +01:00
puddly 96864e951a Allow the ESPHome serial proxy URL to be null (#55665)
* Allow the ESPHome serial proxy URL to be None

* Clarify comment
2026-11-05 18:08:22 +02:00
renovate[bot] a1355e5116 Update babel monorepo to v8.1.0 (#55667)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-05 17:19:21 +02:00
Petar Petrov ae4366020c Choose the area when creating a helper without a device (#55646)
* Choose the area of a helper that has no device

Storage helpers get an Area field in their create form. Config flow
helpers get an area picker on the success step when any of their
entities has no device; the area is set on all of them. The redirect
after creating then uses the entity ID as regenerated for the area.

* Count the area as an unsaved change in the helper dialog

* Disable the helper area picker while saving
2026-11-05 15:03:47 +00:00
renovate[bot] 7f73d29b2f Update dependency @versatiles/style to v8 (#55668)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-05 15:46:36 +01:00
Petar Petrov ebf453ccc7 Show the new helper after creating it (#55628)
* Open the new helper after creating it

From the helpers page, a new helper now opens its device page when its
entities belong to one device, or its more-info dialog otherwise.

* Open the right entity after creating a helper

Look up the storage helper's entity by its unique ID, because its entity
ID can differ from the helper ID. Start the config entry lookup when the
success step shows and skip the redirect if the dialog closed meanwhile.

* Ignore the helper lookup after the dialog closes or reopens

* Ignore a helper created after its dialog closed or reopened
2026-11-05 15:23:30 +01:00
TimothyandClaude Opus 5.5 a0d9afe695 Keep dialogs while navigating (#55627)
* Keep dialogs while navigating

* Apply suggestions

* Keep navigate async when keeping dialogs

Skipping the await committed the navigation synchronously, so a navigate()
called right after it saw the committed count change and was dropped as
stale.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-05 15:11:49 +01:00
Javier Torres c42d048736 Wrap long text in cloud remote access settings list (#55660) 2026-11-05 12:34:55 +00:00
Stefan AgnerandClaude Opus 5.5 2f62ed5f73 Improve app panel ingress error messages (#55655)
Give the raw Supervisor client error some context when loading the app
info fails, show a dedicated message when a non-admin opens an
admin-only app, and include the underlying error when creating the
ingress session fails.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-05 14:19:46 +02:00
Aidan Timson 8b6caeecdd Render full dashboards with their tabs in the demo embed (#55640)
* Add an embedded mode to hui-root that leaves the page's URL alone

* Add a dashboard component to the demo embed

* Keep embedded dashboards' back navigation, height and sidebar tabs inside the embed

* Follow anchors, replacements and new configs in embedded dashboards

* Go back past a followed back_path in embedded dashboards

* Isolate embedded view containers so view backgrounds show

* Match decoded and aliased views and skip empty embedded dashboards

* Read the sidebar tab once sections views have their dashboard

* Declare the navigate events in HTMLElementEventMap

* Skip the Lovelace info fetch and its mock in embedded dashboards
2026-11-05 12:59:44 +02:00
Bram KragtenandClaude Opus 5.5 4800b4f7e9 Deploy production when the Demo and Cast workflows are run on master (#55657)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-05 12:59:09 +02:00
Paul Bottein c7afc3391c Add history and statistics chart pages to the gallery (#55651) 2026-11-05 12:15:29 +02:00
Aidan Timson 3e9490e547 Render views and dashboard strategies in the demo embed (#55639)
* Add a view component to the demo embed

* Show only the latest generated dashboard in the demo embed

* Stop stale and repeated strategy results in embedded views

* Draw view themes and backgrounds in embedded views
2026-11-05 09:50:00 +00:00
Aidan Timson 7a1cd6bbfe Render sections in the demo embed, with a device registry (#55638)
* Share embed provider lookup and theme in a base element

* Add devices to the demo embed provider

* Add a section component to the demo embed

* Draw section backgrounds and accept more section configs in the demo embed
2026-11-05 09:49:59 +00:00
Paul Bottein a2c3147d33 Fix area controls freezing large dashboards (#55650) 2026-11-05 10:44:35 +02:00
Petar Petrov 7039e3ad9f Open the new device or entry after adding an entry from an integration page (#55630)
Keep navigateToResult when adding an entry for a specific domain
2026-11-05 09:33:55 +01:00
JonathanandXyolyp e97745d7d5 Filter the device classes by the chosen unit (#55422)
* Filter the device classes by the chosen unit

* Keep unsupported device classes selectable

* Only preselect a required device class and keep the default rows

---------

Co-authored-by: Xyolyp <[email protected]>
2026-11-05 08:57:19 +02:00
Mr-Hell 790c957405 Add device class column, search and filter to entities table (#55366)
* Add device class column and filter to entities table

* Localize device classes and preserve redirect filters

* Fix import conflict after rebase

* Refactor device class filter based on review feedback

* Avoid rebuilding device class options while filter is collapsed

* Address final device class review feedback

* Use shared device class translation key for filter
2026-11-05 06:26:31 +00:00
JonathanandXyolyp f740091579 Prevent a double Finish click in the config flow success step (#55616)
* Prevent a double Finish click in the config flow success step

* Keep only the double-click test and stub ha-input

---------

Co-authored-by: Xyolyp <[email protected]>
2026-11-05 08:19:55 +02:00
Simon Lamon f76c88dd9f Clean up check list item based on mwc-list (#55643) 2026-11-05 08:19:18 +02:00
renovate[bot] a428d61cdb Update formatjs (#55644)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-05 01:13:34 +00:00
Bram KragtenandClaude Opus 5.5 fcf7eeae46 Use the preloaded Lovelace config again (#55626)
Since the "lovelace" dashboard got a real url_path (#28446), the panel threw
the config preloaded in core.ts away and fetched it a second time on every
page load at / or /lovelace/.

Use the preload for the "lovelace" dashboard and fetch again if it failed.
Only preload the config on /lovelace/, as / opens the default panel, which
is not known yet and is home for new installs.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 20:47:32 +01:00
6d2817b7cf Migrate the Cast launcher view picker (#55634)
* Migrate the Cast launcher view picker from ha-list

The view picker in the Cast launcher was the last use of the mwc-based
list outside of open pull requests. It is now a single-select
ha-list-selectable.

Views without an icon get the dashboard icon in the start slot, which
mwc never showed because it was put in a slot ha-list-item does not
have. As every row has an icon when any view has one, the hide-icons
attribute is no longer needed.

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

* Enhance view selection in HcCast component with accessible header and click handling

* Type Cast view click event target

Co-authored-by: wendevlin <[email protected]>

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-11-04 19:46:53 +00:00
Maarten Lakerveld 640e4e2f94 Resolve security and maintenance entities through one helper per page (#55609)
The security page, the maintenance page, the Home summary badges and
the Home overview each rebuilt the same filter functions and ran
findEntities over every state. Add findFilteredEntities, which takes
the states and registries as one object, and a findSecurityEntities
and findMaintenanceEntities wrapper so every consumer resolves a
page's entity set the same way. No behaviour change.
2026-11-04 20:32:36 +01:00
renovate[bot] 6670139c02 Update dependency @bundle-stats/plugin-webpack-filter to v4.23.0 (#55642)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-04 20:13:39 +02:00
Erwin DounaandCopilot Autofix powered by AI 93eb44b041 Offer log download after disabling debug logging (#55621)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-11-04 16:49:28 +01:00
Joost LekkerkerkerandClaude Opus 5.5 ba6067f048 Label marketplace open issue count as issues and pull requests (#55635)
* Label marketplace open issue count as issues and pull requests

The GitHub open issues count includes pull requests. Rename the
translation keys and update the English strings so the label matches
the number shown.

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

* Update marketplace tests for the renamed open issues keys

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 17:22:53 +02:00
renovate[bot] cd866a497a Update dependency globals to v17.14.0 (#55633)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-04 17:20:30 +02:00
98a8483426 onboarding-location migrate list (#55467)
* onboarding-location migrate list

* Apply batched suggestions from code review

Co-authored-by: Aidan Timson <[email protected]>

* fix tests and review

---------

Co-authored-by: Aidan Timson <[email protected]>
Co-authored-by: Petar Petrov <[email protected]>
Co-authored-by: Wendelin <[email protected]>
2026-11-04 15:39:56 +02:00
33af67bafe Respect safe area insets on the onboarding page (#55623)
* Respect safe area insets on the onboarding page

* Update src/html/onboarding.html.template

Co-authored-by: Wendelin <[email protected]>

* Update src/html/onboarding.html.template

Co-authored-by: Maarten Lakerveld <[email protected]>

---------

Co-authored-by: Wendelin <[email protected]>
Co-authored-by: Maarten Lakerveld <[email protected]>
2026-11-04 12:58:51 +00:00
Bram KragtenandClaude Opus 5.5 027d9578ec Match the entity filter in the states tool to the entity column (#55622)
The entity filter matched the full name and the friendly name, so typing a
device name returned entities whose entity column showed no match. It now
matches the entity ID and the name the entity column shows. When the device
column is hidden, it still matches the full name.

Fixes #55451

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 14:54:05 +02:00
Bram KragtenandClaude Opus 5.5 5fef8f3a68 Refetch the cached lovelace dashboard after it changed elsewhere (#55624)
The "lovelace" panel is cached by the panel resolver, so it should stay
subscribed to config updates while the user is on another page. Since the
urlPath getter returns "lovelace" instead of null, the null check in
disconnectedCallback always unsubscribed it, and nothing resubscribed on
reconnect. Changes saved from another page, like adding a device section
from the device page, only showed after a browser refresh.

Also reset the refetch flag once used, so returning to the dashboard
without a change doesn't refetch the config again, and let a pending
refetch take precedence over the locale rebuild, which would otherwise
rebuild from the stale config.

Fixes #55440

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 14:53:35 +02:00
Wendelin 18e65c1940 Show a loading skeleton while the card editor loads (#55625) 2026-11-04 11:22:55 +00:00
378 changed files with 12936 additions and 4085 deletions

No files matched your search

@@ -10,6 +10,7 @@ Use this skill when editing CSS, layout, visual hierarchy, theme integration, re
## Theme And Layout Basics
- Use Home Assistant CSS custom properties instead of hardcoded colors.
- CSS in the static HTML templates under `src/html/` must give custom properties a fallback value (for example `var(--ha-space-4, 16px)`), because the global tokens only exist once the JS-appended global stylesheet from `append-ha-style` has loaded.
- Use `--ha-space-*` spacing tokens instead of hardcoded spacing where possible.
- Keep components mobile-first and enhance for desktop.
- Keep layouts RTL-safe. Prefer logical properties when they fit.
+4 -3
View File
@@ -35,11 +35,12 @@ runs:
NETLIFY_AUTH_TOKEN: ${{ inputs.auth-token }}
NETLIFY_SITE_ID: ${{ inputs.site-id }}
run: |
# Execute the deployment
# Execute the deployment (--filter picks the root workspace project,
# --no-build because we deploy an already built directory)
if [ -n "$ALIAS" ]; then
npx -y netlify-cli deploy --dir="$DIR" --alias "$ALIAS" --json > deploy_output.json
npx -y netlify-cli deploy --filter home-assistant-frontend --no-build --dir="$DIR" --alias "$ALIAS" --json > deploy_output.json
else
npx -y netlify-cli deploy --dir="$DIR" --prod --json > deploy_output.json
npx -y netlify-cli deploy --filter home-assistant-frontend --no-build --dir="$DIR" --prod --json > deploy_output.json
fi
# Collect the urls from the deployment output
@@ -16,6 +16,7 @@ runs:
with:
path: |
node_modules
packages/*/node_modules
~/.local/share/pnpm/store
key: >-
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
@@ -35,6 +36,7 @@ runs:
with:
path: |
node_modules
packages/*/node_modules
~/.local/share/pnpm/store
key: >-
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
+1
View File
@@ -28,6 +28,7 @@ runs:
with:
path: |
node_modules
packages/*/node_modules
~/.local/share/pnpm/store
key: >-
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
+2 -2
View File
@@ -18,7 +18,7 @@ jobs:
deploy_dev:
runs-on: ubuntu-latest
name: Deploy Development
if: github.repository == 'home-assistant/frontend' && github.event_name != 'push'
if: github.repository == 'home-assistant/frontend' && github.ref_name != 'master'
environment:
name: Cast Development
url: ${{ steps.deploy.outputs.netlify_url }}
@@ -50,7 +50,7 @@ jobs:
deploy_master:
runs-on: ubuntu-latest
name: Deploy Production
if: github.repository == 'home-assistant/frontend' && github.event_name == 'push'
if: github.repository == 'home-assistant/frontend' && github.ref_name == 'master'
environment:
name: Cast Production
url: ${{ steps.deploy.outputs.netlify_url }}
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
deploy_dev:
runs-on: ubuntu-latest
name: Demo Development
if: github.repository == 'home-assistant/frontend' && (github.event_name != 'push' || github.ref_name != 'master')
if: github.repository == 'home-assistant/frontend' && github.ref_name != 'master'
environment:
name: Demo Development
url: ${{ steps.deploy.outputs.unique_deploy_url }}
@@ -50,7 +50,7 @@ jobs:
deploy_master:
runs-on: ubuntu-latest
name: Demo Production
if: github.repository == 'home-assistant/frontend' && github.event_name == 'push' && github.ref_name == 'master'
if: github.repository == 'home-assistant/frontend' && github.ref_name == 'master'
environment:
name: Demo Production
url: ${{ steps.deploy.outputs.netlify_url }}
+4 -4
View File
@@ -38,7 +38,7 @@ jobs:
name: Prepare container dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
image: mcr.microsoft.com/playwright:v1.65.0-noble@sha256:6e3fb709361521eb27161606e6da6d2674e5e89fa16516cb37d6fdd91372273d
options: --user 1001
defaults:
run:
@@ -155,7 +155,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
image: mcr.microsoft.com/playwright:v1.65.0-noble@sha256:6e3fb709361521eb27161606e6da6d2674e5e89fa16516cb37d6fdd91372273d
options: --user 1001 --ipc=host
defaults:
run:
@@ -207,7 +207,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
image: mcr.microsoft.com/playwright:v1.65.0-noble@sha256:6e3fb709361521eb27161606e6da6d2674e5e89fa16516cb37d6fdd91372273d
options: --user 1001 --ipc=host
defaults:
run:
@@ -261,7 +261,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
image: mcr.microsoft.com/playwright:v1.65.0-noble@sha256:6e3fb709361521eb27161606e6da6d2674e5e89fa16516cb37d6fdd91372273d
options: --user 1001 --ipc=host
defaults:
run:
+8
View File
@@ -28,10 +28,18 @@ AI can be a helpful tool for writing code and documentation. However, due to the
All contributions must be reviewed and understood by the contributor before submission. You should be able to explain every change in a pull request you submit. Pull requests that appear to be unreviewed AI output will be closed without review.
## Design contributions
AI can be a helpful tool to explore ideas and build mockups and prototypes. But AI tends to give the most common answer, not the one that fits Home Assistant and its users. So we require a human who brings the point of view and owns the design decisions.
You should be able to explain the problem your design solves and why you made your choices. If a mockup or prototype was mostly generated with AI, say so when you share it. Check your work against the [design guidelines](https://contributors.home-assistant.io/design/), as AI tools often make up components, colors, or spacing that don't exist in Home Assistant. Design proposals that look like unreviewed AI output may be closed or hidden.
## Our use of AI
Some of our projects use AI tools to assist with code reviews, issue triaging, reporting, and other project management tasks. These tools may leave comments on pull requests or issues. As with any automated tooling, these comments are not always correct.
We also use AI to help keep the design system, the Figma kit, and the code in sync. The design decisions behind them are made by people.
If an AI tool leaves a comment on your contribution, treat it as you would any other review comment. If you believe it is incorrect, say so; a brief explanation is sufficient. Maintainers always have the final say. If in doubt, ask a maintainer.
## Enforcement
@@ -1,4 +1,3 @@
/* global module, require */
// rspack/webpack loader that minifies the HTML, SVG, and CSS inside lit
// tagged template literals using `minify-literals` (html-minifier-next +
// lightningcss). Replaces the unmaintained babel-plugin-template-html-minifier.
@@ -36,6 +35,14 @@ const initLoader = (browserslistEnv) => {
return {
minifyHTMLLiterals: minifyModule.minifyHTMLLiterals,
lightningcssTargets,
// With the legacy floor at Chrome 67, lightningcss rewrites rgba()
// to 8-digit hex (#rrggbbaa). Some kiosk webviews served the legacy
// build (e.g. Shelly Wall Display) can't parse that, so any
// var(--divider-color) border falls back to currentColor. Always
// compile hex alpha colors to rgba() in legacy builds.
lightningcssInclude: browserslistEnv.startsWith("legacy")
? lightningcssModule.Features.HexAlphaColors
: 0,
};
})
);
@@ -67,12 +74,13 @@ module.exports = function minifyTemplateLiteralsLoader(source, map, meta) {
const { browserslistEnv } = this.getOptions();
initLoader(browserslistEnv)
.then(({ minifyHTMLLiterals, lightningcssTargets }) =>
.then(({ minifyHTMLLiterals, lightningcssTargets, lightningcssInclude }) =>
minifyHTMLLiterals(source, {
fileName: this.resourcePath,
html: htmlOptions,
css: {
targets: lightningcssTargets,
include: lightningcssInclude,
},
})
)
+38
View File
@@ -68,8 +68,42 @@ const CONFIG_FILES = [
path.join(__dirname, "lit-disable-dev-mode-loader.cjs"),
path.join(__dirname, "babel-plugins", "custom-polyfill-plugin.js"),
path.join(__dirname, "babel-plugins", "inline-constants-plugin.cjs"),
// Its exports define the components source aliases below
path.join(__dirname, "..", "packages", "components", "package.json"),
];
// Resolve @home-assistant/components entry points to their sources, so the package
// needs no build step during development. The entry points are read from the
// "ha-source" export condition of the package; subpath patterns are expanded
// for every element, i.e. every directory with a global.ts, like "card".
const COMPONENTS_PACKAGE_DIR = path.join(
paths.root_dir,
"packages",
"components"
);
const COMPONENTS_SRC_DIR = path.join(
COMPONENTS_PACKAGE_DIR,
"src",
"components"
);
const componentElements = fs
.readdirSync(COMPONENTS_SRC_DIR, { recursive: true })
.filter((file) => path.basename(file) === "global.ts")
.map((file) => path.dirname(file).split(path.sep).join("/"));
const componentsSourceAliases = Object.fromEntries(
Object.entries(require("../packages/components/package.json").exports)
.filter(([, target]) => target["ha-source"])
.flatMap(([subpath, target]) =>
(subpath.includes("*") ? componentElements : [undefined]).map((name) => [
`${path.posix.join("@home-assistant/components", name ? subpath.replaceAll("*", name) : subpath)}$`,
path.join(
COMPONENTS_PACKAGE_DIR,
name ? target["ha-source"].replaceAll("*", name) : target["ha-source"]
),
])
)
);
// Patches change a package's files but not its version, which is all the
// node_modules snapshot checks, so their contents go into the version too.
const PATCHES_DIR = path.join(paths.root_dir, "patches");
@@ -244,6 +278,9 @@ const createRspackConfig = ({
path: paths.root_dir,
env: "legacy",
}),
// Keep rgba() instead of #rrggbbaa, see
// minify-template-literals-loader.cjs
include: { hexAlphaColors: true },
},
},
],
@@ -428,6 +465,7 @@ const createRspackConfig = ({
"src/resources/polyfills/bigdecimal.ts"
),
}),
...componentsSourceAliases,
"lit/static-html$": "lit/static-html.js",
"lit/decorators$": "lit/decorators.js",
"lit/directive$": "lit/directive.js",
+5 -5
View File
@@ -1,5 +1,5 @@
/*
Cache-Control: public, max-age: 0, s-maxage=3600, must-revalidate
Cache-Control: public, max-age=0, s-maxage=3600, must-revalidate
Content-Security-Policy: form-action https:
Feature-Policy: vibrate 'none'; geolocation 'none'; midi 'none'; microphone 'none'; camera 'none'; magnetometer 'none'; gyroscope 'none'; speaker 'none'; vibrate 'none'; payment 'none'
Referrer-Policy: no-referrer-when-downgrade
@@ -8,13 +8,13 @@
X-XSS-Protection: 1; mode=block
/images/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Cache-Control: public, max-age=604800, s-maxage=604800
/manifest.json
Cache-Control: public, max-age: 3600, s-maxage=3600
Cache-Control: public, max-age=3600, s-maxage=3600
/frontend_es5/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Cache-Control: public, max-age=604800, s-maxage=604800
/frontend_latest/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Cache-Control: public, max-age=604800, s-maxage=604800
+1 -1
View File
@@ -1,6 +1,6 @@
{
"background_color": "#FFFFFF",
"description": "Show Home Assistant on your Chromecast or Google Assistant devices with a screen.",
"description": "Show Home Assistant on your Chromecast or Google Home devices with a screen.",
"dir": "ltr",
"display": "standalone",
"icons": [
+2 -2
View File
@@ -5,7 +5,7 @@
<meta property="og:type" content="website" />
<meta
property="og:description"
content="Show Home Assistant on your Chromecast or Google Assistant devices with a screen."
content="Show Home Assistant on your Chromecast or Google Home devices with a screen."
/>
<meta
property="og:image"
@@ -16,7 +16,7 @@
<meta name="twitter:title" content="Home Assistant Cast" />
<meta
name="twitter:description"
content="Show Home Assistant on your Chromecast or Google Assistant devices with a screen."
content="Show Home Assistant on your Chromecast or Google Home devices with a screen."
/>
<meta
name="twitter:image"
+2 -1
View File
@@ -3,6 +3,7 @@
<head>
<title>Home Assistant Cast - FAQ</title>
<link rel="icon" href="/images/ha-cast-icon.png" type="image/png" />
<%= renderTemplate("../../../src/html/_meta_viewport.html.template") %>
<%= renderTemplate("../../../src/html/_style_base.html.template") %>
<style>
body {
@@ -52,7 +53,7 @@
<p>
Home Assistant Cast allows you to show your Home Assistant data on a
Chromecast device and allows you to interact with Home Assistant on
Google Assistant devices with a screen.
Google Home devices with a screen.
</p>
</div>
+1
View File
@@ -4,6 +4,7 @@
<title>Home Assistant Cast</title>
<link rel="manifest" href="/manifest.json" />
<link rel="icon" href="/images/ha-cast-icon.png" type="image/png" />
<%= renderTemplate("../../../src/html/_meta_viewport.html.template") %>
<%= renderTemplate("../../../src/html/_style_base.html.template") %>
<style>
body {
+1
View File
@@ -4,6 +4,7 @@
<% for (const entry of latestEntryJS) { %>
<script type="module" src="<%= entry %>"></script>
<% } %>
<%= renderTemplate("../../../src/html/_meta_viewport.html.template") %>
<%= renderTemplate("../../../src/html/_style_base.html.template") %>
<style>
body {
+42 -49
View File
@@ -1,7 +1,7 @@
import { mdiCast, mdiCastConnected, mdiViewDashboard } from "@mdi/js";
import type { Auth, Connection } from "home-assistant-js-websocket";
import type { TemplateResult, PropertyValues } from "lit";
import { LitElement, css, html } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { CastManager } from "../../../../src/cast/cast_manager";
import {
@@ -13,12 +13,14 @@ import {
enableWrite,
saveTokens,
} from "../../../../src/common/auth/token_storage";
import type { HASSDomCurrentTargetEvent } from "../../../../src/common/dom/fire_event";
import { atLeastVersion } from "../../../../src/common/config/version";
import "../../../../src/components/ha-button";
import "../../../../src/components/ha-icon";
import "../../../../src/components/ha-list";
import "../../../../src/components/ha-list-item";
import "../../../../src/components/ha-svg-icon";
import "../../../../src/components/item/ha-list-item-option";
import type { HaListItemOption } from "../../../../src/components/item/ha-list-item-option";
import "../../../../src/components/list/ha-list-selectable";
import {
getLegacyLovelaceCollection,
getLovelaceCollection,
@@ -52,6 +54,9 @@ class HcCast extends LitElement {
`
: undefined;
const views = this.lovelaceViews ?? [{ title: "Home" }];
const hasIcons = views.some((view) => view.icon);
return html`
<hc-layout .auth=${this.auth} .connection=${this.connection}>
${
@@ -92,45 +97,41 @@ class HcCast extends LitElement {
</p>
`
: html`
<div class="section-header">PICK A VIEW</div>
<ha-list @action=${this._handlePickView} activatable>
${(
this.lovelaceViews ?? [
{
title: "Home",
},
]
).map(
<div class="section-header" id="pick-view-header">
PICK A VIEW
</div>
<ha-list-selectable
controlled
aria-labelledby="pick-view-header"
>
${views.map(
(view, idx) => html`
<ha-list-item
graphic="avatar"
.activated=${
this.castManager.status?.lovelacePath ===
(view.path ?? idx)
}
<ha-list-item-option
data-index=${idx}
@click=${this._handlePickView}
.headline=${view.title || view.path || "Unnamed view"}
.selected=${
this.castManager.status?.lovelacePath ===
(view.path ?? idx)
}
>
${view.title || view.path || "Unnamed view"}
${
view.icon
? html`
<ha-icon
!hasIcons
? nothing
: view.icon
? html`<ha-icon
slot="start"
.icon=${view.icon}
slot="graphic"
></ha-icon>
`
: html`<ha-svg-icon
slot="item-icon"
.path=${mdiViewDashboard}
></ha-svg-icon>`
></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${mdiViewDashboard}
></ha-svg-icon>`
}
</ha-list-item>
</ha-list-item-option>
`
)}</ha-list
>
)}
</ha-list-selectable>
`
}
@@ -193,14 +194,6 @@ class HcCast extends LitElement {
});
}
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
this.toggleAttribute(
"hide-icons",
this.lovelaceViews ? !this.lovelaceViews.some((view) => view.icon) : true
);
}
private async _handleSkipSaveTokens() {
this.askWrite = false;
}
@@ -214,8 +207,13 @@ class HcCast extends LitElement {
this.castManager.requestSession();
}
private async _handlePickView(ev: CustomEvent<{ index: number }>) {
const path = this.lovelaceViews?.[ev.detail.index]?.path ?? ev.detail.index;
// Listens to clicks instead of `ha-list-item-selected`, which does not fire
// for the selected view, so the current view can still be cast again.
private async _handlePickView(
ev: HASSDomCurrentTargetEvent<HaListItemOption>
) {
const index = Number(ev.currentTarget.dataset.index);
const path = this.lovelaceViews?.[index]?.path ?? index;
await ensureConnectedCastSession(this.castManager!, this.auth!);
castSendShowLovelaceView(this.castManager, this.auth.data.hassUrl, path);
}
@@ -270,16 +268,11 @@ class HcCast extends LitElement {
color: var(--secondary-text-color);
}
ha-list-item ha-icon,
ha-list-item ha-svg-icon {
padding: 12px;
ha-list-item-option ha-icon,
ha-list-item-option ha-svg-icon {
color: var(--secondary-text-color);
}
:host([hide-icons]) ha-icon {
display: none;
}
.spacer {
flex: 1;
}
+1 -1
View File
@@ -48,7 +48,7 @@ const translateErr = (err) =>
const INTRO = html`
<p>
Home Assistant Cast allows you to cast your Home Assistant installation to
Chromecast video devices and to Google Assistant devices with a screen.
Chromecast video devices and to Google Home devices with a screen.
</p>
<p>
For more information, see the
+1 -1
View File
@@ -6,7 +6,7 @@ const playerManager = castContext.getPlayerManager();
playerManager.setMessageInterceptor("LOAD", (loadRequestData) => {
const media = loadRequestData.media;
// Special handling if it came from Google Assistant
// Special handling if it came from Google Home
if (media.entity) {
media.contentId = media.entity;
media.streamType = "LIVE";
+1 -1
View File
@@ -13,7 +13,7 @@ export const castDemoLovelace: () => LovelaceConfig = () => {
{
type: "markdown",
title: "Home Assistant Cast",
content: `With Home Assistant you can easily create interfaces (just like this one) which can be shown on Chromecast devices connected to TVs or Google Assistant devices with a screen.${
content: `With Home Assistant you can easily create interfaces (just like this one) which can be shown on Chromecast devices connected to TVs or Google Home devices with a screen.${
touchSupported
? "\n\nYou are able to interact with this demo using the touch screen."
: "\n\nOn a Google Nest Hub you are able to interact with Home Assistant Cast via the touch screen."
+1 -1
View File
@@ -117,7 +117,7 @@ playerManager.setMessageInterceptor("LOAD", (loadRequestData) => {
// We received a play media command, hide Lovelace and show media player
showMediaPlayer();
const media = loadRequestData.media;
// Special handling if it came from Google Assistant
// Special handling if it came from Google Home
if (media.entity) {
media.contentId = media.entity;
media.streamType = "LIVE";
+5 -5
View File
@@ -1,21 +1,21 @@
/*
Cache-Control: public, max-age: 0, s-maxage=3600, must-revalidate
Cache-Control: public, max-age=0, s-maxage=3600, must-revalidate
Content-Security-Policy: form-action https:
Referrer-Policy: no-referrer-when-downgrade
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
/api/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Cache-Control: public, max-age=604800, s-maxage=604800
/assets/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Cache-Control: public, max-age=604800, s-maxage=604800
/frontend_es5/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Cache-Control: public, max-age=604800, s-maxage=604800
/frontend_latest/*
Cache-Control: public, max-age: 604800, s-maxage=604800
Cache-Control: public, max-age=604800, s-maxage=604800
Access-Control-Allow-Origin: *
/embed.js
+173
View File
@@ -0,0 +1,173 @@
import { ContextEvent } from "@lit/context";
import type { TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { consume } from "../../../src/common/decorators/consume";
import { computeRTLDirection } from "../../../src/common/util/compute_rtl";
import { handleAction } from "../../../src/panels/lovelace/common/handle-action";
import type { HomeAssistant } from "../../../src/types";
import { embedHassContext } from "./context";
import type { ScriptObject } from "./script-data";
import { readScriptObject } from "./script-data";
const pageProvider = (): HTMLElement =>
document.querySelector("ha-embed-provider") ??
document.body.appendChild(document.createElement("ha-embed-provider"));
// Looks past shadow roots, so an element inside another component still
// finds the provider around that component.
const insideProvider = (element: Element): boolean => {
let current: Element | null = element;
while (current) {
if (current.closest("ha-embed-provider")) {
return true;
}
const root = current.getRootNode();
current = root instanceof ShadowRoot ? root.host : null;
}
return false;
};
/**
* The base of the embed elements that render dashboard content. The element
* uses the provider with the ID in `provider`, else the closest provider
* around it, else the first provider on the page. If the page has no
* provider, the element adds an empty one.
*/
export abstract class EmbedElement<
Config extends ScriptObject,
> extends LitElement {
/**
* The configuration of the content, the same as in a dashboard. Also read
* from a JSON or YAML script child, see `readScriptObject`.
*/
@property({ type: Object }) public config?: Config;
/** The ID of the `ha-embed-provider` to use. */
@property() public provider?: string;
@state()
@consume({ context: embedHassContext, subscribe: true })
protected _hass?: HomeAssistant;
// A provider that is not an ancestor, which gets the context requests of the
// element and its content. Context requests only reach ancestors by
// themselves.
private _linkedProvider?: HTMLElement;
private _scriptRead = false;
constructor() {
super();
this.addEventListener("context-request", (ev) => {
if (!this._linkedProvider) {
return;
}
ev.stopPropagation();
this._linkedProvider.dispatchEvent(
new ContextEvent(
ev.context,
ev.contextTarget,
ev.callback,
ev.subscribe
)
);
});
// Cards fire their tap, hold and double tap actions for the app to handle
this.addEventListener("hass-action", (ev) => {
if (this._hass) {
// The element that fired the action, so that fire-dom-event reaches
// listeners inside custom cards, as in action-mixin
const [origin] = ev.composedPath();
const node = origin instanceof HTMLElement ? origin : this;
handleAction(node, this._hass, ev.detail.config, ev.detail.action);
}
});
// The page's address belongs to the page, so only a hash on it, such as
// an anchor, navigates there. The browser does that itself, as the app's
// navigate() doesn't scroll to the anchor.
this.addEventListener("hass-navigate", (ev) => {
const { path, replace } = ev.detail;
ev.preventDefault();
if (!path.startsWith("#")) {
this.navigateTo(path, replace);
} else if (replace) {
window.location.replace(path);
} else {
window.location.hash = path;
}
});
}
public connectedCallback() {
// Before super.connectedCallback(), which sends the context requests.
this._linkedProvider = this.provider
? (document.getElementById(this.provider) ?? undefined)
: insideProvider(this)
? undefined
: pageProvider();
super.connectedCallback();
// Only once, so that moving the element keeps a config set since
if (this._scriptRead) {
return;
}
this._scriptRead = true;
const data = readScriptObject(this);
if (data !== undefined) {
this.setScriptData(data);
}
}
/**
* A navigate action of the content, other than to a hash on the page. It
* leaves the page where it is, like actions that open a dialog. `replace`
* is the action's `navigation_replace`.
*/
protected navigateTo(_path: string, _replace?: boolean) {
// Nothing to navigate to, outside a dashboard
}
/** Whether the data of a script child is a configuration of the content. */
protected abstract isConfig(data: ScriptObject): data is Config;
/** Sets the data of a JSON or YAML script child, see `readScriptObject`. */
protected setScriptData(data: ScriptObject) {
if (this.isConfig(data)) {
this.config = data;
}
}
/** The content, or `undefined` while there is nothing to render. */
protected abstract renderContent(
hass: HomeAssistant
): TemplateResult | undefined;
// The content in the theme of the provider
protected render() {
const hass = this._hass;
const content = hass && this.renderContent(hass);
if (!hass || !content) {
return nothing;
}
return html`<div
class=${classMap({ theme: true, dark: hass.themes.darkMode })}
dir=${computeRTLDirection(hass)}
>
${content}
</div>`;
}
}
+3
View File
@@ -4,6 +4,9 @@ import "./workers";
// they are defined, and cards need a provider to request their context from.
import "./ha-embed-provider";
import "./ha-embed-card";
import "./ha-embed-section";
import "./ha-embed-view";
import "./ha-embed-dashboard";
// For custom cards, which can't load them themselves. The built-in cards only
// load ha-list-item-button with the map card. The full app loads ha-select
// with the more info dialog.
+22 -141
View File
@@ -1,139 +1,44 @@
import { ContextEvent } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { css, html } from "lit";
import { customElement } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { consume } from "../../../src/common/decorators/consume";
import { computeRTLDirection } from "../../../src/common/util/compute_rtl";
import type { LovelaceCardConfig } from "../../../src/data/lovelace/config/card";
import { handleAction } from "../../../src/panels/lovelace/common/handle-action";
import { computeCardGridSize } from "../../../src/panels/lovelace/common/compute-card-grid-size";
import "../../../src/panels/lovelace/cards/hui-card";
import type { HuiCard } from "../../../src/panels/lovelace/cards/hui-card";
import type { HomeAssistant } from "../../../src/types";
import { embedHassContext } from "./context";
import { readScriptData } from "./script-data";
import { embedThemeStyles } from "./theme-styles";
const pageProvider = (): HTMLElement =>
document.querySelector("ha-embed-provider") ??
document.body.appendChild(document.createElement("ha-embed-provider"));
// Looks past shadow roots, so a card inside another component still finds
// the provider around that component.
const insideProvider = (element: Element): boolean => {
let current: Element | null = element;
while (current) {
if (current.closest("ha-embed-provider")) {
return true;
}
const root = current.getRootNode();
current = root instanceof ShadowRoot ? root.host : null;
}
return false;
};
import { EmbedElement } from "./embed-element";
import type { ScriptObject } from "./script-data";
import { embedElementStyles } from "./theme-styles";
/**
* Renders a Lovelace card. The card uses the provider with the ID in
* `provider`, else the closest provider around it, else the first provider on
* the page. If the page has no provider, the card adds an empty one.
* Renders a Lovelace card with the backend of a provider, see `EmbedElement`.
*
* The card takes the columns and rows it would take in a section, so cards
* in an element with `display: grid` and 12 columns lay out like a section.
* It also sets `--column-size` and `--row-size` on itself.
*/
@customElement("ha-embed-card")
export class HaEmbedCard extends LitElement {
/**
* The card configuration. Also read from a JSON or YAML script child, see
* `readScriptData`.
*/
@property({ type: Object }) public config?: LovelaceCardConfig;
/** The ID of the `ha-embed-provider` to use. */
@property() public provider?: string;
@state()
@consume({ context: embedHassContext, subscribe: true })
private _hass?: HomeAssistant;
// A provider that is not an ancestor, which gets the context requests of the
// card and its content. Context requests only reach ancestors by themselves.
private _linkedProvider?: HTMLElement;
private _scriptRead = false;
export class HaEmbedCard extends EmbedElement<LovelaceCardConfig> {
private _card = createRef<HuiCard>();
constructor() {
super();
this.addEventListener("context-request", (ev) => {
if (!this._linkedProvider) {
return;
}
ev.stopPropagation();
this._linkedProvider.dispatchEvent(
new ContextEvent(
ev.context,
ev.contextTarget,
ev.callback,
ev.subscribe
)
);
});
// Cards fire their tap, hold and double tap actions for the app to handle
this.addEventListener("hass-action", (ev) => {
if (this._hass) {
// The element that fired the action, so that fire-dom-event reaches
// listeners inside custom cards, as in action-mixin
const node = (ev.composedPath()[0] as HTMLElement | undefined) ?? this;
handleAction(node, this._hass, ev.detail.config, ev.detail.action);
}
});
// A card names its type
protected isConfig(data: ScriptObject): data is LovelaceCardConfig {
return "type" in data && typeof data.type === "string";
}
public connectedCallback() {
// Before super.connectedCallback(), which sends the context requests.
this._linkedProvider = this.provider
? (document.getElementById(this.provider) ?? undefined)
: insideProvider(this)
? undefined
: pageProvider();
super.connectedCallback();
// Only once, so that moving the card keeps a config set since
if (this._scriptRead) {
return;
protected renderContent(hass: HomeAssistant) {
if (!this.config) {
return undefined;
}
this._scriptRead = true;
const config = readScriptData(this) as LovelaceCardConfig | undefined;
if (config) {
this.config = config;
}
}
protected render() {
if (!this._hass || !this.config) {
return nothing;
}
return html`<div
class=${classMap({ theme: true, dark: this._hass.themes.darkMode })}
dir=${computeRTLDirection(this._hass)}
>
<hui-card
${ref(this._card)}
.hass=${this._hass}
.config=${this.config}
.layout=${"grid"}
@card-updated=${this._updateSize}
@card-visibility-changed=${this._updateSize}
></hui-card>
</div>`;
return html`<hui-card
${ref(this._card)}
.hass=${hass}
.config=${this.config}
.layout=${"grid"}
@card-updated=${this._updateSize}
@card-visibility-changed=${this._updateSize}
></hui-card>`;
}
protected updated() {
@@ -163,7 +68,7 @@ export class HaEmbedCard extends LitElement {
};
static styles = [
embedThemeStyles,
embedElementStyles,
css`
/* The sizes of a card in a section, see hui-grid-section */
:host {
@@ -176,9 +81,6 @@ export class HaEmbedCard extends LitElement {
:host([full-width]) {
grid-column: 1 / -1;
}
:host([hidden]) {
display: none;
}
:host([fit-rows]) .theme {
height: calc(
(var(--row-size, 1) * (var(--row-height) + var(--row-gap))) - var(
@@ -186,27 +88,6 @@ export class HaEmbedCard extends LitElement {
)
);
}
/* The text styles the app sets on html and body, so that the card does
not inherit the text styles of the page that embeds it. */
.theme {
color: var(--primary-text-color);
font-family: var(--ha-font-family-body);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-body);
line-height: normal;
letter-spacing: normal;
text-align: start;
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: var(--ha-font-smoothing);
}
/* What setDirectionStyles sets on the app for a right-to-left language,
here only for the card */
.theme[dir="rtl"] {
--direction: rtl;
--float-start: right;
--float-end: left;
--scale-direction: -1;
}
`,
];
}
+173
View File
@@ -0,0 +1,173 @@
import type { PropertyValues } from "lit";
import { css, html } from "lit";
import { customElement } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../../src/common/dom/fire_event";
import type {
LovelaceConfig,
LovelaceRawConfig,
} from "../../../src/data/lovelace/config/types";
import "../../../src/panels/lovelace/hui-root";
import type { HomeAssistant } from "../../../src/types";
import { EmbedLovelaceElement, isDashboardConfig } from "./lovelace";
import type { ScriptObject } from "./script-data";
/**
* Renders a dashboard, or a dashboard strategy, with its tabs, with the
* backend of a provider, see `EmbedElement`. Each dashboard keeps its own
* view in `view`, so switching tabs and following links to its views don't
* change the page's URL. The dashboard scrolls inside the element, under its
* toolbar.
*/
@customElement("ha-embed-dashboard")
export class HaEmbedDashboard extends EmbedLovelaceElement<LovelaceRawConfig> {
private _scroller = createRef<HTMLDivElement>();
// The route of hui-root, here without a page URL around it. A new
// configuration gives a new route, as hui-root only looks up the view of
// the path again when its route changes, and the views may have moved.
private _route = memoizeOne(
(view: string | undefined, _config: LovelaceConfig) => ({
prefix: "",
path: `/${view ?? ""}`,
})
);
protected get rawConfig() {
return this.config;
}
protected isConfig(data: ScriptObject): data is LovelaceRawConfig {
return isDashboardConfig(data);
}
// The view shown, and the views before it, which the back button of a
// subview without a `back_path` goes back through, as the browser's back
// would in the app
private _shownView?: string;
private _history: (string | undefined)[] = [];
// Going back, or a navigate action with `navigation_replace`, which
// replaces the view shown rather than adding to the views before it
private _skipHistory = false;
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (this.view === this._shownView) {
return;
}
if (this._shownView !== undefined) {
if (!this._skipHistory) {
this._history.push(this._shownView);
}
this._scroller.value?.scrollTo({ top: 0 });
}
this._skipHistory = false;
this._shownView = this.view;
}
protected navigateTo(path: string, replace?: boolean) {
const view = this.viewOfPath(path);
if (view === undefined || this.isSameView(view, this.view)) {
return;
}
this._skipHistory = Boolean(replace);
this.view = view;
}
// The embedded attribute is in the template, so that hui-root has it when
// it connects and skips the listeners on the page
protected renderContent(hass: HomeAssistant) {
if (!this._lovelace?.config.views.length) {
return undefined;
}
return html`<div class="scroller" ${ref(this._scroller)}>
<hui-root
embedded
no-edit
.hass=${hass}
.lovelace=${this._lovelace}
.route=${this._route(this.view, this._lovelace.config)}
.narrow=${this._narrow.value ?? false}
@view-navigate=${this._viewNavigate}
></hui-root>
</div>`;
}
// A tab, or the back button of a subview, which goes to its `back_path`
// when that is one of the dashboard's views, else to the view before it
private _viewNavigate(ev: HASSDomEvent<HASSDomEvents["view-navigate"]>) {
const view = this.viewOfPath(ev.detail.path);
if (!ev.detail.back) {
if (view !== undefined) {
this.view = view;
}
return;
}
const target = view === undefined ? this._previousView() : view;
// Following a `back_path` goes back too, so the view it goes to leaves
// the views before
if (
view !== undefined &&
this.isSameView(this._history[this._history.length - 1], view)
) {
this._history.pop();
}
if (!this.isSameView(target, this.view)) {
this._skipHistory = true;
this.view = target;
}
}
// The last view before that isn't the one shown, else the first view
private _previousView(): string | undefined {
while (this._history.length) {
const previous = this._history.pop();
if (!this.isSameView(previous, this.view)) {
return previous;
}
}
return undefined;
}
static styles = [
EmbedLovelaceElement.styles,
css`
/* Contained, so that the toolbar and background of hui-root, which are
fixed to the window in the app, are fixed to the element */
:host {
height: 600px;
contain: layout paint;
}
.theme,
.scroller {
height: 100%;
}
.scroller {
overflow: auto;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-embed-dashboard": HaEmbedDashboard;
}
}
+40 -12
View File
@@ -8,6 +8,7 @@ import { listenMediaQuery } from "../../../src/common/dom/media_query";
import type { AreaRegistryEntry } from "../../../src/data/area/area_registry";
import type { CalendarEventApiData } from "../../../src/data/calendar";
import { reducedMotionContext } from "../../../src/data/context";
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
import type { EnergyInfo, EnergyPreferences } from "../../../src/data/energy";
import { EMPTY_PREFERENCES } from "../../../src/data/energy";
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
@@ -22,6 +23,8 @@ import {
mockCalendarEvents,
WORK_EVENTS,
} from "../stubs/calendar";
import type { DemoDevice } from "../stubs/device_registry";
import { toDeviceRegistryEntry } from "../stubs/device_registry";
import { extEntry, newEntry } from "../stubs/entity_registry";
import { mockHistory } from "../stubs/history";
import { mockIcons } from "../stubs/icons";
@@ -40,6 +43,7 @@ import { readScriptData } from "./script-data";
interface EmbedProviderData {
entities?: EntityInput[];
areas?: DemoArea[];
devices?: DemoDevice[];
calendars?: Record<string, CalendarEventApiData[]>;
energy?: Partial<EnergyPreferences>;
}
@@ -54,8 +58,8 @@ const areaName = (areaId: string) => {
};
/**
* Holds a mocked Home Assistant backend for all `ha-embed-card` elements
* inside it. Cards in the same provider share their entities.
* Holds a mocked Home Assistant backend for all embed elements inside it.
* Elements in the same provider share their entities.
*/
@customElement("ha-embed-provider")
export class HaEmbedProvider extends LitElement {
@@ -67,13 +71,19 @@ export class HaEmbedProvider extends LitElement {
* All entities of the backend. Each change replaces all entities.
* Also read from a JSON or YAML script child, see `readScriptData`. The
* script holds either the list of entities or an object with `entities`,
* `areas`, `calendars` and `energy`.
* `areas`, `devices`, `calendars` and `energy`.
*/
@property({ type: Array }) public entities: EntityInput[] = [];
/** All areas. Without them, an area for each `area_id` of the entities. */
/**
* All areas. Without them, an area for each `area_id` of the entities and
* devices.
*/
@property({ attribute: false }) public areas?: DemoArea[];
/** All devices, which entities reference with their `device_id`. */
@property({ attribute: false }) public devices?: DemoDevice[];
/**
* Events of calendar entities by entity ID. Calendars without events here
* get sample events around today.
@@ -155,7 +165,11 @@ export class HaEmbedProvider extends LitElement {
hass.mockWS("config/area_registry/list", (_msg, currentHass) =>
Object.values(currentHass.areas)
);
// The registry of the entities, without settings such as calendar colors
hass.mockWS("config/device_registry/list", (_msg, currentHass) =>
Object.values(currentHass.devices)
);
// The registry of the entities, with the options each entity sets, such
// as timer presets. Other settings, such as calendar colors, are not set.
const registryEntries = (currentHass: MockHomeAssistant) =>
Object.values(currentHass.entities).map(
(entity): EntityRegistryEntry => ({
@@ -166,6 +180,9 @@ export class HaEmbedProvider extends LitElement {
}),
area_id: entity.area_id ?? null,
device_id: entity.device_id ?? null,
options:
this.entities.find((item) => item.entity_id === entity.entity_id)
?.options ?? null,
})
);
hass.mockWS("config/entity_registry/list", (_msg, currentHass) =>
@@ -222,6 +239,7 @@ export class HaEmbedProvider extends LitElement {
} else if (data) {
this.entities = data.entities ?? [];
this.areas = data.areas;
this.devices = data.devices;
this.calendars = data.calendars;
this.energy = data.energy;
}
@@ -238,14 +256,19 @@ export class HaEmbedProvider extends LitElement {
this._applyColorScheme();
}
if (changedProps.has("entities") || changedProps.has("areas")) {
if (
changedProps.has("entities") ||
changedProps.has("areas") ||
changedProps.has("devices")
) {
const devices = this.devices ?? [];
const areas: DemoArea[] =
this.areas ??
[
...new Set(
this.entities
.map((entity) => entity.area_id)
.filter((areaId) => areaId !== undefined)
[...this.entities, ...devices].flatMap((item) =>
item.area_id === undefined ? [] : [item.area_id]
)
),
].map((areaId) => ({ area_id: areaId, name: areaName(areaId) }));
@@ -253,11 +276,16 @@ export class HaEmbedProvider extends LitElement {
areas.forEach((area) => {
areasById[area.area_id] = toAreaRegistryEntry(area);
});
// Before the entities, so that their names can include the area
this.hass.updateHass({ areas: areasById });
const devicesById: Record<string, DeviceRegistryEntry> = {};
devices.forEach((device) => {
devicesById[device.id] = toDeviceRegistryEntry(device);
});
// Before the entities, so that their names can include the area and
// device
this.hass.updateHass({ areas: areasById, devices: devicesById });
// Entities that services changed keep their state when only the areas
// change
// or devices change
if (changedProps.has("entities")) {
this.hass.addEntities(this.entities, true);
} else {
+95
View File
@@ -0,0 +1,95 @@
import type { PropertyValues } from "lit";
import { css, html } from "lit";
import { customElement } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../../src/common/dom/fire_event";
import { normalizeSectionConfig } from "../../../src/data/lovelace/config/normalize";
import type { LovelaceSectionRawConfig } from "../../../src/data/lovelace/config/section";
import "../../../src/panels/lovelace/sections/hui-section";
import {
renderSectionContainer,
sectionContainerStyles,
} from "../../../src/panels/lovelace/sections/section-container";
import type { HomeAssistant } from "../../../src/types";
import { EmbedElement } from "./embed-element";
import type { ScriptObject } from "./script-data";
import { embedElementStyles } from "./theme-styles";
/**
* Renders a dashboard section, or a section strategy, with the backend of a
* provider, see `EmbedElement`. The section takes the width of the element.
*/
@customElement("ha-embed-section")
export class HaEmbedSection extends EmbedElement<LovelaceSectionRawConfig> {
// A title becomes a heading card, as on a dashboard
private _normalizedConfig = memoizeOne(normalizeSectionConfig);
// A section has cards, sections inside it, a strategy that makes them, a
// title, or the type of a custom section
protected isConfig(data: ScriptObject): data is LovelaceSectionRawConfig {
return (
"cards" in data ||
"sections" in data ||
"strategy" in data ||
"title" in data ||
("type" in data && typeof data.type === "string")
);
}
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
// The section that hid the element is gone, and the next one starts
// visible without telling it
if (changedProps.has("config") && !this.config) {
this.hidden = false;
}
}
// In the container that draws the background of the section, as in a
// sections view
protected renderContent(hass: HomeAssistant) {
if (!this.config) {
return undefined;
}
const config = this._normalizedConfig(this.config);
return renderSectionContainer(
hass,
config,
html`<hui-section
.hass=${hass}
.config=${config}
@section-visibility-changed=${this._visibilityChanged}
></hui-section>`
);
}
private _visibilityChanged(
ev: HASSDomEvent<HASSDomEvents["section-visibility-changed"]>
) {
// Sections inside the section fire the same event
if (ev.target !== ev.currentTarget) {
return;
}
this.hidden = !ev.detail.value;
}
static styles = [
embedElementStyles,
sectionContainerStyles,
css`
:host {
display: block;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-embed-section": HaEmbedSection;
}
}
+134
View File
@@ -0,0 +1,134 @@
import { css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { keyed } from "lit/directives/keyed";
import memoizeOne from "memoize-one";
import type {
LovelaceConfig,
LovelaceRawConfig,
} from "../../../src/data/lovelace/config/types";
import type { LovelaceViewRawConfig } from "../../../src/data/lovelace/config/view";
import "../../../src/panels/lovelace/views/hui-view";
import "../../../src/panels/lovelace/views/hui-view-background";
import "../../../src/panels/lovelace/views/hui-view-container";
import type { HomeAssistant } from "../../../src/types";
import {
EmbedLovelaceElement,
findViewIndex,
isDashboardConfig,
} from "./lovelace";
import type { ScriptObject } from "./script-data";
/**
* Renders a dashboard view, a view strategy, or one view of a dashboard or
* dashboard strategy, with the backend of a provider, see `EmbedElement`.
* `config` holds the view.
*/
@customElement("ha-embed-view")
export class HaEmbedView extends EmbedLovelaceElement<LovelaceViewRawConfig> {
/**
* A dashboard configuration or dashboard strategy, to show its `view`
* instead of `config`. Also read from the `dashboard` key of a script child,
* next to `view`.
*/
@property({ type: Object }) public dashboard?: LovelaceRawConfig;
private _viewDashboard = memoizeOne(
(view: LovelaceViewRawConfig): LovelaceConfig => ({ views: [view] })
);
protected get rawConfig() {
return this.dashboard ?? (this.config && this._viewDashboard(this.config));
}
// A view strategy and a dashboard strategy both have a `strategy` key, so
// a dashboard comes in its own key, which no view has
protected isConfig(data: ScriptObject): data is LovelaceViewRawConfig {
return !("dashboard" in data);
}
protected setScriptData(data: ScriptObject) {
if (!("dashboard" in data)) {
super.setScriptData(data);
return;
}
const { dashboard } = data;
if (
typeof dashboard === "object" &&
dashboard !== null &&
isDashboardConfig(dashboard)
) {
this.dashboard = dashboard;
}
if (
"view" in data &&
(typeof data.view === "string" || typeof data.view === "number")
) {
this.view ??= String(data.view);
}
}
// Nothing for a dashboard without views, such as an empty strategy result.
// The view's theme and background, or the dashboard's background, as in
// hui-root. Each view gets its own hui-view, as there, so that a view
// strategy still generating for the view before can't replace the cards of
// this one.
protected renderContent(hass: HomeAssistant) {
const views = this._lovelace?.config.views;
if (!this._lovelace || !views?.length) {
return undefined;
}
const index = findViewIndex(views, this.view);
const viewConfig = views[index];
const background =
viewConfig.background || this._lovelace.config.background;
return html`<hui-view-container .hass=${hass} .theme=${viewConfig.theme}>
<hui-view-background .hass=${hass} .background=${background}>
</hui-view-background>
${keyed(
viewConfig,
html`<hui-view
.hass=${hass}
.lovelace=${this._lovelace}
.index=${index}
.narrow=${this._narrow.value ?? false}
></hui-view>`
)}
</hui-view-container>`;
}
// A fixed background stays inside the element. With a height set on the
// element, a panel view fills it, and other views keep their own height.
static styles = [
EmbedLovelaceElement.styles,
css`
:host {
contain: layout paint;
}
.theme,
hui-view-container,
hui-view {
display: block;
height: 100%;
}
/* The background sits below the view, above the element's own */
hui-view-container {
position: relative;
isolation: isolate;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-embed-view": HaEmbedView;
}
}
+217
View File
@@ -0,0 +1,217 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css } from "lit";
import { property, state } from "lit/decorators";
import { normalizeLovelaceConfig } from "../../../src/data/lovelace/config/normalize";
import type { LovelaceRawConfig } from "../../../src/data/lovelace/config/types";
import { isStrategyDashboard } from "../../../src/data/lovelace/config/types";
import type { LovelaceViewRawConfig } from "../../../src/data/lovelace/config/view";
import {
checkStrategyShouldRegenerate,
generateLovelaceDashboardStrategy,
} from "../../../src/panels/lovelace/strategies/get-strategy";
import type { Lovelace } from "../../../src/panels/lovelace/types";
import type { HomeAssistant } from "../../../src/types";
import { EmbedElement } from "./embed-element";
import type { ScriptObject } from "./script-data";
import { embedElementStyles } from "./theme-styles";
// The width below which the app is narrow, see home-assistant-main
const NARROW_WIDTH = 870;
/** The index of the view with this path or index, if there is one. */
const viewIndex = (
views: LovelaceViewRawConfig[],
view: string
): number | undefined => {
const byPath = views.findIndex((item) => item.path === view);
if (byPath !== -1) {
return byPath;
}
const index = Number(view);
return Number.isInteger(index) && index >= 0 && index < views.length
? index
: undefined;
};
/** Whether script data is a dashboard: its views, or a strategy for them. */
export const isDashboardConfig = (
data: ScriptObject
): data is LovelaceRawConfig => "views" in data || "strategy" in data;
/** The index of the view with this path or index, else the first view. */
export const findViewIndex = (
views: LovelaceViewRawConfig[],
view?: string
): number => (view === undefined ? 0 : (viewIndex(views, view) ?? 0));
/**
* The base of the embed elements that render a dashboard, or one of its
* views. It generates dashboard strategies, again when the registries they
* depend on change, and holds a dashboard that can't be edited or saved.
*/
export abstract class EmbedLovelaceElement<
Config extends ScriptObject,
> extends EmbedElement<Config> {
/** The path or index of the view to show. Without it, the first view. */
@property() public view?: string;
@state() protected _lovelace?: Lovelace;
// Narrow like the app at the same width, measured on the element because
// it rarely takes the width of the page
protected _narrow = new ResizeController(this, {
callback: (entries) =>
(entries[0]?.contentRect.width ?? NARROW_WIDTH) < NARROW_WIDTH,
});
// The configuration of the dashboard in `_lovelace`, or being generated
private _rawConfig?: LovelaceRawConfig;
// Counts the generations started, so that only the latest one is shown.
// Generating again for changed registries keeps the same configuration.
private _generation = 0;
// The backend at the last update, to tell what a strategy depends on changed
private _previousHass?: HomeAssistant;
/** The dashboard to render. The same object until it changes. */
protected abstract get rawConfig(): LovelaceRawConfig | undefined;
/**
* The view of the dashboard a path goes to, relative like `batteries` or
* with the dashboard like `/maintenance/batteries`, if it has that view.
*/
protected viewOfPath(path: string | undefined): string | undefined {
const views = this._lovelace?.config.views;
const view = path
? decodeURI(new URL(path, "http://embed/").pathname)
.split("/")
.filter(Boolean)
.pop()
: undefined;
return views && view !== undefined && viewIndex(views, view) !== undefined
? view
: undefined;
}
/** Whether two paths or indexes go to the same view of the dashboard. */
protected isSameView(a?: string, b?: string): boolean {
const views = this._lovelace?.config.views;
return views
? findViewIndex(views, a) === findViewIndex(views, b)
: a === b;
}
// A path to one of the dashboard's views shows that view. Other paths leave
// the dashboard where it is.
protected navigateTo(path: string) {
const view = this.viewOfPath(path);
if (view !== undefined) {
this.view = view;
}
}
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
const raw = this.rawConfig;
const oldHass = this._previousHass;
this._previousHass = this._hass;
// Without a configuration, nothing is shown, and a generation still
// running is dropped
if (!raw) {
if (this._rawConfig) {
this._rawConfig = undefined;
this._lovelace = undefined;
this._generation++;
}
return;
}
if (!this._hass) {
return;
}
// A strategy is only asked whether to generate again when the backend
// changed, as in ha-panel-lovelace, not when the generated dashboard
// arrives
if (
raw !== this._rawConfig ||
(oldHass &&
oldHass !== this._hass &&
isStrategyDashboard(raw) &&
this._hass.config.state === "RUNNING" &&
checkStrategyShouldRegenerate(
"dashboard",
raw.strategy,
oldHass,
this._hass
))
) {
this._rawConfig = raw;
this._generate(raw, this._hass, ++this._generation);
}
}
private async _generate(
raw: LovelaceRawConfig,
hass: HomeAssistant,
generation: number
) {
const generated = isStrategyDashboard(raw)
? await generateLovelaceDashboardStrategy(raw, hass)
: raw;
const config = normalizeLovelaceConfig(generated);
// A newer generation has started. A generated dashboard has its views,
// rather than a strategy.
if (generation !== this._generation || isStrategyDashboard(config)) {
return;
}
this._lovelace = {
config,
rawConfig: raw,
editMode: false,
urlPath: null,
mode: "generated",
locale: hass.locale,
enableFullEditMode: () => undefined,
saveConfig: async () => undefined,
deleteConfig: async () => undefined,
setEditMode: () => undefined,
showToast: () => undefined,
embedded: true,
};
}
// The background of a dashboard, and no insets for a notch or home bar
static styles: CSSResultGroup = [
embedElementStyles,
css`
:host {
display: block;
--app-safe-area-inset-top: 0px;
--app-safe-area-inset-bottom: 0px;
--app-safe-area-inset-left: 0px;
--app-safe-area-inset-right: 0px;
}
.theme {
position: relative;
background: var(--primary-background-color);
}
`,
];
}
+14
View File
@@ -15,3 +15,17 @@ export const readScriptData = (element: HTMLElement): unknown => {
? load(script.textContent!)
: JSON.parse(script.textContent!);
};
/** The data of a script child that holds an object, such as a config. */
export type ScriptObject = object;
/** The object in a script child, see `readScriptData`, if it holds one. */
export const readScriptObject = (
element: HTMLElement
): ScriptObject | undefined => {
const data = readScriptData(element);
return typeof data === "object" && data !== null && !Array.isArray(data)
? data
: undefined;
};
+35 -1
View File
@@ -1,4 +1,4 @@
import { unsafeCSS } from "lit";
import { css, unsafeCSS } from "lit";
import { hex2rgb } from "../../../src/common/color/convert-color";
import { darkColorVariables } from "../../../src/resources/theme/color";
import { darkSemanticVariables } from "../../../src/resources/theme/semantic.globals";
@@ -59,3 +59,37 @@ export const embedThemeStyles = unsafeCSS(
scopeThemeStyles(themeStyles, ".theme") +
`.theme.dark{${darkVariables.join("")}}`
);
/**
* The styles of every embed element: the theme, and the page styles of the
* app, for the `.theme` wrapper of `EmbedElement`.
*/
export const embedElementStyles = [
embedThemeStyles,
css`
:host([hidden]) {
display: none;
}
/* The text styles the app sets on html and body, so that the content does
not inherit the text styles of the page that embeds it. */
.theme {
color: var(--primary-text-color);
font-family: var(--ha-font-family-body);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-body);
line-height: normal;
letter-spacing: normal;
text-align: start;
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: var(--ha-font-smoothing);
}
/* What setDirectionStyles sets on the app for a right-to-left language,
here only for the content */
.theme[dir="rtl"] {
--direction: rtl;
--float-start: right;
--float-end: left;
--scale-direction: -1;
}
`,
];
+291 -9
View File
@@ -108,15 +108,24 @@
against a mocked backend in the browser. No Home Assistant server is
necessary.
</p>
<p>There are two components:</p>
<p>
<code>&lt;ha-embed-provider&gt;</code> holds the mocked backend and its
entities. These components render dashboard content with the backend of
a provider:
</p>
<ul>
<li><code>&lt;ha-embed-card&gt;</code> renders one card.</li>
<li>
<code>&lt;ha-embed-provider&gt;</code> holds the mocked backend and
its entities.
<code>&lt;ha-embed-section&gt;</code> renders one section, or a
section strategy.
</li>
<li>
<code>&lt;ha-embed-card&gt;</code> renders one card. It uses the
backend of a provider.
<code>&lt;ha-embed-view&gt;</code> renders one view, a view strategy,
or one view of a dashboard.
</li>
<li>
<code>&lt;ha-embed-dashboard&gt;</code> renders a dashboard, or a
dashboard strategy, with its tabs.
</li>
</ul>
<p>
@@ -324,6 +333,193 @@
</script><!-- htmlmin:ignore -->
</ha-embed-card>
</div>
<p>
The other components find their provider the same way, and take the
same <code>provider</code> attribute.
</p>
<h2>Sections</h2>
<p>
<code>&lt;ha-embed-section&gt;</code> renders a section the same way as
in a sections view. The configuration is the same as in a dashboard,
so headings span the top, and a <code>strategy</code> generates the
section. The section takes the width of the component.
</p>
<pre><code>&lt;ha-embed-section&gt;
&lt;script type="text/yaml"&gt;
cards:
- type: heading
heading: Kitchen
- type: tile
entity: light.kitchen
- type: tile
entity: switch.kettle
&lt;/script&gt;
&lt;/ha-embed-section&gt;</code></pre>
<ha-embed-provider>
<!-- htmlmin:ignore --><script type="text/yaml">
entities:
- entity_id: light.kitchen_ceiling
state: "on"
device_id: kitchen_ceiling
attributes:
friendly_name: Ceiling
supported_color_modes: [brightness]
color_mode: brightness
brightness: 200
- entity_id: switch.kettle
state: "off"
area_id: kitchen
attributes:
friendly_name: Kettle
device_class: outlet
- entity_id: sensor.kitchen_temperature
state: "21.4"
area_id: kitchen
attributes:
friendly_name: Kitchen temperature
device_class: temperature
state_class: measurement
unit_of_measurement: °C
devices:
- id: kitchen_ceiling
name: Kitchen ceiling light
manufacturer: Acme
model: Smart bulb
area_id: kitchen
</script><!-- htmlmin:ignore -->
<div class="single">
<ha-embed-section>
<!-- htmlmin:ignore --><script type="text/yaml">
cards:
- type: heading
heading: Kitchen
- type: tile
entity: light.kitchen_ceiling
tap_action:
action: toggle
- type: tile
entity: switch.kettle
tap_action:
action: toggle
- type: tile
entity: sensor.kitchen_temperature
grid_options:
columns: 12
</script><!-- htmlmin:ignore -->
</ha-embed-section>
</div>
</ha-embed-provider>
<h2>Views</h2>
<p>
<code>&lt;ha-embed-view&gt;</code> renders a view, with its badges and
layout. A <code>strategy</code> generates the view. Give the component
the width the view needs: masonry and sidebar views pick their columns
from the width of the page, not the component.
</p>
<pre><code>&lt;ha-embed-view&gt;
&lt;script type="text/yaml"&gt;
type: sections
badges:
- entity: sensor.outside_temperature
sections:
- cards: …
&lt;/script&gt;
&lt;/ha-embed-view&gt;</code></pre>
<p>
To show one view of a dashboard, or of a dashboard strategy, put the
dashboard in <code>dashboard</code> and the path or index of the view
in <code>view</code>. Without <code>view</code>, the first view shows.
</p>
<pre><code>&lt;ha-embed-view&gt;
&lt;script type="text/yaml"&gt;
dashboard:
strategy:
type: custom:my-dashboard
view: overview
&lt;/script&gt;
&lt;/ha-embed-view&gt;</code></pre>
<ha-embed-view provider="shared">
<!-- htmlmin:ignore --><script type="text/yaml">
type: sections
max_columns: 2
badges:
- entity: sensor.outside_temperature
sections:
- cards:
- type: heading
heading: Living room
- type: tile
entity: light.living_room
tap_action:
action: toggle
- type: tile
entity: switch.coffee_maker
tap_action:
action: toggle
- cards:
- type: heading
heading: Hallway
- type: thermostat
entity: climate.hallway
</script><!-- htmlmin:ignore -->
</ha-embed-view>
<h2>Dashboards</h2>
<p>
<code>&lt;ha-embed-dashboard&gt;</code> renders a dashboard with its
tabs. A <code>strategy</code> generates the dashboard. Each dashboard
keeps its own tab, so switching tabs does not change the address of
your page. The dashboard scrolls inside the component, under its
toolbar. The component is 600 pixels high; set its
<code>height</code> to change that. Set <code>view</code> to the path
or index of the first view to show.
</p>
<pre><code>&lt;ha-embed-dashboard style="height: 480px"&gt;
&lt;script type="text/yaml"&gt;
views:
- title: Home
path: home
cards: …
- title: Climate
path: climate
cards: …
&lt;/script&gt;
&lt;/ha-embed-dashboard&gt;</code></pre>
<ha-embed-dashboard provider="shared" style="height: 480px">
<!-- htmlmin:ignore --><script type="text/yaml">
views:
- title: Home
path: home
type: sections
max_columns: 2
sections:
- cards:
- type: heading
heading: Living room
- type: tile
entity: light.living_room
tap_action:
action: toggle
- type: tile
entity: switch.coffee_maker
tap_action:
action: toggle
- cards:
- type: heading
heading: Outside
- type: tile
entity: sensor.outside_temperature
- title: Climate
path: climate
type: sections
sections:
- cards:
- type: thermostat
entity: climate.hallway
</script><!-- htmlmin:ignore -->
</ha-embed-dashboard>
<h2>Light and dark mode</h2>
<p>
@@ -375,7 +571,7 @@ provider.entities = Object.values(provider.hass.states).map((entity) =&gt;
<code>provider.hass.states</code>.
</p>
<h2>Areas, calendars and energy</h2>
<h2>Areas, devices, calendars and energy</h2>
<p>
Some cards need data that is not an entity. To set it, put an object in
the script tag of the provider instead of the list. The object has the
@@ -385,7 +581,15 @@ provider.entities = Object.values(provider.hass.states).map((entity) =&gt;
<li>
<code>areas</code>: a list of areas, each with an
<code>area_id</code> and a <code>name</code>. Without it, each
<code>area_id</code> of the entities becomes an area.
<code>area_id</code> of the entities and devices becomes an area.
</li>
<li>
<code>devices</code>: a list of devices, each with an
<code>id</code> and a <code>name</code>, and optionally a
<code>manufacturer</code>, <code>model</code> and
<code>area_id</code>. Entities belong to a device with their
<code>device_id</code>, and cards and strategies then find the
device and its name.
</li>
<li>
<code>calendars</code>: the events of calendar entities, by entity
@@ -679,14 +883,92 @@ customElements.define("embed-demo-custom-card", EmbedDemoCustomCard);</code></pr
<td>Attribute and property</td>
<td>
The ID of the provider to use. Without it, the card uses the
provider around it, else the first provider on the page.
provider around it, else the first provider on the page. The other
components take it too.
</td>
</tr>
</table>
<h3><code>&lt;ha-embed-section&gt;</code></h3>
<table>
<tr>
<th>Name</th>
<th>Type</th>
<th>Description</th>
</tr>
<tr>
<td><code>config</code></td>
<td>Attribute and property</td>
<td>
The section configuration or section strategy, the same as in a
dashboard. A JSON or YAML script child sets the first
configuration.
</td>
</tr>
</table>
<h3><code>&lt;ha-embed-view&gt;</code></h3>
<table>
<tr>
<th>Name</th>
<th>Type</th>
<th>Description</th>
</tr>
<tr>
<td><code>config</code></td>
<td>Attribute and property</td>
<td>
The view configuration or view strategy, the same as in a
dashboard. A JSON or YAML script child sets the first
configuration.
</td>
</tr>
<tr>
<td><code>dashboard</code></td>
<td>Attribute and property</td>
<td>
A dashboard configuration or dashboard strategy, to show one of its
views instead of <code>config</code>. A script child with a
<code>dashboard</code> key, and optionally <code>view</code>, sets
it.
</td>
</tr>
<tr>
<td><code>view</code></td>
<td>Attribute and property</td>
<td>
The path or index of the view of <code>dashboard</code> to show.
Without it, the first view.
</td>
</tr>
</table>
<h3><code>&lt;ha-embed-dashboard&gt;</code></h3>
<table>
<tr>
<th>Name</th>
<th>Type</th>
<th>Description</th>
</tr>
<tr>
<td><code>config</code></td>
<td>Attribute and property</td>
<td>
The dashboard configuration or dashboard strategy. A JSON or YAML
script child sets the first configuration.
</td>
</tr>
<tr>
<td><code>view</code></td>
<td>Attribute and property</td>
<td>
The path or index of the first view to show. Without it, the first
view.
</td>
</tr>
</table>
<p>
The mocked backend knows the actions of lights, switches, climate,
covers, fans, locks, media players, and more. Actions that open dialogs,
such as <code>more-info</code>, do nothing.
such as <code>more-info</code>, do nothing. Dashboards can't be edited,
and their toolbar only shows their tabs.
</p>
</main>
<script>
+3 -90
View File
@@ -35,13 +35,12 @@
/>
<meta name="theme-color" content="#03a9f4" />
<%= renderTemplate("_social_meta.html.template") %>
<%= renderTemplate("../../../src/html/_style_base.html.template") %>
<style>
html {
background-color: var(--primary-background-color, #fafafa);
color: var(--primary-text-color, #212121);
/* Prevent iOS Safari from inflating text when rotating to landscape */
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
height: 100vh;
}
@media (prefers-color-scheme: dark) {
html {
@@ -49,94 +48,8 @@
color: var(--primary-text-color, #e1e1e1);
}
}
body {
font-family: Roboto, Noto, sans-serif;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
font-weight: 400;
height: 100vh;
margin: 0;
padding: 0;
}
#ha-launch-screen {
font-family: ui-sans-serif, system-ui, sans-serif;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
user-select: none;
-webkit-user-select: none;
transition: opacity var(--ha-animation-duration-normal, 250ms) ease-out;
}
#ha-launch-screen.removing {
opacity: 0;
}
@keyframes launch-lockup-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
#ha-launch-screen .ha-lockup {
display: flex;
align-items: center;
flex-shrink: 0;
animation: launch-lockup-in var(--ha-animation-duration-normal, 250ms) ease-out both;
}
#ha-launch-screen-info-box > *,
#ha-launch-screen .ohf-logo {
animation: launch-lockup-in var(--ha-animation-duration-normal, 250ms) ease-out both;
}
#ha-launch-screen .ha-logo {
width: 96px;
height: 96px;
}
#ha-launch-screen .ha-launch-screen-spacer-top {
flex: 1;
margin-top: calc( 2 * max(var(--safe-area-inset-top, 0px), 48px) + 46px );
padding-top: 48px;
}
#ha-launch-screen .ha-launch-screen-spacer-bottom {
flex: 1;
padding-top: 16px;
}
.ohf-logo {
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
opacity: .66;
}
.ohf-logo span {
font-size: 12px;
line-height: 12px;
text-transform: uppercase;
}
.ohf-logo picture {
display: flex;
}
.ohf-logo img {
width: 237px;
aspect-ratio: 237 / 24;
height: auto;
}
@media (max-height: 560px) {
#ha-launch-screen .ha-launch-screen-spacer-top {
margin-top: 24px;
padding-top: 24px;
}
#ha-launch-screen .ha-launch-screen-spacer-bottom {
padding-top: 16px;
}
.ohf-logo {
margin-block: 24px;
}
}
</style>
<%= renderTemplate("../../../src/html/_style_launch_screen.html.template") %>
</head>
<body>
<div id="ha-launch-screen">
@@ -44,14 +44,21 @@ export const lorawanFixtures: ConnectivityFixtures = {
],
commands: ["lorawan/"],
manifests: [
manifest("lorawan", "LoRaWAN", { integration_type: "helper" }),
manifest("lorawan", "LoRaWAN", {
config_flow: false,
integration_type: "service",
}),
manifest("sensecap_lorawan", "SenseCAP", {
integration_type: "device",
dependencies: ["lorawan"],
}),
manifest("chirpstack", "ChirpStack", { integration_type: "hub" }),
manifest("chirpstack", "ChirpStack", {
integration_type: "hub",
dependencies: ["lorawan"],
}),
manifest("thethingsnetwork_lorawan", "The Things Network", {
integration_type: "hub",
dependencies: ["lorawan"],
}),
],
configEntries: [
@@ -10,19 +10,26 @@ export const FRONIUS_ENTRY_ID = "mock-fronius";
export const RS485_PORT = "/dev/ttyUSB1";
export const modbusFixtures: ConnectivityFixtures = {
components: ["modbus"],
components: ["modbus", "flexit", "solaredge_modbus", "fronius"],
commands: ["modbus/"],
manifests: [
manifest("modbus", "Modbus", {
config_flow: false,
iot_class: "local_polling",
}),
manifest("flexit", "Flexit", {
integration_type: "device",
dependencies: ["modbus"],
iot_class: "local_polling",
}),
manifest("solaredge_modbus", "SolarEdge Modbus", {
integration_type: "device",
dependencies: ["modbus"],
iot_class: "local_polling",
}),
manifest("fronius", "Fronius", {
integration_type: "device",
dependencies: ["modbus"],
iot_class: "local_polling",
}),
],
+54
View File
@@ -1,6 +1,60 @@
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
/** The fields of a device that the demo devices leave at their default. */
export const baseDevice: Omit<
DeviceRegistryEntry,
| "id"
| "name"
| "manufacturer"
| "model"
| "config_entries"
| "primary_config_entry"
| "entry_type"
> = {
config_entries_subentries: {},
connections: [],
identifiers: [],
model_id: null,
labels: [],
sw_version: null,
hw_version: null,
serial_number: null,
via_device_id: null,
area_id: null,
name_by_user: null,
disabled_by: null,
configuration_url: null,
parent_device_id: null,
created_at: 0,
modified_at: 0,
};
export interface DemoDevice {
id: string;
name: string;
manufacturer?: string;
model?: string;
area_id?: string;
via_device_id?: string;
entry_type?: "service";
}
export const toDeviceRegistryEntry = (
device: DemoDevice
): DeviceRegistryEntry => ({
...baseDevice,
id: device.id,
name: device.name,
manufacturer: device.manufacturer ?? null,
model: device.model ?? null,
area_id: device.area_id ?? null,
via_device_id: device.via_device_id ?? null,
entry_type: device.entry_type ?? null,
config_entries: [],
primary_config_entry: null,
});
export const mockDeviceRegistry = (
hass: MockHomeAssistant,
data: DeviceRegistryEntry[] = []
+1 -19
View File
@@ -1,24 +1,6 @@
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
import { connectivityDevices } from "./connectivity/fixtures";
const baseDevice = {
config_entries_subentries: {},
connections: [] as [string, string][],
identifiers: [] as [string, string][],
model_id: null,
labels: [] as string[],
sw_version: null,
hw_version: null,
serial_number: null,
via_device_id: null,
area_id: null,
name_by_user: null,
disabled_by: null,
configuration_url: null,
parent_device_id: null,
created_at: 0,
modified_at: 0,
};
import { baseDevice } from "./device_registry";
export const demoDevices: DeviceRegistryEntry[] = [
{
+9
View File
@@ -97,6 +97,14 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
stat_consumption: "sensor.water_garden",
},
],
device_consumption_gas: [
{
stat_consumption: "sensor.gas_kitchen",
},
{
stat_consumption: "sensor.gas_heat_pump",
},
],
}));
hass.mockWS("energy/info", (): EnergyInfo => ({
cost_sensors: {},
@@ -106,6 +114,7 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
energy_sources: Array.from({ length: 6 }, () => []),
device_consumption: Array.from({ length: 6 }, () => []),
device_consumption_water: Array.from({ length: 2 }, () => []),
device_consumption_gas: Array.from({ length: 2 }, () => []),
}));
hass.mockWS(
"energy/fossil_energy_consumption",
+20
View File
@@ -160,6 +160,26 @@ export const energyEntities = () =>
unit_of_measurement: "L",
},
},
"sensor.gas_kitchen": {
entity_id: "sensor.gas_kitchen",
state: "100",
attributes: {
friendly_name: "Gas kitchen",
unit_of_measurement: "m³",
device_class: "gas",
state_class: "total_increasing",
},
},
"sensor.gas_heat_pump": {
entity_id: "sensor.gas_heat_pump",
state: "500",
attributes: {
friendly_name: "Gas heat pump",
unit_of_measurement: "m³",
device_class: "gas",
state_class: "total_increasing",
},
},
"sensor.energy_car": {
entity_id: "sensor.energy_car",
state: "4",
+18
View File
@@ -158,6 +158,24 @@ const statisticsFunctions: Record<
(bucketStart, bucketEnd) =>
(20 * (bucketEnd.getTime() - bucketStart.getTime())) / 3600000
),
"sensor.gas_kitchen": (_id, start, end, period = "hour") =>
generateSumStatistics(
start,
end,
period,
100,
(bucketStart, bucketEnd) =>
(0.1 * (bucketEnd.getTime() - bucketStart.getTime())) / 3600000
),
"sensor.gas_heat_pump": (_id, start, end, period = "hour") =>
generateSumStatistics(
start,
end,
period,
500,
(bucketStart, bucketEnd) =>
(0.6 * (bucketEnd.getTime() - bucketStart.getTime())) / 3600000
),
"sensor.energy_consumption_tarif_1": (
_id: string,
start: Date,
+8
View File
@@ -301,6 +301,14 @@ export default tseslint.config(
globals: globals.node,
},
},
{
// Published as native ESM, where package subpath imports such as
// "lit/decorators.js" need their extension.
files: ["packages/*/src/**/*.ts"],
rules: {
"import-x/extensions": ["error", "ignorePackages", { ts: "never" }],
},
},
{
plugins: {
html,
+4
View File
@@ -81,6 +81,10 @@ export default [
header: "Feedback and status",
pages: ["ha-alert", "ha-spinner", "ha-tip", "ha-bar", "ha-gauge"],
},
{
header: "Charts",
pages: ["state-history-charts", "statistics-chart"],
},
{
header: "Labels and text",
pages: ["ha-badge", "ha-label-badge", "ha-chips", "ha-marquee-text"],
+2 -2
View File
@@ -4,7 +4,7 @@ title: "Our story"
## Open source home automation that puts local control and privacy first
Home Assistant is a free and open-source software for home automation that is designed to be the central control system for smart home devices with a focus on local control and privacy. It can be accessed via a web-based user interface, via apps for Android and iOS, or using voice commands via a supported virtual assistant like Google Assistant and Amazon Alexa.
Home Assistant is a free and open-source software for home automation that is designed to be the central control system for smart home devices with a focus on local control and privacy. It can be accessed via a web-based user interface, via apps for Android and iOS, or using voice commands via a supported virtual assistant like Google Home and Amazon Alexa.
IoT devices and services are supported by modular support for controlling proprietary ecosystems if they provide public access via an Open API for third-party integrations and protocols like Bluetooth, MQTT, Zigbee, and Z-Wave, After the Home Assistant software application is installed as a computer appliance it will act as a central control system for home automation. Information from all entities it sees can be used and controlled from within scripts trigger automations using scheduling and "blueprint" subroutines, e.g. for controlling lighting, climate, entertainment systems, and appliances.
@@ -36,6 +36,6 @@ Durability for the Open Home means that devices are designed and built to keep w
The project was started as a Python application by Paulus Schoutsen in September 2013 and first published publicly on GitHub in November 2013. In July 2017, a managed operating system called Hass.io was initially introduced to make it easier use to use Home Assistant on single-board computers like the Raspberry Pi series. Its bundled "supervisor" management system allowed users to manage, backup, and update the local installation and introduced the option to extend the functionality of the software with add-ons.
An optional subscription service was introduced in December 2017 for $5/month to solve the complexities associated with secured remote access, as well as linking to Amazon Alexa and Google Assistant. Nabu Casa, Inc. was formed in September 2018 to take over the subscription service. The company's funding is based solely on revenue from the subscription service. It is used to finance the project's infrastructure and to pay for full-time employees contributing to the project.
An optional subscription service was introduced in December 2017 for $5/month to solve the complexities associated with secured remote access, as well as linking to Amazon Alexa and Google Home. Nabu Casa, Inc. was formed in September 2018 to take over the subscription service. The company's funding is based solely on revenue from the subscription service. It is used to finance the project's infrastructure and to pay for full-time employees contributing to the project.
In January 2020, branding was adjusted to make it easier to refer to different parts of the project. The main piece of software was renamed to Home Assistant Core, while the full suite of software with the embedded operating system and bundled "supervisor" management system was renamed to Home Assistant.
+7 -2
View File
@@ -683,8 +683,13 @@ const SCHEMAS: {
name: "Unit of measurement",
input: {
sensor_device_class: {
name: "Device class",
selector: { device_class: { domain: "sensor" } },
name: "Device class of the unit below",
selector: {
device_class: {
domain: "sensor",
context: { filter_unit_of_measurement: "unit_linked" },
},
},
},
sensor_state_class: {
name: "State class",
@@ -0,0 +1,6 @@
---
title: History charts
subtitle: State history as timelines and line charts.
---
`<state-history-charts>` renders the state history of entities: a timeline for entities with text states, and a line chart per unit for numeric ones. It is used by the history panel, the history graph card and the more-info history. The data below is generated, so the charts work without a backend.
@@ -0,0 +1,211 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../../src/components/chart/state-history-charts";
import "../../../../src/components/ha-card";
import type {
HistoryResult,
LineChartState,
TimelineState,
} from "../../../../src/data/history";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
const HOUR = 60 * 60 * 1000;
const HOURS = 48;
const END = new Date();
END.setMinutes(0, 0, 0);
const START = new Date(END.getTime() - HOURS * HOUR);
const ENTITIES = [
{
entity_id: "sensor.outdoor_temperature",
state: "12.4",
attributes: {
friendly_name: "Outdoor temperature",
device_class: "temperature",
state_class: "measurement",
unit_of_measurement: "°C",
},
},
{
entity_id: "light.living_room",
state: "on",
attributes: { friendly_name: "Living room light" },
},
{
entity_id: "binary_sensor.front_door",
state: "off",
attributes: { friendly_name: "Front door", device_class: "door" },
},
];
const temperatureStates = (): LineChartState[] =>
Array.from({ length: HOURS * 2 }, (_, index) => {
const time = START.getTime() + (index * HOUR) / 2;
const hourOfDay = new Date(time).getHours();
const value =
10 +
4 * Math.sin(((hourOfDay - 9) / 24) * 2 * Math.PI) +
0.5 * Math.sin(time / HOUR);
return {
state: value.toFixed(1),
last_changed: time,
};
});
const toggleStates = (
onHours: [number, number][],
onLabel: string,
offLabel: string
): TimelineState[] => {
const on = { state: "on", state_localize: onLabel };
const off = { state: "off", state_localize: offLabel };
const states: TimelineState[] = [{ ...off, last_changed: START.getTime() }];
onHours.forEach(([from, to]) => {
states.push(
{ ...on, last_changed: START.getTime() + from * HOUR },
{ ...off, last_changed: START.getTime() + to * HOUR }
);
});
return states;
};
const HISTORY: HistoryResult = {
line: [
{
unit: "°C",
device_class: "temperature",
identifier: "temperature",
data: [
{
domain: "sensor",
name: "Outdoor temperature",
entity_id: "sensor.outdoor_temperature",
states: temperatureStates(),
},
],
},
],
timeline: [
{
name: "Living room light",
entity_id: "light.living_room",
data: toggleStates(
[
[6, 8],
[18, 23],
[30, 32],
[42, 47],
],
"On",
"Off"
),
},
{
name: "Front door",
entity_id: "binary_sensor.front_door",
data: toggleStates(
[
[7.5, 7.6],
[17, 17.2],
[31.5, 31.6],
[41, 41.1],
],
"Open",
"Closed"
),
},
],
};
const LIGHT_ONLY: HistoryResult = {
line: [],
timeline: [HISTORY.timeline[0]],
};
interface Example {
title: string;
description: string;
history: HistoryResult;
showNames?: boolean;
insideLabels?: boolean;
syncCharts?: boolean;
}
const EXAMPLES: Example[] = [
{
title: "Names in a column",
description:
"The default layout, like the history graph card: timeline names on the left and a line chart for each unit.",
history: HISTORY,
},
{
title: "Names inside the timeline, synced zoom",
description:
"Like the history panel: names sit under each timeline bar, and zooming one chart zooms all of them.",
history: HISTORY,
insideLabels: true,
syncCharts: true,
},
{
title: "Single entity without names",
description:
"Like the more-info dialog, where the entity is already known.",
history: LIGHT_ONLY,
showNames: false,
},
];
@customElement("demo-components-state-history-charts")
export class DemoStateHistoryCharts extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
const hass = provideHass(this);
hass.updateTranslations(null, "en");
hass.addEntities(ENTITIES);
}
protected render() {
if (!this.hass) {
return nothing;
}
return EXAMPLES.map(
(example) => html`
<ha-card header=${example.title}>
<div class="card-content">
<p>${example.description}</p>
<state-history-charts
.historyData=${example.history}
.startTime=${START}
.endTime=${END}
.showNames=${example.showNames ?? true}
.insideLabels=${example.insideLabels ?? false}
.syncCharts=${example.syncCharts ?? false}
.clickForMoreInfo=${false}
></state-history-charts>
</div>
</ha-card>
`
);
}
static styles = css`
ha-card {
max-width: 800px;
margin: 24px auto;
}
p {
margin-top: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-state-history-charts": DemoStateHistoryCharts;
}
}
@@ -0,0 +1,6 @@
---
title: Statistics chart
subtitle: Long-term statistics as a bar or line chart.
---
`<statistics-chart>` renders long-term statistics from the recorder. It is used by the statistics graph card, the more-info history and the statistics developer tools. The data below is generated, so the charts work without a backend.
@@ -0,0 +1,212 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../../src/components/chart/statistics-chart";
import "../../../../src/components/ha-card";
import type {
Statistics,
StatisticsMetaData,
StatisticType,
StatisticValue,
} from "../../../../src/data/recorder";
import { StatisticMeanType } from "../../../../src/data/recorder";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
const HOUR = 60 * 60 * 1000;
const HOURS = 48;
const END = new Date();
END.setMinutes(0, 0, 0);
const START = new Date(END.getTime() - HOURS * HOUR);
const round = (value: number, digits: number) =>
Math.round(value * 10 ** digits) / 10 ** digits;
const hourlyValues = (
compute: (start: number) => Partial<StatisticValue>
): StatisticValue[] =>
Array.from({ length: HOURS }, (_, hour) => {
const start = START.getTime() + hour * HOUR;
return { start, end: start + HOUR, ...compute(start) };
});
const energyValues = (dayUse: number, eveningUse: number) => {
let sum = 0;
return hourlyValues((start) => {
const hourOfDay = new Date(start).getHours();
const base = hourOfDay < 7 ? 0.1 : hourOfDay < 18 ? dayUse : eveningUse;
const change = round(base + 0.2 * Math.abs(Math.sin(start / HOUR)), 2);
sum += change;
return { change, sum: round(sum, 2) };
});
};
const temperatureValues = (average: number, swing: number) =>
hourlyValues((start) => {
const hourOfDay = new Date(start).getHours();
const mean =
average +
swing * Math.sin(((hourOfDay - 9) / 24) * 2 * Math.PI) +
0.3 * Math.sin(start / HOUR);
return {
mean: round(mean, 1),
min: round(mean - 0.4, 1),
max: round(mean + 0.4, 1),
};
});
const HEAT_PUMP_ID = "sensor.heat_pump_energy";
const LIGHTING_ID = "sensor.lighting_energy";
const LIVING_ROOM_ID = "sensor.living_room_temperature";
const BEDROOM_ID = "sensor.bedroom_temperature";
const HEAT_PUMP = energyValues(0.4, 1.2);
const LIGHTING = energyValues(0.05, 0.3);
const LIVING_ROOM = temperatureValues(21, 1.5);
const BEDROOM = temperatureValues(18.5, 1);
const energyMetadata = (id: string, name: string): StatisticsMetaData => ({
statistic_id: id,
statistics_unit_of_measurement: "kWh",
source: "recorder",
name,
has_sum: true,
mean_type: StatisticMeanType.NONE,
unit_class: "energy",
});
const temperatureMetadata = (id: string, name: string): StatisticsMetaData => ({
statistic_id: id,
statistics_unit_of_measurement: "°C",
source: "recorder",
name,
has_sum: false,
mean_type: StatisticMeanType.ARITHMETIC,
unit_class: "temperature",
});
const METADATA: Record<string, StatisticsMetaData> = {
[HEAT_PUMP_ID]: energyMetadata(HEAT_PUMP_ID, "Heat pump"),
[LIGHTING_ID]: energyMetadata(LIGHTING_ID, "Lighting"),
[LIVING_ROOM_ID]: temperatureMetadata(LIVING_ROOM_ID, "Living room"),
[BEDROOM_ID]: temperatureMetadata(BEDROOM_ID, "Bedroom"),
};
const NAMES = Object.fromEntries(
Object.values(METADATA).map((meta) => [meta.statistic_id, meta.name!])
);
interface Example {
title: string;
description: string;
statistics: Statistics;
statTypes: StatisticType[];
chartType: "line" | "line-stack" | "bar" | "bar-stack";
unit: string;
hideLegend?: boolean;
height?: string;
}
const EXAMPLES: Example[] = [
{
title: "Sum as bars",
description:
"The change of a sum for each hour, like an energy statistics graph card.",
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP },
statTypes: ["change"],
chartType: "bar",
unit: "kWh",
},
{
title: "Stacked bars",
description: "Several sums stacked, to compare what each one adds.",
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP, [LIGHTING_ID]: LIGHTING },
statTypes: ["change"],
chartType: "bar-stack",
unit: "kWh",
},
{
title: "Mean, min and max",
description: "A measurement with its hourly mean, min and max.",
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM },
statTypes: ["mean", "min", "max"],
chartType: "line",
unit: "°C",
},
{
title: "Several statistics",
description: "The mean of several measurements with the same unit.",
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM, [BEDROOM_ID]: BEDROOM },
statTypes: ["mean"],
chartType: "line",
unit: "°C",
},
{
title: "Without legend",
description:
"A single statistic with a fixed height, like in the more-info dialog.",
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM },
statTypes: ["mean", "min", "max"],
chartType: "line",
unit: "°C",
hideLegend: true,
height: "240px",
},
];
@customElement("demo-components-statistics-chart")
export class DemoStatisticsChart extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
const hass = provideHass(this);
hass.updateTranslations(null, "en");
}
protected render() {
if (!this.hass) {
return nothing;
}
return EXAMPLES.map(
(example) => html`
<ha-card header=${example.title}>
<div class="card-content">
<p>${example.description}</p>
<statistics-chart
.statisticsData=${example.statistics}
.metadata=${METADATA}
.names=${NAMES}
.statTypes=${example.statTypes}
.chartType=${example.chartType}
period="hour"
.unit=${example.unit}
.startTime=${START}
.endTime=${END}
.hideLegend=${example.hideLegend ?? false}
.height=${example.height}
.clickForMoreInfo=${false}
></statistics-chart>
</div>
</ha-card>
`
);
}
static styles = css`
ha-card {
max-width: 800px;
margin: 24px auto;
}
p {
margin-top: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-statistics-chart": DemoStatisticsChart;
}
}
+1 -1
View File
@@ -121,7 +121,7 @@ const brandEntries: ConfigEntryExtended[] = (
["tuya", "Tuya", "setup_error"],
["unifi", "UniFi Network", "not_loaded"],
["met", "Meteorologisk institutt (Met.no)", "setup_error"],
["google_assistant", "Google Assistant", "not_loaded"],
["google_assistant", "Google Home", "not_loaded"],
["homekit_controller", "HomeKit Device", "setup_retry"],
] as [string, string, ConfigEntryExtended["state"]][]
).map(([domain, name, entryState]) =>
@@ -3,6 +3,7 @@
<head>
<title>Home Assistant</title>
<%= renderTemplate("../../../src/html/_header.html.template") %>
<%= renderTemplate("../../../src/html/_meta_viewport.html.template") %>
<%= renderTemplate("../../../src/html/_style_base.html.template") %>
<style>
html {
+29 -29
View File
@@ -13,7 +13,7 @@
"lint:prettier": "prettier . --cache --check",
"format:prettier": "prettier . --cache --write",
"lint:types": "node ./node_modules/@typescript/native/bin/tsc && node ./node_modules/@typescript/native/bin/tsc -p .github/scripts",
"lint:lit": "lit-analyzer \"{.,*}/src/**/*.ts\"",
"lint:lit": "lit-analyzer \"{.,*,packages/*}/src/**/*.ts\"",
"lint:element-imports": "eslint \"**/src/**/*.{js,ts,html}\" --ignore-pattern=.gitignore --rule '{\"ha/no-unused-element-import\": \"warn\"}'",
"lint:licenses": "node --no-deprecation script/check-licenses",
"lint": "pnpm run lint:eslint && pnpm run lint:prettier && pnpm run lint:types && pnpm run lint:lit",
@@ -40,7 +40,7 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "8.0.10",
"@babel/runtime": "8.1.0",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.4",
"@codemirror/commands": "6.11.1",
@@ -54,14 +54,14 @@
"@codemirror/view": "6.43.15",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "8.0.1",
"@formatjs/intl-datetimeformat": "8.0.2",
"@formatjs/intl-displaynames": "8.0.1",
"@formatjs/intl-durationformat": "0.12.2",
"@formatjs/intl-durationformat": "0.12.3",
"@formatjs/intl-getcanonicallocales": "3.3.0",
"@formatjs/intl-listformat": "9.0.1",
"@formatjs/intl-locale": "5.4.2",
"@formatjs/intl-numberformat": "10.0.1",
"@formatjs/intl-pluralrules": "7.0.1",
"@formatjs/intl-numberformat": "10.0.2",
"@formatjs/intl-pluralrules": "7.0.2",
"@formatjs/intl-relativetimeformat": "13.0.1",
"@home-assistant/webawesome": "3.14.0-ha.2",
"@lezer/common": "1.5.5",
@@ -73,16 +73,15 @@
"@lit/reactive-element": "2.1.2",
"@lit/task": "1.0.3",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@material/mwc-list": "0.27.0",
"@mdi/js": "7.4.47",
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.23",
"@tsparticles/engine": "4.5.0",
"@tsparticles/preset-links": "4.5.0",
"@versatiles/style": "7.1.0",
"@swc/helpers": "0.5.24",
"@tsparticles/engine": "4.5.3",
"@tsparticles/preset-links": "4.5.3",
"@versatiles/style": "8.1.1",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.202.0",
"@vvo/tzdb": "6.203.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"barcode-detector": "3.2.3",
"cally": "0.9.2",
@@ -98,7 +97,7 @@
"echarts": "6.1.0",
"echarts-extension-chart2music": "0.1.1",
"element-internals-polyfill": "3.0.2",
"fullcalendar": "7.1.3",
"fullcalendar": "7.1.4",
"fuse.js": "7.5.0",
"hls.js": "1.7.4",
"home-assistant-js-websocket": "9.8.1",
@@ -110,7 +109,7 @@
"lit": "3.3.3",
"lit-html": "3.3.3",
"maplibre-gl": "6.16.0",
"marked": "18.1.2",
"marked": "18.1.3",
"memoize-one": "6.0.0",
"nanoid": "6.0.2",
"node-vibrant": "4.0.4",
@@ -123,7 +122,7 @@
"sortablejs": "1.15.7",
"stacktrace-js": "2.0.2",
"superstruct": "2.0.2",
"temporal-polyfill": "1.0.6",
"temporal-polyfill": "1.0.8",
"tinykeys": "4.0.1",
"weekstart": "2.0.0",
"workbox-cacheable-response": "7.4.1",
@@ -137,23 +136,24 @@
},
"devDependencies": {
"@ampproject/remapping": "2.3.0",
"@babel/core": "8.0.10",
"@babel/core": "8.1.0",
"@babel/helper-compilation-targets": "8.0.7",
"@babel/helper-define-polyfill-provider": "1.0.0",
"@babel/plugin-transform-class-properties": "8.0.1",
"@babel/plugin-transform-private-methods": "8.0.1",
"@babel/plugin-transform-runtime": "8.0.6",
"@babel/preset-env": "8.0.7",
"@bundle-stats/plugin-webpack-filter": "4.22.3",
"@bundle-stats/plugin-webpack-filter": "4.23.1",
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.67.0",
"@home-assistant/components": "workspace:*",
"@html-eslint/eslint-plugin": "0.68.0",
"@lokalise/node-api": "17.0.1",
"@octokit/auth-oauth-device": "8.0.5",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.64.1",
"@playwright/test": "1.65.0",
"@rsdoctor/rspack-plugin": "1.6.5",
"@rspack/core": "2.3.0",
"@rspack/core": "2.3.1",
"@rspack/dev-server": "2.2.2",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "7.0.1",
@@ -170,7 +170,7 @@
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@typescript/native": "npm:[email protected]",
"@vitest/coverage-v8": "5.0.5",
"@vitest/coverage-v8": "5.1.0",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist": "4.29.5",
@@ -180,7 +180,7 @@
"eslint": "10.14.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
"eslint-plugin-import-x": "4.18.0",
"eslint-plugin-lit": "2.3.1",
"eslint-plugin-lit-a11y": "5.1.3",
"eslint-plugin-unused-imports": "4.4.1",
@@ -188,14 +188,14 @@
"fancy-log": "2.0.0",
"fs-extra": "11.4.1",
"generate-license-file": "4.2.5",
"glob": "14.0.3",
"globals": "17.13.0",
"glob": "14.0.4",
"globals": "17.14.0",
"gulp": "5.0.1",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "30.1.3",
"jsdom": "31.0.0",
"jszip": "3.10.2",
"license-checker-rseidelsohn": "5.0.1",
"lightningcss": "1.33.0",
@@ -208,21 +208,21 @@
"pinst": "3.0.0",
"prettier": "3.9.10",
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"serve": "14.2.7",
"sinon": "22.1.0",
"tar": "7.5.22",
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.72.1",
"vite": "8.3.5",
"vite": "8.3.6",
"vite-tsconfig-paths": "6.1.1",
"vitest": "5.0.5",
"vitest": "5.1.0",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "7.4.1"
},
"packageManager": "[email protected]6.1",
"packageManager": "[email protected]8.0",
"volta": {
"node": "24.22.2"
}
+156
View File
@@ -0,0 +1,156 @@
# @home-assistant/components
Home Assistant Components (`hac-*`): framework-agnostic web components built
with [Lit](https://lit.dev).
> Work in progress, not published yet.
## Registration
Every element is imported on its own, in one of two ways:
| Import | Registers in | For |
| --------------------------------------------- | -------------------- | ----------------------------------------------------------- |
| `@home-assistant/components/<element>` | Global registry | Home Assistant itself |
| `@home-assistant/components/<element>/scoped` | Your scoped registry | Custom cards and other code that runs inside Home Assistant |
`<element>` is the tag name without the `hac-` prefix, like `card` for
`hac-card`.
Both use the copy bundled with your code. A scoped copy is isolated from the
one Home Assistant registers globally, so a custom card keeps the version it
was built with.
Global registration skips tag names that are already registered, so the first
registration wins and nothing throws.
### Global
```ts
import "@home-assistant/components/card";
```
### Scoped
Pass the component's elements to `ScopedElementsMixin` for Lit, or to
`createScopedElementRegistry` for plain custom elements. Forgetting the
elements is a TypeScript error, and a runtime error in plain JavaScript.
Lit:
```ts
import { LitElement, html } from "lit";
import { ScopedElementsMixin } from "@home-assistant/components/scoped";
import { hacCard } from "@home-assistant/components/card/scoped";
class MyCard extends ScopedElementsMixin(LitElement, [hacCard]) {
render() {
return html`<hac-card>…</hac-card>`;
}
}
```
Plain custom elements:
```js
import {
attachScopedShadow,
createScopedElement,
createScopedElementRegistry,
} from "@home-assistant/components/scoped";
import { hacCard } from "@home-assistant/components/card/scoped";
// Create the registry once, outside the class: all instances of the card share it
const registry = createScopedElementRegistry([hacCard]);
class MyCard extends HTMLElement {
constructor() {
super();
const root = attachScopedShadow(this, { mode: "open" }, registry);
root.innerHTML = "<hac-card>…</hac-card>";
// or: root.append(createScopedElement(root, "hac-card"));
}
}
```
`innerHTML`, `insertAdjacentHTML` and `createScopedElement` use the scoped
registry. `document.createElement` uses the global one, so avoid it for
scoped elements.
Scoped registries need native support (Chrome 146+, Safari 26) or
`@webcomponents/scoped-custom-element-registry`, which Home Assistant loads.
Without either, elements are registered globally instead, and an already
registered tag name keeps its existing class.
Only elements rendered inside the shadow root use the scoped registry. Light
DOM content, such as slotted elements, uses the registry of the tree it is
written in.
`lit` is a peer dependency.
### TypeScript
Each element's entry points declare its tag in `HTMLElementTagNameMap`, so
`document.querySelector("hac-card")` and templates are typed once the element
is imported. For the class itself, import its type from the `scoped` entry
point; `import type` loads nothing at runtime:
```ts
import type { HacCard } from "@home-assistant/components/card/scoped";
```
The package has no root entry point: each element and the scoped helpers
(`@home-assistant/components/scoped`) have their own.
## Theming
Each component exposes its own `--hac-*` custom properties, documented in its
source, which take precedence. Next come the theme variables of the existing
Home Assistant element it replaces, so current themes keep working: `hac-card`
reads the variables of `ha-card` (`--ha-card-background`,
`--card-background-color`, `--ha-card-border-radius`, …). Last come the
semantic color tokens (`--ha-color-*`, like `--ha-color-surface-default`) and
their light values, so the components also render outside Home Assistant.
The legacy theme variables are a temporary bridge for this experiment, not the
target design. Home Assistant always defines some of them, like
`--card-background-color`, so inside Home Assistant they currently win over
the semantic tokens. Once themes move to the semantic tokens, the legacy
variables will be removed from the fallbacks.
## Structure
Each element has its own directory, named like its import path:
```
src/components/card/ <hac-card> -> @home-assistant/components/card
hac-card.ts class, does not register itself
info.ts tag name and version
global.ts registers globally
scoped.ts element for a scoped registry -> …/card/scoped
```
Shared code lives in `src/utils/`: `registration/` for defining elements
(global and scoped) and `element/` for helpers that components use
themselves, like slot checks.
Every element has its own version in `info.ts`.
Entry points are mapped with subpath patterns in `package.json`, so a new
element directory needs no `package.json` change. Only `global.ts` files have
side effects, which `sideEffects` relies on for tree shaking.
## Development
```bash
pnpm --filter @home-assistant/components build # ESM, type declarations and custom-elements.json to dist/
pnpm --filter @home-assistant/components dev # rebuild on change
```
Inside this repository the app uses the sources directly through
the `ha-source` export condition, so no build is needed during development.
The package is built with [Rslib](https://rslib.rs) in bundleless mode: every
source file becomes its own ES module. The build then generates a
[custom elements manifest](https://custom-elements-manifest.open-wc.org) from
the JSDoc of each element, so document new slots, parts, CSS properties and
events there.
@@ -0,0 +1,84 @@
// Generates dist/custom-elements.json from the JSDoc of the hac elements, for
// editors and documentation tools. Only hac-*.ts files declare elements; the
// entry points just re-export them.
const forEachDeclaration = (customElementsManifest, callback) => {
for (const module of customElementsManifest.modules) {
for (const declaration of module.declarations ?? []) {
callback(declaration);
}
}
};
// The analyzer leaves the readme empty. Paths in the manifest are relative to
// the package root, like the module paths.
const packageReadme = () => ({
name: "package-readme",
packageLinkPhase({ customElementsManifest }) {
customElementsManifest.readme = "README.md";
},
});
// Elements are documented with @summary, which the analyzer only puts into
// `summary`. Most tools show `description`, so fall back to the summary.
const summaryAsDescription = () => ({
name: "summary-as-description",
packageLinkPhase({ customElementsManifest }) {
forEachDeclaration(customElementsManifest, (declaration) => {
if (!declaration.description && declaration.summary) {
declaration.description = declaration.summary;
}
});
},
});
// Private and protected members are internal, so keep them out of the
// documented API.
const publicApiOnly = () => ({
name: "public-api-only",
packageLinkPhase({ customElementsManifest }) {
forEachDeclaration(customElementsManifest, (declaration) => {
declaration.members = declaration.members?.filter(
(member) => !member.privacy || member.privacy === "public"
);
});
},
});
const distPaths = () => {
const toDist = (value) => value.replace(/^src\/(.+)\.ts$/, "dist/$1.js");
const rewrite = (node) => {
if (Array.isArray(node)) {
node.forEach(rewrite);
return;
}
if (!node || typeof node !== "object") {
return;
}
for (const [key, value] of Object.entries(node)) {
if ((key === "path" || key === "module") && typeof value === "string") {
node[key] = toDist(value);
} else {
rewrite(value);
}
}
};
return {
name: "dist-paths",
packageLinkPhase({ customElementsManifest }) {
rewrite(customElementsManifest.modules);
},
};
};
export default {
globs: ["src/components/**/hac-*.ts"],
outdir: "dist",
litelement: true,
plugins: [
packageReadme(),
summaryAsDescription(),
publicApiOnly(),
distPaths(),
],
};
+60
View File
@@ -0,0 +1,60 @@
{
"name": "@home-assistant/components",
"version": "0.0.0",
"description": "Home Assistant web components (hac-* elements) built with Lit",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "git+https://github.com/home-assistant/frontend.git",
"directory": "packages/components"
},
"type": "module",
"sideEffects": [
"./dist/components/**/global.js",
"./src/components/**/global.ts"
],
"customElements": "dist/custom-elements.json",
"exports": {
"./scoped": {
"ha-source": "./src/scoped.ts",
"types": "./dist/scoped.d.ts",
"import": "./dist/scoped.js"
},
"./package.json": "./package.json",
"./*/scoped": {
"ha-source": "./src/components/*/scoped.ts",
"types": "./dist/components/*/scoped.d.ts",
"import": "./dist/components/*/scoped.js"
},
"./*/info": {
"ha-source": "./src/components/*/info.ts",
"types": "./dist/components/*/info.d.ts",
"import": "./dist/components/*/info.js"
},
"./*": {
"ha-source": "./src/components/*/global.ts",
"types": "./dist/components/*/global.d.ts",
"import": "./dist/components/*/global.js"
}
},
"files": [
"dist"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "rslib build && cem analyze",
"dev": "rslib build --watch",
"prepublishOnly": "pnpm run build"
},
"peerDependencies": {
"lit": "^3.3.0"
},
"devDependencies": {
"@custom-elements-manifest/analyzer": "0.11.0",
"@rslib/core": "1.0.6",
"lit": "3.3.3",
"typescript": "6.0.3"
}
}
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig } from "@rslib/core";
// Bundleless build: every source file becomes its own ESM file, so consumers'
// bundlers only include the entry points they import. Only global.ts files
// register elements; everything else is free of side effects.
export default defineConfig({
source: {
entry: { index: ["./src/**/*.ts"] },
decorators: { version: "legacy" },
tsconfigPath: "./tsconfig.json",
},
tools: {
swc: { jsc: { transform: { decoratorMetadata: false } } },
},
lib: [
{
format: "esm",
bundle: false,
syntax: "es2021",
dts: true,
output: { target: "web", distPath: { root: "./dist" } },
},
],
});
@@ -0,0 +1,8 @@
import { define } from "../../utils/registration/define";
import { HacCard } from "./hac-card";
import { TAG_NAME } from "./info";
// Registers <hac-card> in the global registry, as used by Home Assistant
define(TAG_NAME, HacCard);
export { HacCard };
@@ -0,0 +1,165 @@
import { css, html, LitElement } from "lit";
import { property, state } from "lit/decorators.js";
import {
hasAssignedContent,
hasAssignedElements,
} from "../../utils/element/slot";
/**
* @element hac-card
*
* @summary
* Card that groups related content, with an optional header and footer.
*
* @slot header - Header of the card. Its spacing replaces the top padding of
* the content.
* @slot - Content of the card. Its padding is omitted when empty.
* @slot footer - Footer of the card. Hidden when empty.
*
* @csspart header - Wrapper of the header slot.
* @csspart content - Wrapper of the default slot.
* @csspart footer - Wrapper of the footer slot.
*
* @cssprop --hac-card-background - Background of the card.
* @cssprop --hac-card-color - Text color of the card.
* @cssprop --hac-card-border-color - Border color of the card and the footer divider.
* @cssprop --hac-card-border-width - Border width of the card.
* @cssprop --hac-card-border-radius - Border radius of the card.
* @cssprop --hac-card-box-shadow - Box shadow of the card.
* @cssprop --hac-card-content-padding - Padding of the content.
* @cssprop --hac-card-footer-padding - Padding of the footer.
*/
export class HacCard extends LitElement {
/**
* Visual style of the card. `outlined` draws a border without a shadow.
* `raised` hides the border and lifts the card with a shadow instead.
*/
@property({ reflect: true }) public appearance: "outlined" | "raised" =
"outlined";
@state() private _hasHeader = false;
@state() private _hasFooter = false;
@state() private _hasContent = false;
protected render() {
return html`
<div class="header" part="header" ?hidden=${!this._hasHeader}>
<slot name="header" @slotchange=${this._headerChanged}></slot>
</div>
<div
class="content ${this._hasHeader ? "below-header" : ""}"
part="content"
?hidden=${!this._hasContent}
>
<slot @slotchange=${this._contentChanged}></slot>
</div>
<div class="footer" part="footer" ?hidden=${!this._hasFooter}>
<slot name="footer" @slotchange=${this._footerChanged}></slot>
</div>
`;
}
private _headerChanged(ev: Event) {
this._hasHeader = hasAssignedElements(ev);
}
private _contentChanged(ev: Event) {
this._hasContent = hasAssignedContent(ev);
}
private _footerChanged(ev: Event) {
this._hasFooter = hasAssignedElements(ev);
}
/*
* Fallback order: --hac-card-*, then the theme variables of ha-card
* (--ha-card-*, --card-background-color, --divider-color,
* --primary-text-color) so existing themes keep working, then the semantic
* tokens and their light values for use outside Home Assistant.
* The ha-card variables are temporary, see "Theming" in the README.
*/
static styles = css`
:host {
display: block;
position: relative;
box-sizing: border-box;
background: var(
--hac-card-background,
var(
--ha-card-background,
var(--card-background-color, var(--ha-color-surface-default, #fff))
)
);
backdrop-filter: var(--ha-card-backdrop-filter, none);
color: var(
--hac-card-color,
var(--primary-text-color, var(--ha-color-on-surface-default, #141414))
);
border: var(--hac-card-border-width, var(--ha-card-border-width, 1px))
solid
var(
--hac-card-border-color,
var(
--ha-card-border-color,
var(--divider-color, var(--ha-color-border-neutral-quiet, #e6e6e6))
)
);
border-radius: var(
--hac-card-border-radius,
var(--ha-card-border-radius, var(--ha-border-radius-lg, 12px))
);
box-shadow: var(--hac-card-box-shadow, var(--ha-card-box-shadow, none));
}
:host([appearance="raised"]) {
border-color: transparent;
box-shadow: var(
--hac-card-box-shadow,
var(
--ha-card-box-shadow,
var(
--ha-box-shadow-s,
0 1px 2px 0 rgba(0, 0, 0, 0.08),
0 1px 3px 0 rgba(0, 0, 0, 0.12)
)
)
);
}
.content {
padding: var(--hac-card-content-padding, var(--ha-space-4, 16px));
}
/* The header already provides the spacing above the content */
.content.below-header {
padding-block-start: 0;
}
.footer {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--ha-space-2, 8px);
padding: var(--hac-card-footer-padding, var(--ha-space-2, 8px));
border-block-start: 1px solid
var(
--hac-card-border-color,
var(--divider-color, var(--ha-color-border-neutral-quiet, #e6e6e6))
);
}
.header[hidden],
.content[hidden],
.footer[hidden] {
display: none;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hac-card": HacCard;
}
}
@@ -0,0 +1,5 @@
/** Tag name of the element. */
export const TAG_NAME = "hac-card";
/** Version of <hac-card>. */
export const VERSION = "0.1.0";
@@ -0,0 +1,15 @@
import type { ElementDefinition } from "../../utils/registration/definition";
import { HacCard } from "./hac-card";
import { TAG_NAME } from "./info";
export { HacCard };
/**
* The bundled <hac-card> for a scoped registry:
* `ScopedElementsMixin(LitElement, [hacCard])` or
* `createScopedElementRegistry([hacCard])`.
*/
export const hacCard: ElementDefinition<typeof HacCard> = {
tagName: TAG_NAME,
element: HacCard,
};
+132
View File
@@ -0,0 +1,132 @@
import type { LitElement } from "lit";
import { define } from "./utils/registration/define";
import type { ElementDefinition } from "./utils/registration/definition";
import {
createScopedRegistry,
scopedCreationScope,
scopedRegistrySupport,
scopedShadowRootInit,
} from "./utils/registration/scoped-registry";
export type { ElementDefinition } from "./utils/registration/definition";
/**
* Creates a registry with the given elements, for `attachScopedShadow`:
* definitions from `scoped` entry points, e.g. `[hacCard]`.
* Create it once, outside the class, so all instances of a card share it.
*
* Without scoped registry support (native or polyfill), the elements are
* registered in the global registry instead, unless their tag name is already
* taken there, and the global registry is returned.
*/
export const createScopedElementRegistry = (
elements: readonly ElementDefinition[]
): CustomElementRegistry => {
// Checked as unknown: Array.isArray would narrow the list to any[]
if (!Array.isArray(elements as unknown)) {
throw new TypeError(
"@home-assistant/components: pass the elements to register, e.g. [hacCard]"
);
}
const registry = createScopedRegistry() ?? customElements;
for (const { tagName, element } of elements) {
define(tagName, element, registry);
}
return registry;
};
const shadowRootInit = (
init: ShadowRootInit,
registry: CustomElementRegistry
): ShadowRootInit =>
registry === customElements ? init : scopedShadowRootInit(init, registry);
const registries = new WeakMap<ShadowRoot, CustomElementRegistry>();
/**
* Attaches a shadow root that uses `registry` for custom elements in it.
*
* Content added with `innerHTML` or `insertAdjacentHTML` uses the registry.
* Create single elements with `createScopedElement`; `document.createElement`
* would use the global registry.
*
* Light DOM content, like slotted elements, keeps using the registry of the
* tree it is written in.
*/
export const attachScopedShadow = (
host: HTMLElement,
init: ShadowRootInit,
registry: CustomElementRegistry
): ShadowRoot => {
const root = host.attachShadow(shadowRootInit(init, registry));
registries.set(root, registry);
return root;
};
/** Creates an element from the registry of a shadow root. */
export const createScopedElement = <K extends keyof HTMLElementTagNameMap>(
root: ShadowRoot,
tagName: K
): HTMLElementTagNameMap[K] => {
const registry = registries.get(root);
if (!registry || registry === customElements) {
return document.createElement(tagName);
}
if (scopedRegistrySupport() === "polyfill") {
// The polyfill adds createElement to shadow roots
return (
root as unknown as {
createElement(tagName: K): HTMLElementTagNameMap[K];
}
).createElement(tagName);
}
return (
document.createElement as (
tagName: K,
options: { customElementRegistry: CustomElementRegistry }
) => HTMLElementTagNameMap[K]
)(tagName, { customElementRegistry: registry });
};
type LitElementConstructor = abstract new (...args: any[]) => LitElement;
/**
* Renders a Lit element into a shadow root with its own custom element
* registry, containing `elements`:
*
* ```ts
* class MyCard extends ScopedElementsMixin(LitElement, [hacCard]) {}
* ```
*
* See `createScopedElementRegistry` and `attachScopedShadow` for the details.
*/
export const ScopedElementsMixin = <T extends LitElementConstructor>(
superClass: T,
elements: readonly ElementDefinition[]
) => {
// Created on first use, so defining the class has no side effects
let registry: CustomElementRegistry | undefined;
const getRegistry = () => {
registry ??= createScopedElementRegistry(elements);
return registry;
};
abstract class ScopedElementsHost extends superClass {
public attachShadow(init: ShadowRootInit): ShadowRoot {
const root = super.attachShadow(shadowRootInit(init, getRegistry()));
registries.set(root, getRegistry());
return root;
}
protected createRenderRoot() {
const root = super.createRenderRoot();
const scoped = getRegistry();
if (scoped !== customElements && root instanceof ShadowRoot) {
this.renderOptions.creationScope = scopedCreationScope(root, scoped);
}
return root;
}
}
// Typed as the base class: the overrides add no public API
return ScopedElementsHost as unknown as T;
};
@@ -0,0 +1,15 @@
/** Whether the slot of a `slotchange` event has elements assigned. */
export const hasAssignedElements = (ev: Event): boolean =>
(ev.target as HTMLSlotElement).assignedElements().length > 0;
/**
* Whether the slot of a `slotchange` event has content assigned: elements or
* text. Whitespace between tags is not content.
*/
export const hasAssignedContent = (ev: Event): boolean =>
(ev.target as HTMLSlotElement)
.assignedNodes()
.some(
(node) =>
node.nodeType === Node.ELEMENT_NODE || Boolean(node.textContent?.trim())
);
@@ -0,0 +1,17 @@
/**
* Registers a custom element unless the tag name is already taken in the
* registry.
*
* Home Assistant and custom cards can each ship a copy of this library, so
* registering the same tag name twice must not throw. The first registration
* wins.
*/
export const define = (
tagName: string,
elementClass: CustomElementConstructor,
registry: CustomElementRegistry = customElements
): void => {
if (!registry.get(tagName)) {
registry.define(tagName, elementClass);
}
};
@@ -0,0 +1,10 @@
/**
* An element to register: its tag name and its class. The `scoped` entry
* point of an element exports one, e.g. `hacCard`.
*/
export interface ElementDefinition<
E extends CustomElementConstructor = CustomElementConstructor,
> {
readonly tagName: string;
readonly element: E;
}
@@ -0,0 +1,67 @@
/**
* Scoped custom element registries come in two flavors: the standard API
* (Chrome 146+, Safari 26) and the older API of
* `@webcomponents/scoped-custom-element-registry`, which Home Assistant loads
* and which replaces the native implementation when present.
*/
export type ScopedRegistrySupport = "polyfill" | "native" | "none";
let support: ScopedRegistrySupport | undefined;
export const scopedRegistrySupport = (): ScopedRegistrySupport => {
if (support) {
return support;
}
if ("CustomElementRegistryPolyfill" in globalThis) {
support = "polyfill";
} else {
try {
new CustomElementRegistry();
support = "native";
} catch (_err) {
support = "none";
}
}
return support;
};
/** A new scoped registry, or `undefined` if the browser has no support. */
export const createScopedRegistry = (): CustomElementRegistry | undefined =>
scopedRegistrySupport() === "none" ? undefined : new CustomElementRegistry();
/** Shadow root options that make the shadow root use `registry`. */
export const scopedShadowRootInit = (
init: ShadowRootInit,
registry: CustomElementRegistry
): ShadowRootInit =>
scopedRegistrySupport() === "polyfill"
? ({ ...init, registry } as ShadowRootInit)
: ({ ...init, customElementRegistry: registry } as ShadowRootInit);
interface CreationScope {
importNode(node: Node, deep?: boolean): Node;
}
/**
* The object Lit should create template content with, so custom elements in
* it are created from the scoped registry (Lit's `creationScope`).
*/
export const scopedCreationScope = (
root: ShadowRoot,
registry: CustomElementRegistry
): CreationScope => {
if (scopedRegistrySupport() === "polyfill") {
// The polyfill adds importNode to shadow roots
return root as unknown as CreationScope;
}
// Standard API: the registry is passed to importNode
return {
importNode: (node, deep) =>
(
document.importNode as (
node: Node,
options: { selfOnly: boolean; customElementRegistry: unknown }
) => Node
)(node, { selfOnly: !deep, customElementRegistry: registry }),
};
};
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { define } from "../src/utils/registration/define";
// Home Assistant and custom cards can each ship a copy of the library, so a
// second registration of the same tag must be ignored instead of throwing.
describe("define", () => {
it("registers an element that is not registered yet", () => {
class First extends HTMLElement {}
define("hac-test-first", First);
expect(customElements.get("hac-test-first")).toBe(First);
});
it("keeps the first registration of a tag name", () => {
class Original extends HTMLElement {}
class Duplicate extends HTMLElement {}
define("hac-test-duplicate", Original);
expect(() => define("hac-test-duplicate", Duplicate)).not.toThrow();
expect(customElements.get("hac-test-duplicate")).toBe(Original);
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import {
hasAssignedContent,
hasAssignedElements,
} from "../src/utils/element/slot";
// Components hide wrappers of empty slots, e.g. the padding of an empty card.
// Whitespace between tags must not count as content.
const slotEvent = (light: string, name?: string) => {
const host = document.createElement("div");
host.innerHTML = light;
const slot = document.createElement("slot");
if (name) {
slot.name = name;
}
host.attachShadow({ mode: "open" }).append(slot);
return { target: slot } as unknown as Event;
};
describe("hasAssignedContent", () => {
it("ignores whitespace", () => {
expect(hasAssignedContent(slotEvent("\n \n"))).toBe(false);
});
it("counts text", () => {
expect(hasAssignedContent(slotEvent(" Hello "))).toBe(true);
});
it("counts elements", () => {
expect(hasAssignedContent(slotEvent("\n <p></p>\n"))).toBe(true);
});
});
describe("hasAssignedElements", () => {
it("only counts elements assigned to the slot", () => {
const light = '<span slot="icon"></span> text';
expect(hasAssignedElements(slotEvent(light, "icon"))).toBe(true);
expect(hasAssignedElements(slotEvent(light, "actions"))).toBe(false);
});
});
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2021",
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"isolatedModules": true,
"skipLibCheck": true,
"declaration": true,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}
-26
View File
@@ -1,26 +0,0 @@
diff --git a/mwc-list-base.js b/mwc-list-base.js
index 1ba95b6a01dcecea4d85b5cbbbcc3dfb04c40d5f..dced13fdb7929c490d6661b1bbe7e9f96dcd2285 100644
--- a/mwc-list-base.js
+++ b/mwc-list-base.js
@@ -11,7 +11,7 @@ import { BaseElement } from '@material/mwc-base/base-element.js';
import { observer } from '@material/mwc-base/observer.js';
import { deepActiveElementPath, doesElementContainFocus, isNodeElement } from '@material/mwc-base/utils.js';
import { html } from 'lit';
-import { property, query, queryAssignedNodes } from 'lit/decorators.js';
+import { property, query, queryAssignedElements } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import MDCListFoundation, { isIndexSet } from './mwc-list-foundation.js';
export { createSetFromIndex, isEventMulti, isIndexSet } from './mwc-list-foundation.js';
@@ -425,10 +425,10 @@ __decorate([
query('.mdc-deprecated-list')
], ListBase.prototype, "mdcRoot", void 0);
__decorate([
- queryAssignedNodes('', true, '*')
+ queryAssignedElements({ flatten: true, selector: "*" })
], ListBase.prototype, "assignedElements", void 0);
__decorate([
- queryAssignedNodes('', true, '[tabindex="0"]')
+ queryAssignedElements({ flatten: true, selector: '[tabindex="0"]' })
], ListBase.prototype, "tabbableElements", void 0);
__decorate([
property({ type: Boolean }),
+1664 -1310
View File
File diff suppressed because it is too large. Load diff
+5 -1
View File
@@ -1,3 +1,7 @@
packages:
- "."
- "packages/*"
savePrefix: ""
autoInstallPeers: false
@@ -19,6 +23,7 @@ trustPolicyExclude:
allowBuilds:
"@tsparticles/engine": false
core-js: false
rs-module-lexer: false
unrs-resolver: false
overrides:
@@ -29,7 +34,6 @@ overrides:
tslib: 2.9.1
patchedDependencies:
"@material/[email protected]": patches/@[email protected]
[email protected]: patches/[email protected]
[email protected]: patches/[email protected]
[email protected]: patches/[email protected]
+5
View File
@@ -89,6 +89,11 @@
}
],
"packageRules": [
{
"description": "Keep semver ranges for peer dependencies of published packages, pinning them would force consumers onto one exact version",
"matchDepTypes": ["peerDependencies"],
"rangeStrategy": "widen"
},
{
"description": "Group all Python version updates from home-assistant/core",
"matchDepNames": ["python"],
@@ -3,6 +3,12 @@ import { ref } from "lit/directives/ref";
const MAX_ROWS = 2;
// A narrow list, as on a phone or a half width card, fits about one item per
// row, so two rows hide most of the legend. 450px is about the widest list a
// phone viewport gives.
const NARROW_MAX_ROWS = 3;
const NARROW_WIDTH = 450;
// 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 = (
@@ -34,9 +40,10 @@ export const fitInRows = (
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.
// Fits the items of a wrapping legend list into two rows, three when it is
// narrow. 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;
@@ -118,7 +125,7 @@ export class LegendFitController {
more?.getBoundingClientRect().width ?? 0,
rowWidth,
parseFloat(getComputedStyle(list).columnGap) || 0,
MAX_ROWS
rowWidth < NARROW_WIDTH ? NARROW_MAX_ROWS : MAX_ROWS
);
if (fit !== this.fit) {
this.fit = fit;
+37
View File
@@ -20,6 +20,11 @@ export interface EntityFilter {
export type EntityFilterFunc = (entityId: string) => boolean;
export type EntityFilterSources = Pick<
HomeAssistant,
"states" | "entities" | "devices" | "areas" | "floors"
>;
const normalizeFilterArray = <T>(
value: T | null | T[] | (T | null)[] | undefined
): Set<T | null> | undefined => {
@@ -157,3 +162,35 @@ export const findEntities = (
return results;
};
export const excludeEntities = (
entityIds: string[],
excludedEntityIds?: string[]
): string[] => {
if (!excludedEntityIds?.length) {
return entityIds;
}
const excluded = new Set(excludedEntityIds);
return entityIds.filter((entityId) => !excluded.has(entityId));
};
export const findFilteredEntities = (
sources: EntityFilterSources,
filters: EntityFilter[],
excludedEntityIds?: string[]
): string[] => {
const filterFuncs = filters.map((filter) =>
generateEntityFilter(
sources.states,
sources.entities,
sources.devices,
sources.areas,
sources.floors,
filter
)
);
return excludeEntities(
findEntities(Object.keys(sources.states), filterFuncs),
excludedEntityIds
);
};
+13 -2
View File
@@ -16,6 +16,13 @@ declare global {
export interface NavigateOptions {
replace?: boolean;
data?: Record<string, unknown>;
/**
* Leave open dialogs open. For a replace that only canonicalizes the current
* URL, like a router filling in its default page: a dialog restored from the
* URL, such as a more-info deep link, may already be open at that point, and
* the entry keeps its state so back still closes it.
*/
keepDialogs?: boolean;
}
// max time to wait for dialogs to close before navigating
@@ -196,7 +203,8 @@ const buildHistoryState = (
};
const performNavigation = async (path: string, options?: NavigateOptions) => {
const canProceed = await ensureDialogsClosed(Date.now());
const keepDialogs = options?.keepDialogs || false;
const canProceed = await (keepDialogs || ensureDialogsClosed(Date.now()));
if (!canProceed) {
return false;
}
@@ -215,7 +223,10 @@ const performNavigation = async (path: string, options?: NavigateOptions) => {
const { history } = mainWindow;
if (replace) {
if (replace && keepDialogs) {
// Same entry under its canonical URL: its state, dialog included, stays.
history.replaceState(history.state, "", path);
} else if (replace) {
// A replaced entry keeps its predecessor, so it keeps `from`.
const { root, from } = history.state ?? {};
const data = root ? { root: true } : options?.data;
@@ -0,0 +1,33 @@
import type { LocalizeFunc } from "./localize";
const YAML_ERROR_REASON_KEYS = new Map(
Object.entries({
"duplicated mapping key": "duplicated_mapping_key",
"bad indentation of a mapping entry": "bad_mapping_indentation",
"bad indentation of a sequence entry": "bad_sequence_indentation",
"deficient indentation": "deficient_indentation",
"tab characters must not be used in indentation": "tab_indentation",
"can not read a block mapping entry; a multiline key may not be an implicit key":
"multiline_implicit_key",
"a whitespace character is expected after the key-value separator within a block mapping":
"missing_key_value_space",
"expected ':' after a mapping key": "missing_colon",
"missed comma between flow collection entries": "missing_comma",
"unexpected end of the stream within a flow collection":
"unterminated_flow_collection",
"unexpected end of the stream within a single quoted scalar":
"unterminated_single_quote",
"unexpected end of the stream within a double quoted scalar":
"unterminated_double_quote",
"end of the stream or a document separator is expected":
"unexpected_content",
} as const)
);
export const localizeYamlErrorReason = (
localize: LocalizeFunc,
reason: string | undefined
): string | undefined => {
const key = reason ? YAML_ERROR_REASON_KEYS.get(reason) : undefined;
return key ? localize(`ui.components.yaml-editor.reasons.${key}`) : reason;
};
+11 -5
View File
@@ -1,6 +1,7 @@
import type {
DataTableFilter,
DataTableFilters,
FloorAreasFilterValue,
} from "../../data/data_table_filters";
import { dataTableQueryParamConfig } from "./data-table-query-params";
import {
@@ -25,6 +26,7 @@ export const automationsFilterQueryParamConfig = {
"assistants",
"blueprint",
],
boolean: [{ key: "no_area", trueValue: "1" }],
} as const satisfies QueryParamConfig;
export type AutomationsFilterQueryParams = QueryParamValues<
@@ -36,7 +38,7 @@ const list = (filter: DataTableFilter | undefined): string[] | undefined =>
const floorAreas = (
filter: DataTableFilter | undefined
): Record<string, string[]> | undefined =>
): FloorAreasFilterValue | undefined =>
filter?.value && !Array.isArray(filter.value) ? filter.value : undefined;
export const automationsFilterQueryParams = (
@@ -44,6 +46,7 @@ export const automationsFilterQueryParams = (
): AutomationsFilterQueryParams => ({
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
no_area: floorAreas(filters["ha-filter-floor-areas"])?.no_area,
device: list(filters["ha-filter-devices"]),
entity: list(filters["ha-filter-entities"]),
label: list(filters["ha-filter-labels"]),
@@ -59,7 +62,7 @@ export const automationsFilterQueryString = (
filters: DataTableFilters
): string => {
const params = automationsFilterQueryParams(filters);
const sorted: AutomationsFilterQueryParams = {};
const sorted: AutomationsFilterQueryParams = { ...params };
for (const key of automationsFilterQueryParamConfig.list) {
sorted[key] = params[key] && [...params[key]].sort();
@@ -94,8 +97,9 @@ export const automationsLinkFromUrl = (
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
const values = (key: keyof AutomationsFilterQueryParams): string[] =>
params[key]?.filter(Boolean) ?? [];
const values = (
key: (typeof automationsFilterQueryParamConfig.list)[number]
): string[] => params[key]?.filter(Boolean) ?? [];
const filter = (value: DataTableFilter["value"]): DataTableFilter => ({
value,
@@ -109,7 +113,9 @@ export const automationsLinkFromUrl = (
search: search ?? "",
filters: {
"ha-filter-floor-areas": filter(
floors.length || areas.length ? { floors, areas } : undefined
floors.length || areas.length || params.no_area
? { floors, areas, no_area: params.no_area }
: undefined
),
"ha-filter-devices": filter(values("device")),
"ha-filter-entities": filter(values("entity")),
+11 -5
View File
@@ -1,6 +1,7 @@
import type {
DataTableFilter,
DataTableFilters,
FloorAreasFilterValue,
} from "../../data/data_table_filters";
import { dataTableQueryParamConfig } from "./data-table-query-params";
import {
@@ -24,6 +25,7 @@ export const devicesFilterQueryParamConfig = {
"config_entry",
"sub_entry",
],
boolean: [{ key: "no_area", trueValue: "1" }],
} as const satisfies QueryParamConfig;
export type DevicesFilterQueryParams = QueryParamValues<
@@ -35,7 +37,7 @@ const list = (filter: DataTableFilter | undefined): string[] | undefined =>
const floorAreas = (
filter: DataTableFilter | undefined
): Record<string, string[]> | undefined =>
): FloorAreasFilterValue | undefined =>
filter?.value && !Array.isArray(filter.value) ? filter.value : undefined;
export const devicesFilterQueryParams = (
@@ -44,6 +46,7 @@ export const devicesFilterQueryParams = (
status: list(filters["ha-filter-states"]),
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
no_area: floorAreas(filters["ha-filter-floor-areas"])?.no_area,
domain: list(filters["ha-filter-integrations"]),
label: list(filters["ha-filter-labels"]),
config_entry: list(filters.config_entry),
@@ -55,7 +58,7 @@ export const devicesFilterQueryParams = (
// filter list sends back what it was given in its own order.
export const devicesFilterQueryString = (filters: DataTableFilters): string => {
const params = devicesFilterQueryParams(filters);
const sorted: DevicesFilterQueryParams = {};
const sorted: DevicesFilterQueryParams = { ...params };
for (const key of devicesFilterQueryParamConfig.list) {
sorted[key] = params[key] && [...params[key]].sort();
@@ -87,8 +90,9 @@ export const devicesLinkFromUrl = (
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
const values = (key: keyof DevicesFilterQueryParams): string[] =>
params[key]?.filter(Boolean) ?? [];
const values = (
key: (typeof devicesFilterQueryParamConfig.list)[number]
): string[] => params[key]?.filter(Boolean) ?? [];
const filter = (value: DataTableFilter["value"]): DataTableFilter => ({
value,
@@ -103,7 +107,9 @@ export const devicesLinkFromUrl = (
filters: {
"ha-filter-states": filter(values("status")),
"ha-filter-floor-areas": filter(
floors.length || areas.length ? { floors, areas } : undefined
floors.length || areas.length || params.no_area
? { floors, areas, no_area: params.no_area }
: undefined
),
"ha-filter-integrations": filter(values("domain")),
"ha-filter-labels": filter(values("label")),
+14 -5
View File
@@ -1,6 +1,7 @@
import type {
DataTableFiltersValue,
DataTableFiltersValues,
FloorAreasFilterValue,
} from "../../data/data_table_filters";
import { dataTableQueryParamConfig } from "./data-table-query-params";
import {
@@ -20,6 +21,7 @@ export const entitiesFilterQueryParamConfig = {
"floor",
"area",
"device",
"device_class",
"domain",
"entity_domain",
"label",
@@ -29,6 +31,7 @@ export const entitiesFilterQueryParamConfig = {
// Read until 2027.5, replaced with `assistants` on the next write
"voice_assistant",
],
boolean: [{ key: "no_area", trueValue: "1" }],
} as const satisfies QueryParamConfig;
export type EntitiesFilterQueryParams = QueryParamValues<
@@ -40,7 +43,7 @@ const list = (value: DataTableFiltersValue): string[] | undefined =>
const floorAreas = (
value: DataTableFiltersValue
): Record<string, string[]> | undefined =>
): FloorAreasFilterValue | undefined =>
value && !Array.isArray(value) ? value : undefined;
export const entitiesFilterQueryParams = (
@@ -49,7 +52,9 @@ export const entitiesFilterQueryParams = (
status: list(filters["ha-filter-states"]),
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
no_area: floorAreas(filters["ha-filter-floor-areas"])?.no_area,
device: list(filters["ha-filter-devices"]),
device_class: list(filters["ha-filter-device-classes"]),
domain: list(filters["ha-filter-integrations"]),
entity_domain: list(filters["ha-filter-domains"]),
label: list(filters["ha-filter-labels"]),
@@ -65,7 +70,7 @@ export const entitiesFilterQueryString = (
filters: DataTableFiltersValues
): string => {
const params = entitiesFilterQueryParams(filters);
const sorted: EntitiesFilterQueryParams = {};
const sorted: EntitiesFilterQueryParams = { ...params };
for (const key of entitiesFilterQueryParamConfig.list) {
sorted[key] = params[key] && [...params[key]].sort();
@@ -100,8 +105,9 @@ export const entitiesLinkFromUrl = (
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
const values = (key: keyof EntitiesFilterQueryParams): string[] =>
params[key]?.filter(Boolean) ?? [];
const values = (
key: (typeof entitiesFilterQueryParamConfig.list)[number]
): string[] => params[key]?.filter(Boolean) ?? [];
const floors = values("floor");
const areas = values("area");
@@ -112,8 +118,11 @@ export const entitiesLinkFromUrl = (
filters: {
"ha-filter-states": values("status"),
"ha-filter-floor-areas":
floors.length || areas.length ? { floors, areas } : undefined,
floors.length || areas.length || params.no_area
? { floors, areas, no_area: params.no_area }
: undefined,
"ha-filter-devices": values("device"),
"ha-filter-device-classes": values("device_class"),
"ha-filter-integrations": values("domain"),
"ha-filter-domains": values("entity_domain"),
"ha-filter-labels": values("label"),
+11 -5
View File
@@ -1,6 +1,7 @@
import type {
DataTableFiltersValue,
DataTableFiltersValues,
FloorAreasFilterValue,
} from "../../data/data_table_filters";
import { dataTableQueryParamConfig } from "./data-table-query-params";
import {
@@ -25,6 +26,7 @@ export const helpersFilterQueryParamConfig = {
// Read until 2027.5, replaced with `assistants` on the next write
"voice_assistant",
],
boolean: [{ key: "no_area", trueValue: "1" }],
} as const satisfies QueryParamConfig;
export type HelpersFilterQueryParams = QueryParamValues<
@@ -36,7 +38,7 @@ const list = (value: DataTableFiltersValue): string[] | undefined =>
const floorAreas = (
value: DataTableFiltersValue
): Record<string, string[]> | undefined =>
): FloorAreasFilterValue | undefined =>
value && !Array.isArray(value) ? value : undefined;
export const helpersFilterQueryParams = (
@@ -44,6 +46,7 @@ export const helpersFilterQueryParams = (
): HelpersFilterQueryParams => ({
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
no_area: floorAreas(filters["ha-filter-floor-areas"])?.no_area,
device: list(filters["ha-filter-devices"]),
label: list(filters["ha-filter-labels"]),
category: list(filters["ha-filter-categories"]),
@@ -57,7 +60,7 @@ export const helpersFilterQueryString = (
filters: DataTableFiltersValues
): string => {
const params = helpersFilterQueryParams(filters);
const sorted: HelpersFilterQueryParams = {};
const sorted: HelpersFilterQueryParams = { ...params };
for (const key of helpersFilterQueryParamConfig.list) {
sorted[key] = params[key] && [...params[key]].sort();
@@ -89,8 +92,9 @@ export const helpersLinkFromUrl = (
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
const values = (key: keyof HelpersFilterQueryParams): string[] =>
params[key]?.filter(Boolean) ?? [];
const values = (
key: (typeof helpersFilterQueryParamConfig.list)[number]
): string[] => params[key]?.filter(Boolean) ?? [];
const floors = values("floor");
const areas = values("area");
@@ -100,7 +104,9 @@ export const helpersLinkFromUrl = (
search: search ?? "",
filters: {
"ha-filter-floor-areas":
floors.length || areas.length ? { floors, areas } : undefined,
floors.length || areas.length || params.no_area
? { floors, areas, no_area: params.no_area }
: undefined,
"ha-filter-devices": values("device"),
"ha-filter-labels": values("label"),
"ha-filter-categories": values("category"),
+11 -5
View File
@@ -1,6 +1,7 @@
import type {
DataTableFilter,
DataTableFilters,
FloorAreasFilterValue,
} from "../../data/data_table_filters";
import { dataTableQueryParamConfig } from "./data-table-query-params";
import {
@@ -24,6 +25,7 @@ export const scenesFilterQueryParamConfig = {
"category",
"assistants",
],
boolean: [{ key: "no_area", trueValue: "1" }],
} as const satisfies QueryParamConfig;
export type ScenesFilterQueryParams = QueryParamValues<
@@ -35,7 +37,7 @@ const list = (filter: DataTableFilter | undefined): string[] | undefined =>
const floorAreas = (
filter: DataTableFilter | undefined
): Record<string, string[]> | undefined =>
): FloorAreasFilterValue | undefined =>
filter?.value && !Array.isArray(filter.value) ? filter.value : undefined;
export const scenesFilterQueryParams = (
@@ -43,6 +45,7 @@ export const scenesFilterQueryParams = (
): ScenesFilterQueryParams => ({
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
no_area: floorAreas(filters["ha-filter-floor-areas"])?.no_area,
device: list(filters["ha-filter-devices"]),
entity: list(filters["ha-filter-entities"]),
label: list(filters["ha-filter-labels"]),
@@ -55,7 +58,7 @@ export const scenesFilterQueryParams = (
// filter list sends back what it was given in its own order.
export const scenesFilterQueryString = (filters: DataTableFilters): string => {
const params = scenesFilterQueryParams(filters);
const sorted: ScenesFilterQueryParams = {};
const sorted: ScenesFilterQueryParams = { ...params };
for (const key of scenesFilterQueryParamConfig.list) {
sorted[key] = params[key] && [...params[key]].sort();
@@ -87,8 +90,9 @@ export const scenesLinkFromUrl = (
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
const values = (key: keyof ScenesFilterQueryParams): string[] =>
params[key]?.filter(Boolean) ?? [];
const values = (
key: (typeof scenesFilterQueryParamConfig.list)[number]
): string[] => params[key]?.filter(Boolean) ?? [];
const filter = (value: DataTableFilter["value"]): DataTableFilter => ({
value,
@@ -102,7 +106,9 @@ export const scenesLinkFromUrl = (
search: search ?? "",
filters: {
"ha-filter-floor-areas": filter(
floors.length || areas.length ? { floors, areas } : undefined
floors.length || areas.length || params.no_area
? { floors, areas, no_area: params.no_area }
: undefined
),
"ha-filter-devices": filter(values("device")),
"ha-filter-entities": filter(values("entity")),
+11 -5
View File
@@ -1,6 +1,7 @@
import type {
DataTableFilter,
DataTableFilters,
FloorAreasFilterValue,
} from "../../data/data_table_filters";
import { dataTableQueryParamConfig } from "./data-table-query-params";
import {
@@ -25,6 +26,7 @@ export const scriptsFilterQueryParamConfig = {
"assistants",
"blueprint",
],
boolean: [{ key: "no_area", trueValue: "1" }],
} as const satisfies QueryParamConfig;
export type ScriptsFilterQueryParams = QueryParamValues<
@@ -36,7 +38,7 @@ const list = (filter: DataTableFilter | undefined): string[] | undefined =>
const floorAreas = (
filter: DataTableFilter | undefined
): Record<string, string[]> | undefined =>
): FloorAreasFilterValue | undefined =>
filter?.value && !Array.isArray(filter.value) ? filter.value : undefined;
export const scriptsFilterQueryParams = (
@@ -44,6 +46,7 @@ export const scriptsFilterQueryParams = (
): ScriptsFilterQueryParams => ({
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
no_area: floorAreas(filters["ha-filter-floor-areas"])?.no_area,
device: list(filters["ha-filter-devices"]),
entity: list(filters["ha-filter-entities"]),
label: list(filters["ha-filter-labels"]),
@@ -57,7 +60,7 @@ export const scriptsFilterQueryParams = (
// filter list sends back what it was given in its own order.
export const scriptsFilterQueryString = (filters: DataTableFilters): string => {
const params = scriptsFilterQueryParams(filters);
const sorted: ScriptsFilterQueryParams = {};
const sorted: ScriptsFilterQueryParams = { ...params };
for (const key of scriptsFilterQueryParamConfig.list) {
sorted[key] = params[key] && [...params[key]].sort();
@@ -89,8 +92,9 @@ export const scriptsLinkFromUrl = (
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
const values = (key: keyof ScriptsFilterQueryParams): string[] =>
params[key]?.filter(Boolean) ?? [];
const values = (
key: (typeof scriptsFilterQueryParamConfig.list)[number]
): string[] => params[key]?.filter(Boolean) ?? [];
const filter = (value: DataTableFilter["value"]): DataTableFilter => ({
value,
@@ -104,7 +108,9 @@ export const scriptsLinkFromUrl = (
search: search ?? "",
filters: {
"ha-filter-floor-areas": filter(
floors.length || areas.length ? { floors, areas } : undefined
floors.length || areas.length || params.no_area
? { floors, areas, no_area: params.no_area }
: undefined
),
"ha-filter-devices": filter(values("device")),
"ha-filter-entities": filter(values("entity")),
+7
View File
@@ -0,0 +1,7 @@
/** Where a value is in a config, e.g. `["actions", 0, "then", 1]`. */
export type ConfigPath = (string | number)[];
export const stringifyPath = (path: ConfigPath): string => path.join("/");
export const pathEquals = (a: ConfigPath, b: ConfigPath): boolean =>
a.length === b.length && a.every((segment, index) => segment === b[index]);
+46 -24
View File
@@ -17,6 +17,7 @@ import {
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { join } from "lit/directives/join";
import { keyed } from "lit/directives/keyed";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { consume } from "../../common/decorators/consume";
@@ -124,16 +125,16 @@ 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
// Renders the actions at the end of a group's header, they don't toggle the
// group. Return `ha-icon-button`s with a label. 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}`;
// button itself for its own colour or size. Not a slot per group, as WebKit
// is slow to add and remove slots while the rows scroll.
export type DataTableGroupActions = (
group: string
) => TemplateResult | typeof nothing;
const UNDEFINED_GROUP_KEY = "zzzzz_undefined";
@@ -197,6 +198,9 @@ export class HaDataTable extends LitElement {
@property({ attribute: false }) public groupOrder?: string[];
@property({ attribute: false })
public renderGroupActions?: DataTableGroupActions;
@property({ attribute: false }) public sortColumn?: string;
@property({ attribute: false }) public sortDirection: SortingDirection = null;
@@ -397,9 +401,13 @@ export class HaDataTable extends LitElement {
}
if (properties.has("data")) {
// Clean up checked rows that no longer exist in the data
// Clean up checked rows that no longer exist or can no longer be selected
if (this._checkedRows.length) {
const validIds = new Set(this.data.map((row) => String(row[this.id])));
const validIds = new Set(
this.data
.filter((row) => row.selectable !== false)
.map((row) => String(row[this.id]))
);
const validCheckedRows = this._checkedRows.filter((id) =>
validIds.has(id)
);
@@ -506,7 +514,8 @@ export class HaDataTable extends LitElement {
this.groupOrder,
this._collapsedGroups,
this.sortColumn,
this.sortDirection
this.sortDirection,
this.renderGroupActions
)
: [];
@@ -759,8 +768,6 @@ export class HaDataTable extends LitElement {
`
: html`
${
// 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
@@ -775,11 +782,15 @@ export class HaDataTable extends LitElement {
.group=${this._stickyGroup}
@click=${this._collapseStickyGroup}
>
${this._renderGroupHeader(
${keyed(
this._stickyGroup,
this._collapsedGroups.includes(this._stickyGroup),
this._i18n?.localize,
true
this._renderGroupHeader(
this._stickyGroup,
this._collapsedGroups.includes(this._stickyGroup),
this._i18n?.localize,
this.renderGroupActions,
true
)
)}
</div>`
: nothing
@@ -803,7 +814,14 @@ export class HaDataTable extends LitElement {
`;
}
private _keyFunction = (row: DataTableRowData) => row?.[this.id] || row;
// Group headers are keyed by their group, so they keep their actions' state
// when the data changes. The prefixes keep them apart from row ids.
private _keyFunction = (row: DataTableRowData) =>
row?.[this.id]
? `row-${row[this.id]}`
: row?.groupHeader !== undefined
? `group-${row.groupHeader}`
: row;
private _renderMainValue(value: unknown) {
return this.clickable
@@ -1063,7 +1081,8 @@ export class HaDataTable extends LitElement {
groupOrder: string[] | undefined,
collapsedGroups: string[],
sortColumn: string | undefined,
sortDirection: SortingDirection
sortDirection: SortingDirection,
renderGroupActions: DataTableGroupActions | undefined
) => {
if (appendRow || groupColumn) {
let items = [...data];
@@ -1122,7 +1141,8 @@ export class HaDataTable extends LitElement {
content: this._renderGroupHeader(
groupName,
collapsedGroups.includes(groupName),
localize
localize,
renderGroupActions
),
});
if (!collapsedGroups.includes(groupName)) {
@@ -1148,6 +1168,7 @@ export class HaDataTable extends LitElement {
groupName: string,
collapsed: boolean,
localize: LocalizeFunc | undefined,
renderGroupActions: DataTableGroupActions | undefined,
sticky = false
) {
// The sticky copy's button and name are hidden from assistive technology,
@@ -1183,7 +1204,7 @@ export class HaDataTable extends LitElement {
}
</span>
<div class="group-actions" @click=${this._stopGroupToggle}>
<slot name=${groupActionsSlot(groupName)}></slot>
${renderGroupActions?.(groupName)}
</div>
</div>`;
}
@@ -1264,7 +1285,8 @@ export class HaDataTable extends LitElement {
this.groupOrder,
this._collapsedGroups,
this.sortColumn,
this.sortDirection
this.sortDirection,
this.renderGroupActions
);
if (
@@ -1961,7 +1983,7 @@ export class HaDataTable extends LitElement {
align-self: stretch;
margin-top: -12px;
margin-inline-start: auto;
/* The rows' icon button size, inherited by the slotted ones */
/* The rows' icon button size, inherited by the actions */
--_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);
@@ -1974,9 +1996,9 @@ export class HaDataTable extends LitElement {
position: sticky;
inset-inline-end: 0;
cursor: default;
/* Don't pass the group name's weight on to slotted menus */
/* Don't pass the group name's weight on to the actions */
font-weight: var(--ha-font-weight-normal);
/* Inherited by the slotted icon buttons, like the rows' icon cells */
/* Inherited by the actions, like the rows' icon cells */
color: var(
--data-table-group-actions-color,
var(--secondary-text-color)
@@ -97,6 +97,8 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
@state() private _dateValue?: string;
@state() private _rangeIncomplete = false;
@state() private _timeValue = {
from: { hours: 0, minutes: 0 },
to: { hours: 23, minutes: 59 },
@@ -195,6 +197,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
.locale=${this._i18n.locale.language}
.focusedDate=${this._focusDate}
@focusday=${this._focusChanged}
@rangestart=${this._handleRangeStart}
@change=${this._handleChange}
show-outside-days
.firstDayOfWeek=${firstWeekdayIndex(this._i18n.locale)}
@@ -253,7 +256,9 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
<ha-button appearance="plain" @click=${this._cancel}
>${this._i18n.localize("ui.common.cancel")}</ha-button
>
<ha-button .disabled=${!this._dateValue} @click=${this._save}
<ha-button
.disabled=${!this._dateValue || this._rangeIncomplete}
@click=${this._save}
>${this._i18n.localize(
"ui.components.date-range-picker.select"
)}</ha-button
@@ -280,7 +285,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
}
private _save() {
if (!this._dateValue) {
if (!this._dateValue || this._rangeIncomplete) {
return;
}
@@ -331,9 +336,21 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
this._focusDate = ev.target.focusedDate;
}
private _handleRangeStart(
ev: HASSDomTargetEvent<HTMLElementTagNameMap["calendar-range"]>
) {
this._rangeIncomplete = matchMedia("(hover: hover)").matches;
if (this._rangeIncomplete) {
return;
}
const day = ev.target.tentative;
this._dateValue = `${day}/${day}`;
}
private _handleChange(
ev: HASSDomTargetEvent<HTMLElementTagNameMap["calendar-range"]>
) {
this._rangeIncomplete = false;
const dateElement = ev.target as HTMLElementTagNameMap["calendar-range"];
this._dateValue = dateElement.value;
this._focusDate = dateElement.focusedDate;
+9 -3
View File
@@ -13,6 +13,7 @@ import {
import {
AREA_CONTROLS_BUTTONS,
getAreaControlEntities,
getAreaEntityIds,
type AreaControlDomain,
} from "../data/area/area_controls";
import {
@@ -148,15 +149,20 @@ export class HaAreaControlsPicker extends LitElement {
currentValue === id ||
(excludeValues !== undefined && excludeValues.includes(id));
const areaEntities = getAreaEntityIds(
registries.entities,
registries.devices,
areaId,
excludeEntities
);
const controlEntities = getAreaControlEntities(
areaEntities,
states,
registries.entities,
registries.devices,
registries.areas,
registries.floors,
AREA_CONTROL_DOMAINS,
areaId,
excludeEntities
AREA_CONTROL_DOMAINS
);
const items: (AreaControlPickerItem | string)[] = [];
-93
View File
@@ -1,93 +0,0 @@
import { CheckListItemBase } from "@material/mwc-list/mwc-check-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 { css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { preventDefault } from "../common/dom/prevent_default";
import { stopPropagation } from "../common/dom/stop_propagation";
import "./ha-checkbox";
@customElement("ha-check-list-item")
export class HaCheckListItem extends CheckListItemBase {
@property({ type: Boolean, attribute: "checkbox-disabled" })
checkboxDisabled = false;
@property({ type: Boolean })
indeterminate = false;
@property({ type: Boolean, attribute: "separate-checkbox-click" })
separateCheckboxClick = false;
async onChange(event) {
event.stopPropagation();
super.onChange(event);
fireEvent(this, event.type);
}
override render() {
const text = this.renderText();
const graphic =
this.graphic && this.graphic !== "control" && !this.left
? this.renderGraphic()
: nothing;
const meta = this.hasMeta && this.left ? this.renderMeta() : nothing;
const ripple = this.renderRipple();
return html` ${ripple} ${graphic} ${this.left ? "" : text}
<ha-checkbox
tabindex=${this.separateCheckboxClick ? this.tabindex : -1}
.checked=${this.selected}
.indeterminate=${this.indeterminate}
?disabled=${this.disabled || this.checkboxDisabled}
@change=${this.onChange}
@click=${this.separateCheckboxClick ? stopPropagation : preventDefault}
class=${this.left ? "left" : ""}
>
</ha-checkbox>
${this.left ? text : ""} ${meta}`;
}
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);
}
ha-checkbox {
flex-shrink: 0;
direction: var(--direction);
margin-inline-start: auto;
margin-inline-end: 0;
height: 100%;
justify-content: center;
}
ha-checkbox.left {
margin-inline-start: 0;
}
.mdc-deprecated-list-item__graphic {
margin-top: var(--check-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-check-list-item": HaCheckListItem;
}
}
+1
View File
@@ -219,6 +219,7 @@ export class HaControlSlider extends LitElement {
this.pressed = false;
this._hideTooltip();
this.value = savedValue;
fireEvent(this, "slider-moved", { value: undefined });
});
this._mc.on("panmove", (e) => {
if (this.disabled) return;
+65 -4
View File
@@ -5,10 +5,13 @@ import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
import { ensureArray } from "../common/array/ensure-array";
import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext } from "../data/context";
import { DOMAIN_DEVICE_CLASSES } from "../data/device_classes";
import { computeDeviceClassName } from "../data/entity/device_class";
import { computeUnitDeviceClasses } from "../data/sensor/unit_of_measurement";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type {
HomeAssistantInternationalization,
ValueChangedEvent,
@@ -27,6 +30,13 @@ export const getDeviceClassOptions = (
return { id: deviceClass, primary, sorting_label: primary };
});
// The reason line of a device class that does not support the unit names the
// unit, so searching it would match every unsupported device class
const UNIT_FILTER_SEARCH_KEYS: FuseWeightedKey[] = [
{ name: "primary", weight: 10 },
{ name: "id", weight: 3 },
];
@customElement("ha-device-class-picker")
export class HaDeviceClassPicker extends LitElement {
@property() public domain?: string;
@@ -43,6 +53,9 @@ export class HaDeviceClassPicker extends LitElement {
@property({ type: Boolean }) public required = false;
/** Unit of measurement: device classes that do not support it are listed last */
@property() public unit?: string;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: HomeAssistantInternationalization;
@@ -70,18 +83,60 @@ export class HaDeviceClassPicker extends LitElement {
private _options = memoizeOne(
(
domain: string | undefined,
localize: LocalizeFunc | undefined
): PickerComboBoxItem[] =>
domain && localize ? getDeviceClassOptions(domain, localize) : []
localize: LocalizeFunc | undefined,
unit: string | undefined,
language: string | undefined
): PickerComboBoxItem[] => {
if (!domain || !localize) {
return [];
}
const options = getDeviceClassOptions(domain, localize);
const fitting = computeUnitDeviceClasses(domain, unit);
if (!fitting) {
return options;
}
// Device classes of the unit first, then those that accept any unit
const rank = (deviceClass: string) =>
fitting.matching.includes(deviceClass)
? 0
: fitting.unrestricted.includes(deviceClass)
? 1
: 2;
const notSupported = localize(
"ui.components.device-class-picker.unit_not_supported",
{ unit }
);
return options
.map((option) =>
rank(option.id) === 2
? { ...option, secondary: notSupported }
: option
)
.sort(
(a, b) =>
rank(a.id) - rank(b.id) ||
caseInsensitiveStringCompare(a.primary, b.primary, language)
);
}
);
// The options are sorted here when the unit filters them
private get _filtered(): boolean {
return computeUnitDeviceClasses(this.domain, this.unit) !== undefined;
}
private _availableOptions = memoizeOne(
(options: PickerComboBoxItem[], selected: string[]) =>
options.filter((option) => !selected.includes(option.id))
);
private _getItems = () => {
const options = this._options(this.domain, this._i18n?.localize);
const options = this._options(
this.domain,
this._i18n?.localize,
this.unit,
this._i18n?.language
);
return this.multiple
? this._availableOptions(options, this._value)
: options;
@@ -104,6 +159,8 @@ export class HaDeviceClassPicker extends LitElement {
const emptyLabel = localize?.(
"ui.components.device-class-picker.no_device_classes"
);
const filtered = this._filtered;
const searchKeys = filtered ? UNIT_FILTER_SEARCH_KEYS : undefined;
if (this.multiple) {
const value = this._value;
@@ -145,6 +202,8 @@ export class HaDeviceClassPicker extends LitElement {
.getItems=${this._getItems}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${emptyLabel}
.noSort=${filtered}
.searchKeys=${searchKeys}
@value-changed=${this._itemAdded}
></ha-generic-picker>
`;
@@ -164,6 +223,8 @@ export class HaDeviceClassPicker extends LitElement {
.valueRenderer=${this._valueRenderer}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${emptyLabel}
.noSort=${filtered}
.searchKeys=${searchKeys}
@value-changed=${this._valueChanged}
></ha-generic-picker>
`;
@@ -0,0 +1,78 @@
import type { ContextType } from "@lit/context";
import { customElement, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { computeDomain } from "../common/entity/compute_domain";
import type { LocalizeFunc } from "../common/translations/localize";
import { stringCompare } from "../common/string/compare";
import { internationalizationContext, statesContext } from "../data/context";
import { computeDeviceClassName } from "../data/entity/device_class";
import { HaFilterStates } from "./ha-filter-states";
@customElement("ha-filter-device-classes")
export class HaFilterDeviceClasses extends HaFilterStates {
@state()
@consume({ context: statesContext, subscribe: true })
private _entityStates?: ContextType<typeof statesContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
@state()
@consumeLocalize()
private _localize?: LocalizeFunc;
private _deviceClasses = memoizeOne(
(
states: ContextType<typeof statesContext>,
localize: LocalizeFunc,
language: string
) => {
const classes = new Map<string, string>();
for (const [entityId, stateObj] of Object.entries(states)) {
const deviceClass = stateObj.attributes.device_class;
if (typeof deviceClass !== "string" || classes.has(deviceClass)) {
continue;
}
classes.set(
deviceClass,
computeDeviceClassName(localize, computeDomain(entityId), deviceClass)
);
}
return [...classes]
.map(([value, label]) => ({ value, label }))
.sort((a, b) => stringCompare(a.label, b.label, language));
}
);
protected firstUpdated() {
this._i18n?.loadBackendTranslation("entity_component");
}
protected willUpdate() {
if (
this._entityStates &&
this._localize &&
this._i18n &&
(this.expanded || !this.states)
) {
this.states = this._deviceClasses(
this._entityStates,
this._localize,
this._i18n.locale.language
);
}
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-device-classes": HaFilterDeviceClasses;
}
}
+136 -23
View File
@@ -8,6 +8,8 @@ import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
import { transform } from "../common/decorators/transform";
import { getDeviceAreaId } from "../common/entity/context/get_device_context";
import {
FilterPanelController,
filterPanelStyles,
@@ -17,11 +19,16 @@ import { fireEvent } from "../common/dom/fire_event";
import { computeRTL } from "../common/util/compute_rtl";
import { deepEqual } from "../common/util/deep-equal";
import type { LocalizeFunc } from "../common/translations/localize";
import type { AreaRegistryEntry } from "../data/area/area_registry";
import type { FloorAreasFilterValue } from "../data/data_table_filters";
import {
apiContext,
areasContext,
devicesContext,
floorsContext,
fullEntitiesContext,
internationalizationContext,
statesContext,
} from "../data/context";
import { getFloorAreaLookup } from "../data/floor_registry";
import type { RelatedResult } from "../data/search";
@@ -33,6 +40,7 @@ import "./ha-icon";
import "./ha-icon-button";
import "./ha-svg-icon";
import "./ha-tree-indicator";
import "./item/ha-list-item-base";
import "./item/ha-list-item-option";
import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-selectable";
@@ -59,10 +67,27 @@ export class HaFilterFloorAreas extends LitElement {
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@property({ attribute: false }) public value?: {
floors?: string[];
areas?: string[];
};
@consume({ context: devicesContext, subscribe: true })
@state()
private _devices!: ContextType<typeof devicesContext>;
@consume({ context: fullEntitiesContext, subscribe: true })
@state()
private _entities?: ContextType<typeof fullEntitiesContext>;
// Only a state being added or removed can change the state-only entities,
// so plain state updates keep the previous set and don't rerender
@state()
@consume({ context: statesContext, subscribe: true })
@transform<ContextType<typeof statesContext>, Set<string>>({
transformer: function (this: HaFilterFloorAreas, states) {
const ids = new Set(Object.keys(states));
return deepEqual(ids, this._stateIds) ? this._stateIds : ids;
},
})
private _stateIds?: Set<string>;
@property({ attribute: false }) public value?: FloorAreasFilterValue;
@property() public type?: keyof RelatedResult;
@@ -81,12 +106,17 @@ export class HaFilterFloorAreas extends LitElement {
private _relatedRequestId = 0;
public willUpdate(properties: PropertyValues<this>) {
public willUpdate(properties: PropertyValues) {
super.willUpdate(properties);
if (
properties.has("value") &&
!deepEqual(this.value, properties.get("value"))
(properties.has("value") &&
!deepEqual(this.value, properties.get("value"))) ||
// Items without an area are worked out from the registries
(this.value?.no_area &&
(properties.has("_devices") ||
properties.has("_entities") ||
(this.type !== "device" && properties.has("_stateIds"))))
) {
this._findRelated();
}
@@ -94,6 +124,12 @@ export class HaFilterFloorAreas extends LitElement {
protected render() {
const areas = this._areas(this._areasReg, this._floorsReg);
const groupOtherAreas =
!!areas.floors.length && !!areas.unassisgnedAreas.length;
const count =
(this.value?.areas?.length || 0) +
(this.value?.floors?.length || 0) +
(this.value?.no_area ? 1 : 0);
return html`
<ha-expansion-panel
@@ -104,13 +140,8 @@ export class HaFilterFloorAreas extends LitElement {
<div slot="header" class="header">
${this._localize("ui.panel.config.areas.caption")}
${
this.value?.areas?.length || this.value?.floors?.length
? html`<div class="badge">
${
(this.value?.areas?.length || 0) +
(this.value?.floors?.length || 0)
}
</div>
count
? html`<div class="badge">${count}</div>
<ha-icon-button
.path=${mdiFilterVariantRemove}
@click=${this._clearFilter}
@@ -131,6 +162,17 @@ export class HaFilterFloorAreas extends LitElement {
@ha-list-item-deselected=${this._handleRemoved}
aria-label=${this._localize("ui.panel.config.areas.caption")}
>
<ha-list-item-option
appearance="checkbox"
selection-position="end"
.value=${"no_area"}
.type=${"no_area"}
.selected=${this.value?.no_area || false}
>
<span slot="headline">
${this._localize("ui.components.area-picker.no_area")}
</span>
</ha-list-item-option>
${repeat(
areas?.floors || [],
(floor) => floor.floor_id,
@@ -162,10 +204,31 @@ export class HaFilterFloorAreas extends LitElement {
)}
`
)}
${
groupOtherAreas
? html`<ha-list-item-base role="presentation">
<ha-svg-icon
slot="start"
.path=${mdiTextureBox}
></ha-svg-icon>
<span slot="headline">
${this._localize(
"ui.panel.config.areas.picker.other_areas"
)}
</span>
</ha-list-item-base>`
: nothing
}
${repeat(
areas?.unassisgnedAreas,
(area) => area.area_id,
(area) => this._renderArea(area)
areas.unassisgnedAreas,
(area, index) =>
`${area.area_id}${index === areas.unassisgnedAreas.length - 1 ? "___last" : ""}`,
(area, index) =>
this._renderArea(
area,
index === areas.unassisgnedAreas.length - 1,
groupOtherAreas
)
)}
</ha-list-selectable>
</div>
@@ -175,9 +238,11 @@ export class HaFilterFloorAreas extends LitElement {
`;
}
private _renderArea(area, last = false) {
const hasFloor = !!area.floor_id;
private _renderArea(
area: AreaRegistryEntry,
last = false,
nested = !!area.floor_id
) {
return html`
<ha-list-item-option
appearance="checkbox"
@@ -190,11 +255,11 @@ export class HaFilterFloorAreas extends LitElement {
this._i18n.language,
this._i18n.translationMetadata.translations
),
floor: hasFloor,
floor: nested,
})}
>
${
hasFloor
nested
? html`<ha-tree-indicator
slot="start"
.end=${last}
@@ -227,6 +292,11 @@ export class HaFilterFloorAreas extends LitElement {
return;
}
if (addedItem.type === "no_area") {
this.value = { ...this.value, no_area: true };
return;
}
this.value = {
...this.value,
[addedItem.type]: [
@@ -249,6 +319,11 @@ export class HaFilterFloorAreas extends LitElement {
return;
}
if (removedItem.type === "no_area") {
this.value = { ...this.value, no_area: false };
return;
}
this.value = {
...this.value,
[removedItem.type]: this.value![removedItem.type].filter(
@@ -289,7 +364,9 @@ export class HaFilterFloorAreas extends LitElement {
if (
!this.value ||
(!this.value.areas?.length && !this.value.floors?.length)
(!this.value.areas?.length &&
!this.value.floors?.length &&
!this.value.no_area)
) {
this.value = {};
fireEvent(this, "data-table-filter-changed", {
@@ -332,6 +409,9 @@ export class HaFilterFloorAreas extends LitElement {
}
const items = new Set<string>();
if (this.value.no_area) {
this._itemsWithoutArea().forEach((item) => items.add(item));
}
for (const result of results) {
if (result[this.type!]) {
result[this.type!]!.forEach((item) => items.add(item));
@@ -344,6 +424,39 @@ export class HaFilterFloorAreas extends LitElement {
});
}
private _itemsWithoutArea(): string[] {
if (this.type === "device") {
return Object.values(this._devices)
.filter((device) => !getDeviceAreaId(device, this._devices))
.map((device) => device.id);
}
// Matches the area column of each table: the entities and helpers tables
// give an entity without its own area the area of its device, the
// automations, scripts and scenes tables only show the entity's own area
const useDeviceArea = this.type === "entity";
const entities = this._entities || [];
const items = entities
.filter(
(entity) =>
(this.includeDisabledEntities || !entity.disabled_by) &&
!entity.area_id &&
!(
useDeviceArea &&
entity.device_id &&
this._devices[entity.device_id]?.area_id
)
)
.map((entity) => entity.entity_id);
// Entities without a registry entry can't have an area
const registered = new Set(entities.map((entity) => entity.entity_id));
for (const entityId of this._stateIds || []) {
if (!registered.has(entityId)) {
items.push(entityId);
}
}
return items;
}
private _handleClearFilterKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.stopPropagation();
+24 -11
View File
@@ -1,3 +1,4 @@
import { ensureArray } from "../../common/array/ensure-array";
import type {
HaFormCondition,
HaFormDataContainer,
@@ -87,26 +88,38 @@ export const combineVisible = (
return conditions;
};
const getConditionFields = (visible: HaFormVisible | undefined): string[] =>
visible === undefined || typeof visible === "boolean"
? []
: ensureArray(visible).flatMap((condition) =>
"condition" in condition
? condition.conditions.flatMap((sub) => getConditionFields(sub))
: [condition.field]
);
// Hiding a field drops its value, which can flip another field's condition, so
// resolve the set to a fixpoint. Fields are given in order as name and visible.
// a field is evaluated after the fields its condition depends on.
export const getHiddenFields = (
fields: readonly (readonly [string, HaFormVisible | undefined])[],
data: HaFormDataContainer | undefined
): Set<string> => {
const hidden = new Set<string>();
const conditions = new Map(fields);
const evalData: HaFormDataContainer = { ...(data ?? {}) };
let changed = true;
while (changed) {
changed = false;
for (const [name, visible] of fields) {
if (hidden.has(name) || isVisible(visible, evalData)) {
continue;
}
const hidden = new Set<string>();
const resolved = new Set<string>();
const resolve = (name: string) => {
if (resolved.has(name) || !conditions.has(name)) {
return;
}
resolved.add(name);
const visible = conditions.get(name);
getConditionFields(visible).forEach(resolve);
if (!isVisible(visible, evalData)) {
hidden.add(name);
delete evalData[name];
changed = true;
}
}
};
fields.forEach(([name]) => resolve(name));
return hidden;
};
+25 -19
View File
@@ -16,6 +16,30 @@ import type {
HaFormIntegerSchema,
} from "./types";
/**
* The value the slider shows: the data, or for a required field without data
* its suggested value, default or minimum.
*/
export const computeIntegerSliderValue = (
schema: HaFormIntegerSchema,
data?: HaFormIntegerData
): number => {
if (data !== undefined) {
return data;
}
if (!schema.required) {
return schema.valueMin || 0;
}
return (
(schema.description?.suggested_value as HaFormIntegerData | undefined) ??
schema.default ??
schema.valueMin ??
0
);
};
@customElement("ha-form-integer")
export class HaFormInteger extends LitElement implements HaFormElement {
@property({ attribute: false }) public localize?: LocalizeFunc;
@@ -79,7 +103,7 @@ export class HaFormInteger extends LitElement implements HaFormElement {
}
<ha-slider
labeled
.value=${this._value}
.value=${computeIntegerSliderValue(this.schema, this.data)}
.min=${this.schema.valueMin}
.max=${this.schema.valueMax}
.disabled=${
@@ -136,24 +160,6 @@ export class HaFormInteger extends LitElement implements HaFormElement {
}
}
private get _value() {
if (this.data !== undefined) {
return this.data;
}
if (!this.schema.required) {
return this.schema.valueMin || 0;
}
return (
(this.schema.description?.suggested_value !== undefined &&
this.schema.description?.suggested_value !== null) ||
this.schema.default ||
this.schema.valueMin ||
0
);
}
private _handleCheckboxChange(ev: HASSDomTargetEvent<HaCheckbox>) {
const checked = (ev.target as HaCheckbox).checked;
let value: HaFormIntegerData | undefined;
+11 -6
View File
@@ -214,15 +214,19 @@ class HaHsColorPicker extends LitElement {
this._mc.add(new Tap({ event: "singletap" }));
let savedPosition;
let savedValue: [number, number] | undefined;
this._mc.on("panstart", (e) => {
if (this.disabled) return;
this._pressed = e.pointerType;
savedPosition = this._cursorPosition;
savedValue = this._localValue;
});
this._mc.on("pancancel", () => {
if (this.disabled) return;
this._pressed = undefined;
this._cursorPosition = savedPosition;
this._localValue = savedValue;
fireEvent(this, "cursor-moved", { value: undefined });
});
this._mc.on("panmove", (e) => {
if (this.disabled) return;
@@ -283,13 +287,14 @@ class HaHsColorPicker extends LitElement {
const x = e.center.x;
const y = e.center.y;
const boundingRect = e.target.getBoundingClientRect();
const offsetX = boundingRect.left;
const offsetY = boundingRect.top;
const maxX = e.target.clientWidth;
const maxY = e.target.clientHeight;
// The wheel is drawn as a centered square (object-fit: contain), but the
// canvas box itself is not always square (e.g. wider in Safari).
const size = Math.min(boundingRect.width, boundingRect.height);
const centerX = boundingRect.left + boundingRect.width / 2;
const centerY = boundingRect.top + boundingRect.height / 2;
const _x = (2 * (x - offsetX)) / maxX - 1;
const _y = (2 * (y - offsetY)) / maxY - 1;
const _x = (2 * (x - centerX)) / size;
const _y = (2 * (y - centerY)) / size;
const [r, phi] = xy2polar(_x, _y);
const [xx, yy] = polar2xy(Math.min(1, r), phi);
-116
View File
@@ -1,116 +0,0 @@
import { ListItemBase } from "@material/mwc-list/mwc-list-item-base";
import { styles } from "@material/mwc-list/mwc-list-item.css";
import type { CSSResultGroup } from "lit";
import { css } from "lit";
import { customElement } from "lit/decorators";
@customElement("ha-list-item")
export class HaListItem extends ListItemBase {
protected renderRipple() {
if (this.noninteractive) {
return "";
}
return super.renderRipple();
}
static get styles(): CSSResultGroup {
return [
styles,
css`
:host {
padding-left: var(
--mdc-list-side-padding-left,
var(--mdc-list-side-padding, 20px)
);
padding-inline-start: var(
--mdc-list-side-padding-left,
var(--mdc-list-side-padding, 20px)
);
padding-right: var(
--mdc-list-side-padding-right,
var(--mdc-list-side-padding, 20px)
);
padding-inline-end: var(
--mdc-list-side-padding-right,
var(--mdc-list-side-padding, 20px)
);
}
:host([graphic="avatar"]:not([twoLine])),
:host([graphic="icon"]:not([twoLine])) {
height: 48px;
}
span.material-icons:first-of-type {
margin-inline-start: 0px !important;
margin-inline-end: var(
--mdc-list-item-graphic-margin,
16px
) !important;
direction: var(--direction) !important;
}
span.material-icons:last-of-type {
margin-inline-start: auto !important;
margin-inline-end: 0px !important;
direction: var(--direction) !important;
}
.mdc-deprecated-list-item__meta {
display: var(--mdc-list-item-meta-display);
align-items: center;
flex-shrink: 0;
}
:host([graphic="icon"]:not([twoline]))
.mdc-deprecated-list-item__graphic {
margin-inline-end: var(
--mdc-list-item-graphic-margin,
20px
) !important;
}
:host([multiline-secondary]) {
height: auto;
}
:host([multiline-secondary]) .mdc-deprecated-list-item__text {
padding: 8px 0;
}
:host([multiline-secondary]) .mdc-deprecated-list-item__secondary-text {
text-overflow: initial;
white-space: normal;
overflow: auto;
display: inline-block;
margin-top: 10px;
}
:host([multiline-secondary]) .mdc-deprecated-list-item__primary-text {
margin-top: 10px;
}
:host([multiline-secondary])
.mdc-deprecated-list-item__secondary-text::before {
display: none;
}
:host([multiline-secondary])
.mdc-deprecated-list-item__primary-text::before {
display: none;
}
:host([disabled]) {
color: var(--disabled-text-color);
}
:host([noninteractive]) {
pointer-events: unset;
}
`,
// safari workaround - must be explicit
document.dir === "rtl"
? css`
span.material-icons:first-of-type,
span.material-icons:last-of-type {
direction: rtl !important;
--direction: rtl;
}
`
: css``,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-list-item": HaListItem;
}
}
-14
View File
@@ -1,14 +0,0 @@
import { ListBase } from "@material/mwc-list/mwc-list-base";
import { styles } from "@material/mwc-list/mwc-list.css";
import { customElement } from "lit/decorators";
@customElement("ha-list")
export class HaList extends ListBase {
static styles = styles;
}
declare global {
interface HTMLElementTagNameMap {
"ha-list": HaList;
}
}
+7 -2
View File
@@ -219,6 +219,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
if (this._searchFieldElement) {
this._searchFieldElement.value = value;
}
this._applySearch(value);
}
protected get scrollableElement(): HTMLElement | null {
@@ -691,7 +692,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _filterChanged = (ev: InputEvent) => {
const textfield = ev.target as HaInputSearch;
const searchString = (textfield.value ?? "").trim();
this._applySearch(textfield.value ?? "");
};
private _applySearch(value: string) {
const searchString = value.trim();
this._search = searchString;
if (this.sections?.length) {
@@ -736,7 +741,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
this._resetSelectedItem();
this._resetListScroll();
};
}
// Keep focus in the search field, so a focus change doesn't scroll the
// list to the active row and make the click miss.
@@ -21,6 +21,8 @@ export class HaActionSelector extends LitElement {
@property({ attribute: false }) public value?: Action;
@property() public name?: string;
@property() public label?: string;
@property({ type: Boolean, reflect: true }) public disabled = false;
@@ -51,6 +53,7 @@ export class HaActionSelector extends LitElement {
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-automation-action
.pathKey=${this.name}
.disabled=${this.disabled}
.actions=${this._actions(this.value)}
.hass=${this.hass}
@@ -11,6 +11,22 @@ import type { LocalizeFunc } from "../../common/translations/localize";
import "../ha-button-toggle-group";
import "./ha-selector";
/**
* A copy of the value with each choice's value under its key. A plain value,
* like a duration without active_choice, belongs to the active choice.
*/
export const computeChooseValues = (
value: any,
activeChoice: string
): Record<string, any> => {
if (value === null || value === undefined) {
return {};
}
return typeof value === "object" && "active_choice" in value
? { ...value }
: { [activeChoice]: value };
};
@customElement("ha-selector-choose")
export class HaChooseSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -109,12 +125,7 @@ export class HaChooseSelector extends LitElement {
private _choiceChanged(ev) {
ev.stopPropagation();
const value =
typeof this.value === "object"
? this.value
: {
[this._activeChoice!]: this.value,
};
const value = computeChooseValues(this.value, this._activeChoice!);
const choice = ev.detail?.value || ev.target.value;
this._activeChoice = choice;
if (choice && "constant" in this.selector.choose.choices[choice].selector) {
@@ -131,7 +142,7 @@ export class HaChooseSelector extends LitElement {
private _handleValueChanged(ev: CustomEvent) {
ev.stopPropagation();
const value = typeof this.value === "object" ? this.value : {};
const value = computeChooseValues(this.value, this._activeChoice!);
fireEvent(this, "value-changed", {
value: {
...value,
@@ -16,6 +16,8 @@ export class HaConditionSelector extends LitElement {
@property({ attribute: false }) public value?: Condition;
@property() public name?: string;
@property() public label?: string;
@property({ type: Boolean, reflect: true }) public disabled = false;
@@ -27,6 +29,7 @@ export class HaConditionSelector extends LitElement {
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-automation-condition
.pathKey=${this.name}
.disabled=${this.disabled}
.conditions=${this.value || []}
.hass=${this.hass}
Loaded 100 of 378 files, more files were not shown because too many files have changed in this diff. Show more