Compare commits

..
Author SHA1 Message Date
Paul Bottein e064958220 Reflect the active filters in the target picker counts 2026-08-20 11:05:09 +02:00
Paul Bottein 753bea99c8 Merge the domain and device class filters into a type filter 2026-08-19 17:44:17 +02:00
Paul Bottein b51c0bf086 Add indeterminate and controlled selection to lists 2026-08-19 16:12:40 +02:00
82ed13b7fc Fix filter pane expand animation and use a bottom sheet in narrow mode (#53684)
* Fix filter pane expand animation and use a bottom sheet in narrow mode

* Update src/common/controllers/filter-panel-controller.ts

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

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-08-19 16:18:40 +03:00
Petar PetrovandGitHub 089592e7e3 Ignore stale History statistics fetches after the selection changes (#53700)
* Ignore stale History statistics fetches after the selection changes.

A slower fetchStatistics call could finish after a newer sources or date-range request and replace the charts the user is looking at.

* Update src/panels/history/ha-panel-history.ts

---------

Co-authored-by: Paul Bottein <[email protected]>
2026-08-19 12:00:43 +02:00
Paul BotteinandGitHub 4bc9b2be7b Remove the unused compact mode from the target picker (#53685) 2026-08-19 11:48:43 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
1778b7abff Update dependency barcode-detector to v3.2.2 (#53702)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-19 11:39:58 +03:00
90567a491a Redesign History and Activity filtering into a sources pane (#53630)
* Redesign History and Activity filtering into a sources pane

Give both panels the toolbar + left pane layout of the data tables, and
merge target picking and filtering into one "Sources" surface, per the UX
discussion.

- ha-filter-pane: pane on wide screens, bottom sheet on narrow ones,
  mirroring the filter pane of hass-tabs-subpage-data-table
- ha-sources-picker: target picker plus domain, device class and
  integration filters, shared by both panels
- ha-filter-device-classes: new filter panel, labelled with the backend
  device class names
- ha-filter-pane-chip and ha-empty-state: the chip with a filter count
  badge and the centered placeholder both panels need
- ha-date-range-nav: the date range picker as one pill with previous and
  next steppers, for the toolbar
- History draws timeline names above their bar (inside-labels), which
  gives long names the full width
- Activity keeps a floating date header while scrolling, and reset moved
  into the overflow menu next to refresh and download
- Drop the now unused compact and add-on-top modes of ha-target-picker,
  including ha-target-picker-value-chip
- Right-align the clear button in the domains filter header, matching the
  other filter panels

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

* Remove dead code breaking the type check

* Fix stuck loading state when the selection resolves to no entity

* Keep the target picker out of the redesign

* Remember the source filters across visits

* Truncate timeline names that do not fit the plot

* Show device class icons in the sources filter

* Open the sources pane by default on wide screens

* Show the entity count in the sources chip

* Count the same entities in the sources chip and the target picker

* Fix sources resolution, fetching and filter badge in History and Activity

* Update src/panels/history/ha-panel-history.ts

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

---------

Co-authored-by: Claude Opus 5 <[email protected]>
Co-authored-by: Paul Bottein <[email protected]>
Co-authored-by: Petar Petrov <[email protected]>
2026-08-19 11:31:10 +03:00
Paul BotteinandGitHub 409e2b315f Skip entities assigned to another area when resolving area targets (#53693) 2026-08-19 08:46:45 +03:00
Petar PetrovandGitHub eb1f8c1fab Add Matter network topology visualization page (#53247)
* Add Matter network topology visualization page

* Add central Home Assistant node to Matter network graph

* Refine Matter graph HA node: theme-aware memoization, drop dead branch

* Render Matter 'unknown' link strength as a present, neutral link

* Pass devices to getDeviceArea so bridged nodes inherit their area

* Distinguish position-unknown Matter graph anchors and name border routers

Anchors to a hubless component only mean the node is reachable, so draw
them dotted instead of reusing the solid line that marks a real path
through a border router or access point.

Also prefer the border router mDNS host name over vendor and model,
which several vendors report identically on every unit.

* Name Matter access points by SSID and show their radio address

Access points were labelled with a BSSID, and the same address was
repeated as the node context. Prefer the SSID for the label and keep the
radio address as context only when it differs, so the radios of one mesh
stay distinguishable without repeating the label.

Falls back to today's BSSID against servers that do not send an SSID.

* Colour Matter graph links by transport and float unknown neighbours

Unknown Thread neighbours are not commissioned on our fabric, so Home
Assistant has no operational path to them. A group made only of unknown
devices no longer draws an anchor to the Home Assistant node and floats
instead of claiming a connection that does not exist.

Link colour now encodes the transport, purple for Thread and pink for
Wi-Fi, with the signal level left on the line width and spelled out in
the tooltip. A dead link keeps the disabled colour, which is the only
thing separating it from a healthy weak one now that both are width 1.
The edge tooltip names the network, since the graph legend describes
nodes only and cannot carry a link entry.

* Float Matter nodes whose route to Home Assistant is unknown

Only border routers and access points get an edge to the Home Assistant
node, because only those are a path we can actually see. A node in a
group with neither was previously anchored to Home Assistant with a
dotted line, which reads as a physical connection that was never
observed.

Removing the anchor makes the component walk that picked a
representative dead code, so it goes too.

* Match the dashboard's line semantics for Matter graph links

Dash an edge whose endpoint is inferred rather than commissioned, or is
offline, which is the same pair of conditions the matter.js dashboard
dashes on. Stop drawing a link whose every direction is dead: the
summary strength is the strongest direction, so none means a stale
neighbour entry, and the dashboard never draws one either.

That also retires the grey dead-link colour, since such links no longer
reach the graph.

* Carry the transport colour onto the Home Assistant edges

An edge from Home Assistant to a hub now takes the colour of the network
behind it, so one transport reads as one colour the whole way back
instead of changing hue at the hub.

Wi-Fi moves from pink to orange: pink sat close to the error red used
for offline nodes, while orange is far from both that red and the Thread
purple.

* Give the Wi-Fi access point node its transport colour

The access point wore indigo while everything else on its network was
orange. Deriving the category colour from the same helper the links use
keeps a hub and its links one hue, and gives the Wi-Fi colour a legend
entry it did not have before.

* Give the border router node its transport colour too

Both hub categories now derive their colour from the same helper as the
links, so a network is one hue from Home Assistant through its hub to
the devices, and each legend swatch keys the links of that transport.

The border router also gains contrast: the shared Thread purple is
legible on both the light and the dark card background, where the deep
purple it replaces was not.
2026-08-19 08:02:49 +03:00
Paul BotteinandGitHub 29cf30fc1a Fix check for updates toasts stacking (#53696) 2026-08-19 06:45:52 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
f88535c446 Lock file maintenance (#53698)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-19 06:43:45 +02:00
Jan BouwhuisandGitHub b9fd639bb7 Add improved mqtt panel to config section (#53653)
* Add mqtt config dashboard shortcut

* Improve MQTT subscribe panel formatting and add copy button

* Standardize MQTT options flow button to a nav card style

* Refactor MQTT dashboard

* Improve style

* Add link to broker connection settings

* Test the effect of not exporting the SVG data

* Move MQTT svg icon to separate file
2026-08-18 19:00:28 +02:00
Paul BotteinandGitHub 2925dfc14e Show entity name in template autocomplete info panel (#53695) 2026-08-18 18:54:03 +02:00
Yosi LevyandGitHub bb7ec34532 Addons RTL fix (#53692)
Addon RTL fix
2026-08-18 17:01:41 +02:00
89015bc135 Fix backup progress segment order to match backend execution order (#53652)
* Fix backup progress segment order to match backend execution order

The segmented backup progress bar showed Apps, Media, Home Assistant,
but Supervisor backs up Home Assistant first since 2024 (supervisor#5203),
then add-ons, then folders. The Home Assistant segment activated first
while Apps/Media incorrectly showed as completed, and the bar regressed
once the apps stage started.

Also move the await-addon-restart stages into the last creation group,
since Supervisor awaits add-on restarts after the backup file is
finished, keeping the progress bar monotonic.

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

* Keep await restart stages out of MEDIA_STAGES

Give them their own constant and merge them into the last creation
group only where the ordering constraint applies.

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

* Group backup setup stages with the first progress segment

addon_repositories/app_repositories (and docker_config on older
Supervisors) are emitted while the backup is initialized, before the
home_assistant stage, so grouping them with the apps segment made the
bar regress at the start of a backup.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-18 13:52:07 +03:00
Petar PetrovandGitHub 343f1e840a Fix Z-Wave network visualization showing wrong route edges (#53676)
Fix repeater mapping and route direction in Z-Wave network graph
2026-08-18 08:07:24 +03:00
G JohanssonandGitHub 1369f80eb0 Allow using description in create_entry for repair flows (#53671) 2026-08-17 22:08:57 +02:00
Paul BotteinandGitHub cd52f7b193 Fix the clear button alignment in the domains filter (#53687) 2026-08-17 17:47:35 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
86dea86b6c Update dependency js-yaml to v5.3.0 (#53679)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-17 17:16:53 +00:00
Paul BotteinandGitHub 04035016b0 Extract the data table filter chip into ha-filter-pane-chip (#53682) 2026-08-17 19:16:46 +02:00
Petar PetrovandGitHub 52b0b46991 Group sankey devices by count as well as by value (#53680)
* Group sankey devices by count as well as by value

The 0.1%-of-total value threshold only catches devices that are a
negligible share of the home total. A breaker panel with dozens of
similar-sized circuit clamps sits entirely above it, so nothing groups and
every node collapses toward the 1px minimum with labels shrinking to match.

Cap the number of named children per parent too, defaulting to 20 (the
readable capacity of the default 400px card). Over-cap devices are grouped
into the same per-parent "Other" node the value threshold already produces,
so the flow arithmetic is unchanged, and the cap is configurable per card
via max_devices.

* Clarify the max_devices label is per upstream device, not per floor or area

* Make max_devices budget named devices, not nodes

A cap of 20 rendered 19 devices because a slot was reserved for the Other
node, and a cap of 1 rendered none. The option now means what its name and
its namesake on the devices graph mean: at most this many devices are shown
by name, with Other as overhead on top.

* Extract the sankey device cap into findDevicesOverCap

Moves the count-cap logic out of buildSankeyDeviceNodes into an exported
function that takes the graph it needs, so it can be unit tested directly,
and trims the commentary to the two non-obvious rules (why the whole subtree
is grouped, and why at least two devices are).
2026-08-17 19:14:48 +02:00
d5389b5bef Fall back to option key for untranslated repair menu options (#53681)
Repair fix flows build their menu from the suggestions the backend
reports. A suggestion this frontend has no translation for yet (e.g. a
newer Supervisor offering a new repair suggestion) rendered as an
empty, unlabeled menu entry. Show the raw option key instead, matching
the fallback the form step header already uses.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-17 15:23:02 +02:00
f828c636a2 Fix Energy configuration back navigation (#53574)
* Fix Energy configuration back navigation

Co-authored-by: Copilot App <[email protected]>

* Align app E2E navigation with production

Co-authored-by: Copilot App <[email protected]>

---------

Co-authored-by: Copilot App <[email protected]>
2026-08-17 12:45:08 +03:00
Petar PetrovandGitHub e91337b320 Log console error when a dashboard resource fails to load (#53675)
* Log console error when a dashboard resource fails to load

* Include the rejection reason in the resource load error
2026-08-17 10:04:51 +02:00
karwostsandGitHub 8ab7c118a9 Fix custom weather icons (#53667) 2026-08-17 06:52:16 +02:00
karwostsandGitHub 0eafb1f4b4 Add themes to yaml reload menu (#53673) 2026-08-17 06:03:12 +02:00
9a7722a02e Remove weather card pointer cursor when it has no action (#53654)
* Remove weather card pointer cursor when it has no action

* use hasAnyAction helper

---------

Co-authored-by: karwosts <[email protected]>
2026-08-16 09:38:05 -07:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
3e184eeb39 Update dependency @rspack/core to v2.1.10 (#53663)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-16 12:37:16 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
61be4e2952 Update dependency @rsdoctor/rspack-plugin to v1.6.2 (#53662)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-16 12:13:08 +02:00
Paul BotteinandGitHub 315cf97e56 Prevent the sidebar from scrolling the dashboard (#53644) 2026-08-16 06:27:47 +00:00
karwostsandGitHub 771a8aa4c9 Fix negative button of duration selector (#53646) 2026-08-16 06:27:17 +00:00
d9aa649854 Remove deprecated battery props from vacuum (#53005)
Co-authored-by: Simon Lamon <[email protected]>
2026-08-16 06:26:54 +00:00
7a087cd6d0 Ability to link a person to an existing user (#53550)
* Support linking a person to an existing user

* Don't delete preexisting user on cancel submit

* retry ci

* Don't show choice when no floating users exist

* collapse to single dialog

---------

Co-authored-by: Simon Lamon <[email protected]>
2026-08-16 06:25:03 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
32a30468bb Update dependency globals to v17.10.0 (#53647)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-16 06:24:56 +00:00
karwostsandGitHub 0ace3cedfb Add disabled to button group (#53651) 2026-08-16 06:24:33 +00:00
karwostsandGitHub aba7478e26 Fix choose selector minor issues (#53648) 2026-08-16 06:24:28 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
c8b2e5160a Update dependency hls.js to v1.6.18 (#53649)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-16 06:21:52 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4c46ca0918 Bump the codeql-action group across 1 directory with 2 updates (#53661)
Bumps the codeql-action group with 2 updates in the / directory: [github/codeql-action/init](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.4 to 4.37.6
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/f205ea1c3313d32999d8d6a48b4f6530d4437b38...5595ccaf912efad79be6eef63a5619ff05969be3)

Updates `github/codeql-action/analyze` from 4.37.4 to 4.37.6
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/f205ea1c3313d32999d8d6a48b4f6530d4437b38...5595ccaf912efad79be6eef63a5619ff05969be3)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-16 06:20:49 +00:00
0483106f61 Restore bundle size budget headroom (#53659)
Co-authored-by: Copilot App <[email protected]>
2026-08-16 08:16:29 +02:00
Paul BotteinandGitHub 7e0c572f69 Replace automation state color with a badge (#53627)
* Replace state color with a badge in automation picker

* Fix data table spacing and text truncation on mobile
2026-08-14 15:31:14 +02:00
renovate[bot]andGitHub e4438a20cb Update dependency @rspack/core to v2.1.9 (#53642) 2026-08-14 14:13:06 +01:00
Aidan TimsonandGitHub ec418498a7 Make date picker navigation arrows themeable (#53639) 2026-08-14 16:05:46 +03:00
8122baca00 Add more deep links to more-info dialogs (#53628)
* Add deep links to more-info dialogs

* Fix

* Preserve existing URL hash in createMoreInfoUrl and removeMoreInfoUrl

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

* Preserve explicit empty hash and move more-info restoration to app owner

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

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-08-14 16:02:58 +03:00
Paul BotteinandGitHub 79738dd257 Add activity detail dialog (#53362)
* Add activity detail dialog

* Apply review fixes to activity detail dialog

* Resolve previous state from history in logbook detail dialog

* Disable text selection on clickable logbook rows

* Document run discovery as a fallback for missing context ids

* Scope the detail chain to the subject's cause path

* Replace clickable logbook rows with explicit detail affordances

* Show the cause badge on automation and integration rows

* Map scheduled and Home Assistant causes to their trigger icons

* Replace logbook detail affordances with full-width rows

* Frame the activity detail dialog with ha-grouped-list

* Add missing import

* Fix milliseconds placement in activity chain times
2026-08-14 15:57:03 +03:00
Bram KragtenandGitHub f23c8f0e23 Fix sub device tree indicator (#53638) 2026-08-14 09:18:06 +01:00
Petar PetrovandGitHub f578cb0526 Guard registry lookups in the target picker item row (#53637) 2026-08-14 09:55:54 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
78878897cc Update dependency typescript-eslint to v8.67.0 (#53634)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-14 06:46:42 +02:00
9472d8ad12 Add device context to energy individual device names (#53603)
Add device context to energy individual device labels

Co-authored-by: Simon Lamon <[email protected]>
2026-08-13 18:52:28 +02:00
Paul BotteinandGitHub 94cbe4467b Lay out inline features in two columns (#53631)
* Lay out inline features in two columns

* Detect stacked features from the slot

* Rename tile container layout classes

* Rename the below features in the layout helper

* Keep area card features below at full width

* Stretch the area card features below the inline one

* Keep area card features compact outside the compact type

* Revert "Keep area card features compact outside the compact type"

This reverts commit 48268cb4e2.

* Reapply "Keep area card features compact outside the compact type"

This reverts commit e10bb27e2f.

* Keep the inline feature compact on condensed cards
2026-08-13 18:00:47 +03:00
Paul BotteinandGitHub 043abb69af Keep control select in the tab order without a selection (#53633) 2026-08-13 15:54:59 +01:00
Paul BotteinandGitHub b6b8fe641f Fix clipped focus ring on media player playback buttons (#53632) 2026-08-13 15:54:49 +01:00
70bb08b101 Child devices UI (#53619)
* Add child devices UI

Surface child devices throughout the config UI so they read as first-class
devices nested under their parent:

- Device page: a "Sub-devices" card lists a device's children, and a child's
  page shows a "Part of <parent>" link (hardware/model/config-entry are
  already inherited from the parent by the registry resolver).
- Integration page: children are nested and indented under their parent
  device in the config-entry and subentry device lists.
- Device picker: children are ordered and indented under their parent with a
  tree connector, mirroring the area/floor picker.
- Naming: the device picker's secondary label and search now include the
  parent device name for children, so they stay identifiable in flat views.

Follow-up to the child devices data layer (#53617).

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

* Add child devices to demo and gallery mock data

A power strip parent with two outlet children in the demo device stubs and
the ha-selector gallery demo, so child device rendering (nesting, tree
indentation, parent-context naming, inherited area) can be exercised without
a running backend.

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

* Refine child devices UI and target resolution

Follow-up polish so child devices behave like a normal device everywhere:

- Targets & filters: a parent device qualifies for and resolves to its
  children's entities (getDevices filter, deviceMeetsFilter,
  deviceMeetsTargetSelector, resolveEntityIDs and the target-chip "split into
  entities" expand), matching core's server-side target resolution. Selecting a
  parent excludes its children from the picker.
- Pickers: keep a parent visible when a child matches the search (device and
  target pickers), fix the target picker's nested order (unsorted search +
  recomputed last-child flag), and render the sub-device tree in the target
  picker's device group.
- Area/naming: the area field shows only the (inherited) area again; the parent
  name remains a search term.
- Integration page: correct the tree end connector and align it in narrow mode.
- A parent-disabled child can no longer be enabled from the settings dialog.
- Devices dashboard: show "Part of <parent>" under a sub-device's name and add a
  hidden-by-default, searchable and groupable Parent device column that groups a
  parent together with its children.

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

* Address review on child devices UI

Align target resolution with core and fix picker/table details:

- Only a directly targeted device expands to its child devices. Labels are
  never inherited by children (core's target helper is explicit about this) and
  areas resolve by effective-area membership, so deviceMeetsFilter and
  deviceMeetsTargetSelector evaluate a device's own entities again.
- Add devicesInEffectiveArea, mirroring core's dr.async_entries_for_area: an
  area contains its devices plus children that inherit the area, but not a child
  with a different explicit area. Used for area expansion, area matching and the
  area chip's split action.
- Device picker: add a searchFn that restores the nested parent/child order
  after the fuzzy search and recomputes the last visible child, so a child can
  no longer be ranked above its parent and connectors stay correct.
- Devices dashboard: derive the family group name from the family's parent
  device with the same fallback for parents and children, so an unnamed parent
  cannot end up in a different group than its children.
- Child devices card: pass the device registry so a child shows its inherited
  area.

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

* Fix formatting in device picker row renderer

Reindent the row renderer template after it gained a block body, so Prettier
is satisfied.

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

* Make the child device tree connector span the full row

The connector was a fixed 48px box centred in a taller two-line row, and its
SVG was letterboxed by the default preserveAspectRatio, so the dashed line
stopped short of the row edges and consecutive children never visually
connected.

Let the indicator stretch (preserveAspectRatio="none") and give it the full row
height, so the line runs edge to edge with the elbow at the vertical centre.
non-scaling-stroke keeps the line width and dash pattern identical however far
it is stretched; existing 48x48 usages render unchanged.

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

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-13 17:45:31 +03:00
4259ddee24 Show remaining tile card features below when position is inline (#53310)
* Show remaining tile card features below when position is inline

Previously the tile card silently dropped every feature after the first
one when "features_position" was set to "inline". The first feature is
now rendered next to the name as before, and any additional features are
stacked underneath, the same way they are in "bottom" position.

The tile container gained a "features-bottom" slot for this, and the
card size and grid options now account for the extra rows.

* Add tests for hui-tile-card size and grid options calculations

* Enhance hui-tile-card to support inline feature layout and improve feature counting logic

* reduce vertical gap for inline features

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-13 15:45:00 +02:00
6c5ac1ea84 Add background editor to dashboard detail editor (#51644)
* Setup

* Use same slot layout

* Fill tabs

* Move content rendering to dedicated function

* Self review

* Save dashboard details before background

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

* Protect strategy dashboard config

* Apply dashboard background review fixes

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-08-13 16:27:34 +03:00
Petar PetrovandGitHub 5224fa2048 Seed selector values when enabling optional service fields (#53629) 2026-08-13 15:27:07 +02:00
206 changed files with 12753 additions and 7521 deletions
+2 -2
View File
@@ -32,12 +32,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
with:
category: "/language:javascript-typescript"
+8 -8
View File
@@ -1,15 +1,15 @@
{
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
"frontend-modern": {
"app": 595204,
"core": 57741,
"authorize": 576928,
"onboarding": 685964
"app": 585518,
"core": 57048,
"authorize": 552423,
"onboarding": 666818
},
"frontend-legacy": {
"app": 861452,
"core": 258557,
"authorize": 834356,
"onboarding": 1001360
"app": 887384,
"core": 244482,
"authorize": 844995,
"onboarding": 1008816
}
}
+35
View File
@@ -51,4 +51,39 @@ export const demoDevices: DeviceRegistryEntry[] = [
primary_config_entry: "mock-sonos",
entry_type: null,
},
{
...baseDevice,
id: "power-strip",
name: "Power strip",
manufacturer: "Acme",
model: "Smart Power Strip",
config_entries: ["mock-hue"],
primary_config_entry: "mock-hue",
entry_type: null,
},
// Child devices (logical parts of the power strip). They carry the parent's
// inherited hardware fields, mirroring how resolveChildDevices fills them in
// from the WebSocket, and reference the parent via parent_device_id.
{
...baseDevice,
id: "power-strip-outlet-1",
name: "Outlet 1",
manufacturer: "Acme",
model: "Smart Power Strip",
config_entries: ["mock-hue"],
primary_config_entry: "mock-hue",
entry_type: null,
parent_device_id: "power-strip",
},
{
...baseDevice,
id: "power-strip-outlet-2",
name: "Outlet 2",
manufacturer: "Acme",
model: "Smart Power Strip",
config_entries: ["mock-hue"],
primary_config_entry: "mock-hue",
entry_type: null,
parent_device_id: "power-strip",
},
];
+5
View File
@@ -102,6 +102,11 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
cost_sensors: {},
solar_forecast_domains: [],
}));
hass.mockWS("energy/validate", () => ({
energy_sources: Array.from({ length: 6 }, () => []),
device_consumption: Array.from({ length: 6 }, () => []),
device_consumption_water: Array.from({ length: 2 }, () => []),
}));
hass.mockWS(
"energy/fossil_energy_consumption",
({ period }): FossilEnergyConsumption => ({
+6
View File
@@ -1,6 +1,12 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockSensor = (hass: MockHomeAssistant) => {
hass.mockWS(
"sensor/device_class_convertible_units",
({ device_class }: { device_class: string }) => ({
units: device_class === "energy" ? ["kWh"] : ["W"],
})
);
hass.mockWS("sensor/numeric_device_classes", () => ({
numeric_device_classes: [
"volume_storage",
+125
View File
@@ -29,6 +29,50 @@ const positions: Position[] = ["start", "end"];
const selectedStates = [false, true];
const disabledStates = [false, true];
interface TreeChild {
key: string;
label: string;
}
interface TreeGroup {
key: string;
label: string;
children: TreeChild[];
}
const treeGroups: TreeGroup[] = [
{
key: "binary_sensor",
label: "Binary sensor",
children: [
{ key: "door", label: "Door" },
{ key: "motion", label: "Motion" },
{ key: "window", label: "Window" },
],
},
{
key: "cover",
label: "Cover",
children: [
{ key: "garage", label: "Garage" },
{ key: "shutter", label: "Shutter" },
],
},
];
interface TreeRow {
group: TreeGroup;
child?: TreeChild;
}
const treeRows: TreeRow[] = treeGroups.flatMap((group) => [
{ group },
...group.children.map((child) => ({ group, child })),
]);
const treeKey = (group: TreeGroup, child: TreeChild) =>
`${group.key}/${child.key}`;
@customElement("demo-components-ha-list")
export class DemoHaList extends LitElement {
@state() private _buttonClicks = 0;
@@ -41,6 +85,8 @@ export class DemoHaList extends LitElement {
@state() private _multiCheckEnd: number | Set<number> = new Set();
@state() private _tree = new Set<string>();
private _options = ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"];
protected render(): TemplateResult {
@@ -274,6 +320,45 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckStart))}</pre>
selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
</ha-card>
<ha-card header="Controlled selection with indeterminate groups">
<ha-list-selectable
multi
controlled
aria-label="Controlled tree"
@ha-list-item-selected=${this._onTreeToggle}
@ha-list-item-deselected=${this._onTreeToggle}
>
${treeGroups.map((group) => {
const groupState = this._groupState(group);
return html`
<ha-list-item-option
appearance="checkbox"
selection-position="end"
.value=${group.key}
?selected=${groupState === "all"}
?indeterminate=${groupState === "some"}
>
<span slot="headline">${group.label}</span>
</ha-list-item-option>
${group.children.map(
(child) => html`
<ha-list-item-option
class="child"
appearance="checkbox"
selection-position="end"
.value=${treeKey(group, child)}
?selected=${this._tree.has(treeKey(group, child))}
>
<span slot="headline">${child.label}</span>
</ha-list-item-option>
`
)}
`;
})}
</ha-list-selectable>
<pre>selected: ${JSON.stringify([...this._tree])}</pre>
</ha-card>
<ha-card header="Option: all combinations">
<div class="grid">
${appearances.map((appearance) =>
@@ -361,6 +446,43 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
return next;
}
private _groupState(group: TreeGroup): "none" | "some" | "all" {
const selected = group.children.filter((child) =>
this._tree.has(treeKey(group, child))
).length;
if (selected === 0) {
return "none";
}
return selected === group.children.length ? "all" : "some";
}
private _onTreeToggle = (ev: CustomEvent<number>) => {
const row = treeRows[ev.detail];
if (!row) {
return;
}
const next = new Set(this._tree);
if (row.child) {
const key = treeKey(row.group, row.child);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
} else {
const select = this._groupState(row.group) !== "all";
row.group.children.forEach((child) => {
const key = treeKey(row.group, child);
if (select) {
next.add(key);
} else {
next.delete(key);
}
});
}
this._tree = next;
};
private _onSingle = (ev: CustomEvent<number>) => {
this._single = ev.detail;
};
@@ -443,6 +565,9 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
.drag-handle {
cursor: grab;
}
.child::part(base) {
padding-inline-start: var(--ha-space-12);
}
`;
}
@@ -1,4 +1,3 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
@@ -9,7 +8,6 @@ import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervis
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import "../../../../src/components/ha-target-picker";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { EntityRegistryDisplayEntry } from "../../../../src/data/entity/entity_registry";
@@ -155,9 +153,6 @@ interface Sample {
description: string;
selector: Selector;
value: unknown;
// Render ha-target-picker directly in compact (chip) mode instead of the
// ha-selector, which does not expose the compact option.
compact?: boolean;
}
const SAMPLES: Sample[] = [
@@ -168,14 +163,6 @@ const SAMPLES: Sample[] = [
selector: { target: {} },
value: { device_id: ["old_composite"] },
},
{
name: "Target (compact)",
description:
"In compact mode the replaced reference is shown as a warning chip.",
selector: { target: {} },
value: { device_id: ["old_composite"] },
compact: true,
},
{
name: "Device (unfiltered, multiple matches)",
description:
@@ -274,23 +261,13 @@ class DemoHaSelectorReplacedDevice
<ha-settings-row narrow slot=${slot}>
<span slot="heading">${sample.name}</span>
<span slot="description">${sample.description}</span>
${
sample.compact
? html`<ha-target-picker
compact
.hass=${this.hass}
.value=${this._values[idx] as HassServiceTarget}
.sampleIdx=${idx}
@value-changed=${this._handleValueChanged}
></ha-target-picker>`
: html`<ha-selector
.hass=${this.hass}
.selector=${sample.selector}
.value=${this._values[idx]}
.sampleIdx=${idx}
@value-changed=${this._handleValueChanged}
></ha-selector>`
}
<ha-selector
.hass=${this.hass}
.selector=${sample.selector}
.value=${this._values[idx]}
.sampleIdx=${idx}
@value-changed=${this._handleValueChanged}
></ha-selector>
</ha-settings-row>
`
)}
@@ -152,6 +152,84 @@ const DEVICES: DeviceRegistryEntry[] = [
primary_config_entry: null,
parent_device_id: null,
},
{
area_id: "livingroom",
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_power_strip",
identifiers: [["demo", "strip1"] as [string, string]],
manufacturer: "Acme",
model: "Smart Power Strip",
model_id: null,
name_by_user: null,
name: "Power strip",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
// Child devices of the power strip. They have no area of their own and
// inherit the parent's area ("Livingroom"); the picker renders them indented
// under the parent with a tree connector.
{
area_id: null,
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_outlet_1",
identifiers: [["demo", "outlet1"] as [string, string]],
manufacturer: "Acme",
model: "Smart Power Strip",
model_id: null,
name_by_user: null,
name: "Outlet 1",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: "device_power_strip",
},
{
area_id: null,
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_outlet_2",
identifiers: [["demo", "outlet2"] as [string, string]],
manufacturer: "Acme",
model: "Smart Power Strip",
model_id: null,
name_by_user: null,
name: "Outlet 2",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: "device_power_strip",
},
];
const AREAS: DemoArea[] = [
+79 -1
View File
@@ -132,12 +132,12 @@ const ENTITIES = [
fan_modes: ["on_low", "on_high", "auto_low", "auto_high", "off"],
preset_modes: ["home", "eco", "away"],
swing_modes: ["auto", "1", "2", "3", "off"],
switch_horizontal_modes: ["auto", "4", "5", "6", "off"],
current_temperature: 23,
target_temp_high: 24,
target_temp_low: 21,
fan_mode: "auto_low",
preset_mode: "home",
swing_horizontal_modes: ["auto", "4", "5", "6", "off"],
swing_mode: "auto",
swing_horizontal_mode: "off",
supported_features:
@@ -340,6 +340,84 @@ const CONFIGS = [
features: [{ type: "fan-oscillate" }],
},
},
{
heading: "Inline features: one feature",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [{ type: "climate-hvac-modes", style: "dropdown" }],
},
},
{
heading: "Inline features: two features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
],
},
},
{
heading: "Inline features: three features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
{ type: "climate-fan-modes", style: "dropdown" },
],
},
},
{
heading: "Inline features: four features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
{ type: "climate-fan-modes", style: "dropdown" },
{ type: "climate-swing-modes", style: "dropdown" },
],
},
},
{
heading: "Inline features: five features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
{ type: "climate-fan-modes", style: "dropdown" },
{ type: "climate-swing-modes", style: "dropdown" },
{ type: "climate-swing-horizontal-modes", style: "dropdown" },
],
},
},
{
heading: "Bottom features: five features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "bottom",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
{ type: "climate-fan-modes", style: "dropdown" },
{ type: "climate-swing-modes", style: "dropdown" },
{ type: "climate-swing-horizontal-modes", style: "dropdown" },
],
},
},
] satisfies DemoCardConfig<TileCardConfig>[];
@customElement("demo-lovelace-tile-card")
+1 -11
View File
@@ -15,7 +15,6 @@ const ALL_FEATURES =
VacuumEntityFeature.STOP +
VacuumEntityFeature.RETURN_HOME +
VacuumEntityFeature.FAN_SPEED +
VacuumEntityFeature.BATTERY +
VacuumEntityFeature.STATUS +
VacuumEntityFeature.LOCATE +
VacuumEntityFeature.CLEAN_SPOT +
@@ -28,8 +27,6 @@ const ENTITIES = [
attributes: {
friendly_name: "Full featured vacuum",
supported_features: ALL_FEATURES,
battery_level: 85,
battery_icon: "mdi:battery-80",
fan_speed: "balanced",
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
status: "Charged",
@@ -41,8 +38,6 @@ const ENTITIES = [
attributes: {
friendly_name: "Cleaning vacuum",
supported_features: ALL_FEATURES,
battery_level: 62,
battery_icon: "mdi:battery-60",
fan_speed: "turbo",
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
status: "Cleaning bedroom",
@@ -58,10 +53,7 @@ const ENTITIES = [
VacuumEntityFeature.START +
VacuumEntityFeature.PAUSE +
VacuumEntityFeature.STOP +
VacuumEntityFeature.RETURN_HOME +
VacuumEntityFeature.BATTERY,
battery_level: 23,
battery_icon: "mdi:battery-20",
VacuumEntityFeature.RETURN_HOME,
status: "Returning to dock",
},
},
@@ -96,8 +88,6 @@ const ENTITIES = [
attributes: {
friendly_name: "Paused vacuum",
supported_features: ALL_FEATURES,
battery_level: 45,
battery_icon: "mdi:battery-40",
fan_speed: "standard",
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
status: "Paused",
+7 -8
View File
@@ -89,7 +89,7 @@
"@vvo/tzdb": "6.198.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.2.1",
"barcode-detector": "3.2.2",
"cally": "0.9.2",
"color-name": "2.1.1",
"comlink": "4.4.2",
@@ -103,11 +103,11 @@
"echarts": "6.1.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.5.0",
"hls.js": "1.6.17",
"hls.js": "1.7.0",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.13",
"js-yaml": "5.2.3",
"js-yaml": "5.3.0",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
@@ -151,8 +151,8 @@
"@octokit/plugin-retry": "8.1.1",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.62.1",
"@rsdoctor/rspack-plugin": "1.6.1",
"@rspack/core": "2.1.8",
"@rsdoctor/rspack-plugin": "1.6.2",
"@rspack/core": "2.1.10",
"@rspack/dev-server": "2.2.0",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -186,7 +186,7 @@
"fs-extra": "11.4.0",
"generate-license-file": "4.2.1",
"glob": "13.0.6",
"globals": "17.9.0",
"globals": "17.11.0",
"gulp": "5.0.1",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0",
@@ -210,7 +210,7 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.66.0",
"typescript-eslint": "8.67.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.10",
"webpack-stats-plugin": "1.1.3",
@@ -223,7 +223,6 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.21",
"globals": "17.9.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
},
@@ -0,0 +1,217 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import { css, type LitElement } from "lit";
import type { Ref } from "lit/directives/ref";
import { parseAnimationDuration } from "../util/parse-animation-duration";
type FilterPanelHost = ReactiveControllerHost &
LitElement & { expanded: boolean };
const EASING = "cubic-bezier(0.4, 0, 0.2, 1)";
/**
* Layout the controller relies on: the filter is a flex column made of its
* header (`ha-expansion-panel`) and a `.content` wrapper. Collapsed, it is as
* tall as its header; expanded, it fills what is left of the pane and hands
* that space down to the list through the wrapper.
*/
export const filterPanelStyles = css`
:host {
display: flex;
flex-direction: column;
box-sizing: border-box;
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
flex: none;
--ha-card-border-radius: var(--ha-border-radius-square);
}
ha-expansion-panel::part(summary) {
-webkit-user-select: none;
user-select: none;
}
.content {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
`;
const panels = new Set<FilterPanelController>();
let pending: Set<FilterPanelController> | undefined;
const flush = () => {
const batch = [...pending!].filter((panel) => panel.host.isConnected);
pending = undefined;
batch.forEach((panel) => panel.prepare());
batch.forEach((panel) => panel.measure());
batch.forEach((panel) => panel.play());
};
// Filters that change in the same frame (one closing while another opens) are
// animated as one batch: every filter is measured before any of them has
// changed the DOM, and the closing ones are parked at their final height so
// the opening one reads its own final height from the real layout.
const enqueue = (panel: FilterPanelController) => {
if (!pending) {
pending = new Set();
panels.forEach((other) => other.snapshot());
requestAnimationFrame(flush);
}
pending.add(panel);
};
const clearInlineStyles = (element?: HTMLElement) => {
element?.style.removeProperty("height");
element?.style.removeProperty("flex");
element?.style.removeProperty("overflow");
};
/**
* Animates a filter of the filter pane between its collapsed and expanded
* heights whenever `expanded` changes, and tells the host when to render its
* content: from the moment it expands until its collapse animation has ended.
*
* During the animation the content keeps its final size and the host clips
* it, so the list is revealed rather than resized.
*/
export class FilterPanelController implements ReactiveController {
public showContent = false;
public host: FilterPanelHost;
private _content: Ref<HTMLElement>;
private _expanded?: boolean;
private _first = 0;
private _last = 0;
private _contentHeight = 0;
private _animation?: Animation;
constructor(host: FilterPanelHost, content: Ref<HTMLElement>) {
this.host = host;
this._content = content;
host.addController(this);
}
public hostConnected() {
panels.add(this);
}
public hostDisconnected() {
panels.delete(this);
this._animation?.cancel();
this._animation = undefined;
clearInlineStyles(this.host);
clearInlineStyles(this._content.value);
}
public hostUpdate() {
const expanded = this.host.expanded;
if (this._expanded === undefined) {
this._expanded = expanded;
this.showContent = expanded;
return;
}
if (expanded === this._expanded) {
return;
}
this._expanded = expanded;
if (!this.host.isConnected) {
this.showContent = expanded;
return;
}
if (expanded) {
this.showContent = true;
}
enqueue(this);
}
public snapshot() {
this._first = this.host.getBoundingClientRect().height;
}
public prepare() {
this._animation?.cancel();
this._animation = undefined;
const host = this.host;
const content = this._content.value;
clearInlineStyles(host);
clearInlineStyles(content);
if (host.expanded) {
return;
}
this._last =
host.getBoundingClientRect().height -
(content?.getBoundingClientRect().height ?? 0);
this._contentHeight = this._first - this._last;
host.style.flex = "none";
host.style.height = `${this._last}px`;
}
public measure() {
if (!this.host.expanded) {
return;
}
this._last = this.host.getBoundingClientRect().height;
this._contentHeight =
this._content.value?.getBoundingClientRect().height ?? 0;
}
public play() {
const host = this.host;
if (this._first === this._last) {
this._finish();
return;
}
const content = this._content.value;
host.style.flex = "none";
host.style.overflow = "hidden";
if (content) {
content.style.flex = "none";
content.style.height = `${this._contentHeight}px`;
}
const animation = host.animate(
[{ height: `${this._first}px` }, { height: `${this._last}px` }],
{
duration:
parseAnimationDuration(
getComputedStyle(host).getPropertyValue(
"--ha-animation-duration-normal"
)
) || 250,
easing: EASING,
fill: "forwards",
}
);
animation.onfinish = () => this._finish(animation);
this._animation = animation;
}
private async _finish(animation?: Animation) {
if (!this.host.expanded) {
this.showContent = false;
this.host.requestUpdate();
await this.host.updateComplete;
}
if (this._animation !== animation) {
return;
}
clearInlineStyles(this.host);
clearInlineStyles(this._content.value);
this._animation?.cancel();
this._animation = undefined;
}
}
+19
View File
@@ -39,6 +39,25 @@ const formatTimeWithSecondsMem = memoizeOne(
})
);
// 9:15:24.123 PM || 21:15:24,123
export const formatTimeWithMilliseconds = (
dateObj: Date,
locale: FrontendLocaleData,
config: HassConfig
) => formatTimeWithMillisecondsMem(locale, config.time_zone).format(dateObj);
const formatTimeWithMillisecondsMem = memoizeOne(
(locale: FrontendLocaleData, serverTimeZone: string) =>
new Intl.DateTimeFormat(locale.language, {
hour: useAmPm(locale) ? "numeric" : "2-digit",
minute: "2-digit",
second: "2-digit",
fractionalSecondDigits: 3,
hourCycle: useAmPm(locale) ? "h12" : "h23",
timeZone: resolveTimeZone(locale.time_zone, serverTimeZone),
})
);
// Tuesday 7:00 PM || Tuesday 19:00
export const formatTimeWeekday = (
dateObj: Date,
+1
View File
@@ -25,6 +25,7 @@ export type LocalizeKeys =
| `ui.dialogs.unsupported.reasons.${string}`
| `ui.panel.config.${string}.${"caption" | "description"}`
| `ui.panel.config.dashboard.${string}`
| `ui.panel.config.mqtt.${string}`
| `ui.panel.config.storage.segments.${string}`
| `ui.panel.config.zha.${string}`
| `ui.panel.config.zwave_js.${string}`
@@ -1,4 +1,5 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { deepEqual } from "../util/deep-equal";
import {
createQueryString,
decodeQueryParams,
@@ -35,6 +36,15 @@ export const historyLogbookTargetFromQueryParams = (
): HassServiceTarget | undefined =>
serviceTargetFromQueryParams(params, historyLogbookTargetParamKeys);
export const historyLogbookTargetsEqual = (
a: HassServiceTarget,
b: HassServiceTarget
): boolean =>
deepEqual(
queryParamsFromServiceTarget(a, historyLogbookTargetParamKeys),
queryParamsFromServiceTarget(b, historyLogbookTargetParamKeys)
);
export const createHistoryLogbookUrl = (
path: string,
target: HassServiceTarget,
+64
View File
@@ -0,0 +1,64 @@
import {
isMoreInfoView,
type MoreInfoView,
} from "../../dialogs/more-info/more-info-view";
import type { SearchParamsSource } from "./query-params";
const ENTITY_ID_PARAM = "more-info-entity-id";
const VIEW_PARAM = "more-info-view";
export interface MoreInfoUrlData {
entityId?: string;
view?: MoreInfoView;
hash: URLSearchParams;
}
export interface CreateMoreInfoUrlData {
entityId: string;
view: MoreInfoView;
hash?: URLSearchParams;
}
export const decodeMoreInfoUrl = (
search: SearchParamsSource,
hash = ""
): MoreInfoUrlData => {
const params =
typeof search === "string"
? new URLSearchParams(search)
: search instanceof URLSearchParams
? search
: new URLSearchParams(search);
const entityId = params.get(ENTITY_ID_PARAM) || undefined;
const view = params.get(VIEW_PARAM) || undefined;
return {
entityId,
view: isMoreInfoView(view) ? view : undefined,
hash: new URLSearchParams(
__DEMO__ ? "" : hash.startsWith("#") ? hash.substring(1) : hash
),
};
};
export const createMoreInfoUrl = (
base: string,
data: CreateMoreInfoUrlData
): string => {
const url = new URL(base, window.location.origin);
url.searchParams.set(ENTITY_ID_PARAM, data.entityId);
url.searchParams.set(VIEW_PARAM, data.view);
if (!__DEMO__ && data.hash !== undefined) {
url.hash = data.hash.toString();
}
return `${url.pathname}${url.search}${url.hash}`;
};
export const removeMoreInfoUrl = (base: string): string => {
const url = new URL(base, window.location.origin);
url.searchParams.delete(ENTITY_ID_PARAM);
url.searchParams.delete(VIEW_PARAM);
return `${url.pathname}${url.search}${url.hash}`;
};
@@ -1,3 +1,4 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -22,6 +23,10 @@ import { hex2rgb } from "../../common/color/convert-color";
import { measureTextWidth } from "../../util/text";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
const ROW_HEIGHT = 30;
const ROW_HEIGHT_INSIDE_LABELS = 64;
const GRID_BOTTOM = 30;
@customElement("state-history-chart-timeline")
export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -38,6 +43,10 @@ export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
/** Draw each row's name above its bar instead of in a label column. */
@property({ attribute: "inside-labels", type: Boolean })
public insideLabels = false;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@@ -60,6 +69,13 @@ export class StateHistoryChartTimeline extends LitElement {
@state() private _yWidth = 0;
private _width = 0;
private _resize = new ResizeController(this, {
skipInitial: true,
callback: (entries) => entries[0]?.contentRect.width,
});
private _chartTime: Date = new Date();
protected render() {
@@ -67,7 +83,7 @@ export class StateHistoryChartTimeline extends LitElement {
<ha-chart-base
.hass=${this.hass}
.options=${this._chartOptions}
.height=${`${this.data.length * 30 + 30}px`}
.height=${`${this.data.length * (this.insideLabels ? ROW_HEIGHT_INSIDE_LABELS : ROW_HEIGHT) + GRID_BOTTOM}px`}
.data=${this._chartData as HaECSeries}
small-controls
@chart-click=${this._handleChartClick}
@@ -177,13 +193,19 @@ export class StateHistoryChartTimeline extends LitElement {
this._generateData();
}
const width = this.insideLabels ? Math.round(this._resize.value ?? 0) : 0;
const widthChanged = width !== this._width;
this._width = width;
if (
!this.hasUpdated ||
changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("showNames") ||
changedProps.has("insideLabels") ||
changedProps.has("paddingYAxis") ||
changedProps.has("_yWidth")
changedProps.has("_yWidth") ||
widthChanged
) {
this._createOptions();
}
@@ -193,14 +215,22 @@ export class StateHistoryChartTimeline extends LitElement {
const narrow = this.narrow;
const showNames = this.chunked || this.showNames;
const maxInternalLabelWidth = narrow ? 105 : 185;
const labelWidth = showNames
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const insideLabels = this.insideLabels;
const labelWidth =
showNames && !insideLabels
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const labelMargin = 5;
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
// Keeps the plot aligned with the line charts sharing the y-axis padding.
const plotPadding = insideLabels ? this.paddingYAxis : labelWidth;
// A zero width hides the labels instead of truncating them.
const insideLabelWidth = this._width
? Math.max(0, this._width - plotPadding - labelMargin)
: undefined;
this._chartOptions = {
xAxis: {
type: "time",
@@ -224,37 +254,52 @@ export class StateHistoryChartTimeline extends LitElement {
axisLine: {
show: false,
},
axisLabel: {
show: showNames,
width: labelWidth,
overflow: "truncate",
margin: labelMargin,
formatter: (id: string) => {
const label = this._chartData.find((d) => d.id === id)
?.name as string;
const width = label
? Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
)
: 0;
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
axisLabel: insideLabels
? {
show: showNames,
inside: true,
margin: 0,
padding: [0, rtl ? 2 : 0, 14, rtl ? 0 : 2],
align: rtl ? "right" : "left",
verticalAlign: "bottom",
width: insideLabelWidth,
overflow: "truncate",
formatter: (id: string) =>
(this._chartData.find((d) => d.id === id)?.name as string) ??
"",
hideOverlap: true,
}
return label;
},
hideOverlap: true,
},
: {
show: showNames,
width: labelWidth,
overflow: "truncate",
margin: labelMargin,
formatter: (id: string) => {
const label = this._chartData.find((d) => d.id === id)
?.name as string;
const width = label
? Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
)
: 0;
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
}
return label;
},
hideOverlap: true,
},
},
grid: {
top: 10,
bottom: 30,
left: rtl ? 1 : labelWidth,
right: rtl ? labelWidth : 1,
top: insideLabels ? 20 : 10,
bottom: GRID_BOTTOM,
left: rtl ? 1 : plotPadding,
right: rtl ? plotPadding : 1,
},
tooltip: {
renderMode: "html",
@@ -398,6 +443,10 @@ export class StateHistoryChartTimeline extends LitElement {
}
static styles = css`
:host {
display: block;
}
ha-chart-base {
--chart-max-height: none;
}
@@ -79,6 +79,10 @@ export class StateHistoryCharts extends LitElement {
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
/** Draw timeline row names above their bar instead of in a label column. */
@property({ attribute: "inside-labels", type: Boolean, reflect: true })
public insideLabels = false;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@@ -227,6 +231,7 @@ export class StateHistoryCharts extends LitElement {
.startTime=${this._computedStartTime}
.endTime=${this._computedEndTime}
.showNames=${this.showNames}
.insideLabels=${this.insideLabels}
.names=${this.names}
.narrow=${this.narrow}
.chunked=${this.virtualize}
@@ -424,6 +429,12 @@ export class StateHistoryCharts extends LitElement {
padding-top: 8px;
}
/* Names inside the plot sit close to the chart above them, so the groups
need more room between them to stay apart. */
:host([inside-labels]) .entry-container.timeline:not(:first-child) {
margin-top: var(--ha-space-8);
}
.entry-container:hover {
z-index: 1;
}
+17 -15
View File
@@ -117,7 +117,7 @@ export class HaDataTable extends LitElement {
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ type: Object }) public columns: DataTableColumnContainer = {};
@@ -1158,6 +1158,11 @@ export class HaDataTable extends LitElement {
/* default mdc styles, colors changed, without checkbox styles */
:host {
height: 100%;
--_cell-padding-inline: 16px;
}
:host([narrow]) {
--_cell-padding-inline: 8px;
}
.mdc-data-table__content {
font-family: var(--ha-font-family-body);
@@ -1238,8 +1243,7 @@ export class HaDataTable extends LitElement {
.mdc-data-table__cell,
.mdc-data-table__header-cell {
padding-right: 16px;
padding-left: 16px;
padding-inline: var(--_cell-padding-inline);
min-width: 150px;
align-self: center;
overflow: hidden;
@@ -1259,14 +1263,8 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell--checkbox,
.mdc-data-table__cell--checkbox {
/* @noflip */
padding-left: 16px;
/* @noflip */
padding-right: 0;
/* @noflip */
padding-inline-start: 16px;
/* @noflip */
padding-inline-end: initial;
padding-inline-start: var(--_cell-padding-inline);
padding-inline-end: 0;
width: 60px;
min-width: 60px;
}
@@ -1379,8 +1377,7 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell--overflow-menu:first-child,
.mdc-data-table__header-cell--icon-button:first-child,
.mdc-data-table__cell--icon-button:first-child {
padding-left: 16px;
padding-inline-start: 16px;
padding-inline-start: var(--_cell-padding-inline);
padding-inline-end: initial;
}
@@ -1388,8 +1385,7 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell--overflow-menu:last-child,
.mdc-data-table__header-cell--icon-button:last-child,
.mdc-data-table__cell--icon-button:last-child {
padding-right: 16px;
padding-inline-end: 16px;
padding-inline-end: var(--_cell-padding-inline);
padding-inline-start: initial;
}
.mdc-data-table__cell--overflow-menu,
@@ -1516,11 +1512,17 @@ export class HaDataTable extends LitElement {
.center {
text-align: center;
}
.primary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.secondary {
color: var(--secondary-text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.scroller {
height: calc(100% - 57px);
@@ -0,0 +1,79 @@
import { mdiCalendar } from "@mdi/js";
import { css, html } from "lit";
import { customElement } from "lit/decorators";
import "../chips/ha-assist-chip";
import "../ha-icon-button-next";
import "../ha-icon-button-prev";
import "../ha-svg-icon";
import {
haDateRangePickerStyles,
HaDateRangePicker,
} from "./ha-date-range-picker";
/**
* Date range picker as a single pill that also steps through ranges: a
* previous button, the selected range and a next button. Meant for a toolbar,
* next to other chips.
*/
@customElement("ha-date-range-nav")
export class HaDateRangeNav extends HaDateRangePicker {
protected override _renderField() {
return html`
<ha-icon-button-prev
class="step"
.label=${this._i18n.localize("ui.common.previous")}
.disabled=${this.disabled}
@click=${this._handlePrev}
></ha-icon-button-prev>
<ha-assist-chip
id="field"
class="range"
.label=${this._formatRange(" ")}
.disabled=${this.disabled}
@click=${this._openPicker}
>
<ha-svg-icon slot="icon" .path=${mdiCalendar}></ha-svg-icon>
</ha-assist-chip>
<ha-icon-button-next
class="step"
.label=${this._i18n.localize("ui.common.next")}
.disabled=${this.disabled}
@click=${this._handleNext}
></ha-icon-button-next>
`;
}
static override styles = [
haDateRangePickerStyles,
css`
/* The three controls read as one pill, with the range chip's borders as
the dividers between them. */
.date-range-inputs {
gap: 0;
border: 1px solid var(--outline-color);
border-radius: var(--ha-assist-chip-container-shape, 10px);
background: var(--ha-assist-chip-container-color, transparent);
overflow: hidden;
width: fit-content;
}
.step {
--ha-icon-button-size: 32px;
--mdc-icon-size: 20px;
}
.range {
--md-assist-chip-outline-color: transparent;
--ha-assist-chip-container-shape: 0;
--ha-assist-chip-container-color: transparent;
border-inline: 1px solid var(--divider-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-date-range-nav": HaDateRangeNav;
}
}
+112 -128
View File
@@ -2,7 +2,6 @@ import "@home-assistant/webawesome/dist/components/popover/popover";
import { consume, type ContextType } from "@lit/context";
import { mdiCalendar } from "@mdi/js";
import "cally";
import { isThisYear } from "date-fns";
import type { HassConfig } from "home-assistant-js-websocket/dist/types";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -11,10 +10,7 @@ import { tinykeys } from "tinykeys";
import { shiftDateRange } from "../../common/datetime/calc_date";
import type { DateRange } from "../../common/datetime/calc_date_range";
import { calcDateRange } from "../../common/datetime/calc_date_range";
import {
formatShortDateTime,
formatShortDateTimeWithYear,
} from "../../common/datetime/format_date_time";
import { formatShortDateTimeWithConditionalYear } from "../../common/datetime/format_date_time";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import { configContext, internationalizationContext } from "../../data/context";
@@ -42,18 +38,67 @@ const EXTENDED_RANGE_KEYS: DateRange[] = [
"now-30d",
];
export const haDateRangePickerStyles = css`
ha-icon-button {
direction: var(--direction);
}
.date-range-inputs {
display: flex;
align-items: center;
gap: var(--ha-space-2);
}
ha-textarea {
display: inline-block;
width: 340px;
}
@media only screen and (max-width: 460px) {
ha-textarea {
width: 100%;
}
}
wa-popover {
--wa-space-l: 0;
}
wa-popover::part(dialog)::backdrop {
opacity: 0;
transition: opacity var(--ha-animation-duration-normal) ease-out;
}
wa-popover.open::part(dialog)::backdrop {
opacity: 1;
}
:host(:not([backdrop])) wa-popover::part(dialog)::backdrop {
background: none;
}
wa-popover::part(body) {
min-width: max(var(--body-width), 250px);
max-width: calc(
100vw - var(--safe-area-inset-left) - var(--safe-area-inset-right) - var(
--ha-space-8
)
);
overflow: hidden;
}
`;
@customElement("ha-date-range-picker")
export class HaDateRangePicker extends LitElement {
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
protected _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
private _hassConfig!: HassConfig;
protected _hassConfig!: HassConfig;
@property({ attribute: false }) public startDate!: Date;
@@ -143,73 +188,7 @@ export class HaDateRangePicker extends LitElement {
protected render(): TemplateResult {
return html`
<div class="container">
<div class="date-range-inputs">
${
!this.minimal
? html`<ha-textarea
id="field"
rows="1"
resize="auto"
@click=${this._openPicker}
@keydown=${this._handleKeydown}
.value=${
(isThisYear(this.startDate)
? formatShortDateTime(
this.startDate,
this._i18n.locale,
this._hassConfig
)
: formatShortDateTimeWithYear(
this.startDate,
this._i18n.locale,
this._hassConfig
)) +
(window.innerWidth >= 459 ? " - " : " - \n") +
(isThisYear(this.endDate)
? formatShortDateTime(
this.endDate,
this._i18n.locale,
this._hassConfig
)
: formatShortDateTimeWithYear(
this.endDate,
this._i18n.locale,
this._hassConfig
))
}
.label=${
this._i18n.localize(
"ui.components.date-range-picker.start_date"
) +
" - " +
this._i18n.localize(
"ui.components.date-range-picker.end_date"
)
}
.disabled=${this.disabled}
readonly
></ha-textarea>
<ha-icon-button-prev
.label=${this._i18n.localize("ui.common.previous")}
@click=${this._handlePrev}
>
</ha-icon-button-prev>
<ha-icon-button-next
.label=${this._i18n.localize("ui.common.next")}
@click=${this._handleNext}
>
</ha-icon-button-next>`
: html`<ha-icon-button
@click=${this._openPicker}
.disabled=${this.disabled}
id="field"
.label=${this._i18n.localize(
"ui.components.date-range-picker.select_date_range"
)}
.path=${mdiCalendar}
></ha-icon-button>`
}
</div>
<div class="date-range-inputs">${this._renderField()}</div>
${
this._pickerWrapperOpen || this._opened
? this._openedNarrow
@@ -248,6 +227,60 @@ export class HaDateRangePicker extends LitElement {
`;
}
/**
* The control that opens the picker. It has to carry `id="field"`, which the
* popover anchors to.
*/
protected _renderField() {
if (this.minimal) {
return html`<ha-icon-button
@click=${this._openPicker}
.disabled=${this.disabled}
id="field"
.label=${this._i18n.localize(
"ui.components.date-range-picker.select_date_range"
)}
.path=${mdiCalendar}
></ha-icon-button>`;
}
return html`<ha-textarea
id="field"
rows="1"
resize="auto"
@click=${this._openPicker}
@keydown=${this._handleKeydown}
.value=${this._formatRange(window.innerWidth >= 459 ? " - " : " - \n")}
.label=${
this._i18n.localize("ui.components.date-range-picker.start_date") +
" - " +
this._i18n.localize("ui.components.date-range-picker.end_date")
}
.disabled=${this.disabled}
readonly
></ha-textarea>
<ha-icon-button-prev
.label=${this._i18n.localize("ui.common.previous")}
@click=${this._handlePrev}
>
</ha-icon-button-prev>
<ha-icon-button-next
.label=${this._i18n.localize("ui.common.next")}
@click=${this._handleNext}
>
</ha-icon-button-next>`;
}
protected _formatRange(separator: string): string {
const format = (date: Date) =>
formatShortDateTimeWithConditionalYear(
date,
this._i18n.locale,
this._hassConfig
);
return format(this.startDate) + separator + format(this.endDate);
}
private _renderPicker() {
if (!this._opened) {
return nothing;
@@ -303,12 +336,12 @@ export class HaDateRangePicker extends LitElement {
this._opened = false;
};
private _handleNext(ev: MouseEvent): void {
protected _handleNext(ev: MouseEvent): void {
if (ev && ev.stopPropagation) ev.stopPropagation();
this._shift(true);
}
private _handlePrev(ev: MouseEvent): void {
protected _handlePrev(ev: MouseEvent): void {
if (ev && ev.stopPropagation) ev.stopPropagation();
this._shift(false);
}
@@ -336,7 +369,7 @@ export class HaDateRangePicker extends LitElement {
this._pickerWrapperOpen = false;
}
private _openPicker(ev?: Event) {
protected _openPicker(ev?: Event) {
if (this.disabled) {
return;
}
@@ -352,7 +385,7 @@ export class HaDateRangePicker extends LitElement {
});
}
private _handleKeydown(ev: KeyboardEvent) {
protected _handleKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.stopPropagation();
this._openPicker(ev);
@@ -369,56 +402,7 @@ export class HaDateRangePicker extends LitElement {
}
}
static styles = [
css`
ha-icon-button {
direction: var(--direction);
}
.date-range-inputs {
display: flex;
align-items: center;
gap: var(--ha-space-2);
}
ha-textarea {
display: inline-block;
width: 340px;
}
@media only screen and (max-width: 460px) {
ha-textarea {
width: 100%;
}
}
wa-popover {
--wa-space-l: 0;
}
wa-popover::part(dialog)::backdrop {
opacity: 0;
transition: opacity var(--ha-animation-duration-normal) ease-out;
}
wa-popover.open::part(dialog)::backdrop {
opacity: 1;
}
:host(:not([backdrop])) wa-popover::part(dialog)::backdrop {
background: none;
}
wa-popover::part(body) {
min-width: max(var(--body-width), 250px);
max-width: calc(
100vw - var(--safe-area-inset-left) - var(
--safe-area-inset-right
) - var(--ha-space-8)
);
overflow: hidden;
}
`,
];
static styles = [haDateRangePickerStyles];
}
declare global {
+1
View File
@@ -8,6 +8,7 @@ export const datePickerStyles = css`
}
calendar-date::part(button),
calendar-range::part(button) {
color: var(--primary-text-color);
border: none;
background-color: unset;
border-radius: var(--ha-border-radius-circle);
+103 -38
View File
@@ -3,11 +3,13 @@ import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { getDeviceArea } from "../../common/entity/context/get_device_context";
import { computeRTL } from "../../common/util/compute_rtl";
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
import {
deviceComboBoxKeys,
@@ -26,7 +28,9 @@ import "../ha-alert";
import "../ha-button";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import type { PickerComboBoxSearchFn } from "../ha-picker-combo-box";
import "../ha-svg-icon";
import "../ha-tree-indicator";
import { showDeviceReplacedDialog } from "./show-dialog-device-replaced";
export type HaDevicePickerDeviceFilterFunc = (
@@ -128,6 +132,7 @@ export class HaDevicePicker extends LitElement {
entityFilter,
excludeDevices,
value,
nested: true,
})
);
@@ -216,6 +221,34 @@ export class HaDevicePicker extends LitElement {
this.value
);
// The fuzzy search ranks matches by relevance, which would pull a child device
// above its parent (the parent often only matches through the lower-weighted
// child names). Restore the nested order from the full item list and recompute
// which child is last, so the tree connectors stay correct while searching.
private _searchFn: PickerComboBoxSearchFn<DevicePickerItem> = (
_search,
filteredItems,
allItems
) => {
const matchedIds = new Set(filteredItems.map((item) => item.id));
const ordered = allItems.filter((item) => matchedIds.has(item.id));
// Keep any items the search added that are not part of the nested list
// (for example the "no items available" placeholder or additional items).
const orderedIds = new Set(ordered.map((item) => item.id));
const extras = filteredItems.filter((item) => !orderedIds.has(item.id));
return [
...ordered.map((item, index) => {
if (!item.is_child) {
return item;
}
const nextItem = ordered[index + 1];
return { ...item, last: !nextItem || !nextItem.is_child };
}),
...extras,
];
};
private _valueRenderer = memoizeOne(
(
configEntriesLookup: Record<string, ConfigEntry>,
@@ -279,46 +312,76 @@ export class HaDevicePicker extends LitElement {
}
);
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => html`
<ha-combo-box-item type="button">
${
item.domain
? html`
<img
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => {
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
return html`
<ha-combo-box-item
type="button"
style=${
item.is_child
? "--md-list-item-leading-space: var(--ha-space-12);"
: ""
}
>
${
item.is_child
? html`<ha-tree-indicator
style=${styleMap({
width: "var(--ha-space-12)",
position: "absolute",
top: "0",
height: "100%",
left: rtl ? undefined : "var(--ha-space-1)",
right: rtl ? "var(--ha-space-1)" : undefined,
transform: rtl ? "scaleX(-1)" : "",
})}
.end=${item.last}
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: item.domain,
type: "icon",
darkOptimized: this.hass.themes.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>
`
: nothing
}
></ha-tree-indicator>`
: nothing
}
${
item.domain
? html`
<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: item.domain,
type: "icon",
darkOptimized: this.hass.themes.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>
`
: nothing
}
<span slot="headline">${item.primary}</span>
${
item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing
}
${
item.domain_name
? html`
<div slot="trailing-supporting-text" class="domain">
${item.domain_name}
</div>
`
: nothing
}
</ha-combo-box-item>
`;
<span slot="headline">${item.primary}</span>
${
item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing
}
${
item.domain_name
? html`
<div slot="trailing-supporting-text" class="domain">
${item.domain_name}
</div>
`
: nothing
}
</ha-combo-box-item>
`;
};
protected render() {
const placeholder =
@@ -375,6 +438,8 @@ export class HaDevicePicker extends LitElement {
.value=${this.value}
.rowRenderer=${this._rowRenderer}
.getItems=${this._getItems}
.searchFn=${this._searchFn}
no-sort
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${valueRenderer}
.searchKeys=${deviceComboBoxKeys}
+3
View File
@@ -34,6 +34,8 @@ export class HaButtonToggleGroup extends LitElement {
@property({ type: Boolean, reflect: true, attribute: "full-width" })
public fullWidth = false;
@property({ type: Boolean }) public disabled = false;
@property() public variant:
"brand" | "neutral" | "success" | "warning" | "danger" = "brand";
@@ -57,6 +59,7 @@ export class HaButtonToggleGroup extends LitElement {
.value=${button.value}
@click=${this._handleClick}
.title=${button.label}
.disabled=${this.disabled}
.appearance=${this.active === button.value ? "accent" : "filled"}
>
${
-172
View File
@@ -1,172 +0,0 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type {
LocalizeFunc,
LocalizeKeys,
} from "../common/translations/localize";
import type { YamlFieldSchema } from "../resources/yaml_field_schema";
/**
* Tooltip element rendered inside a CodeMirror hoverTooltip for YAML field
* keys in the automation / script / card YAML editors.
*
* Shows:
* - Field name (monospace)
* - "required" badge when applicable
* - Description paragraph
* - Selector type hint
* - Example value
* - Default value
*/
@customElement("ha-code-editor-yaml-hover")
export class HaCodeEditorYamlHover extends LitElement {
@property({ attribute: false }) public fieldName = "";
@property({ attribute: false }) public fieldSchema!: YamlFieldSchema;
/**
* Optional localize callback forwarded from the editor so translated
* descriptions can be rendered. When absent, strings are shown verbatim.
*/
@property({ attribute: false }) public localize?: LocalizeFunc;
render() {
const schema = this.fieldSchema;
if (!schema) return nothing;
// Built-in field descriptions are translation keys; descriptions coming
// from the backend are already translated and fall through unchanged.
const description = schema.description
? this.localize?.(schema.description as LocalizeKeys) ||
schema.description
: undefined;
const selectorType = schema.selector
? Object.keys(schema.selector)[0]
: undefined;
return html`
<div class="header">
<code class="key">${this.fieldName}</code>
${
schema.required
? html`<span class="badge required"
>${this._label("required", "required")}</span
>`
: nothing
}
${
selectorType
? html`<span class="badge type">${selectorType}</span>`
: nothing
}
</div>
${description ? html`<div class="desc">${description}</div>` : nothing}
${
schema.example != null
? html`<div class="meta">
<span class="meta-label"
>${this._label("example", "Example:")}</span
>
<code>${String(schema.example)}</code>
</div>`
: nothing
}
${
schema.default != null
? html`<div class="meta">
<span class="meta-label"
>${this._label("default", "Default:")}</span
>
<code>${String(schema.default)}</code>
</div>`
: nothing
}
`;
}
private _label(
key: "required" | "example" | "default",
fallback: string
): string {
return (
this.localize?.(`ui.components.yaml-editor.schema.${key}`) || fallback
);
}
static styles = css`
:host {
display: block;
padding: 6px 10px;
max-width: 320px;
line-height: 1.5;
font-size: 0.9em;
}
.header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
flex-wrap: wrap;
}
code.key {
font-family: var(--ha-font-family-code);
font-size: 1em;
font-weight: bold;
}
.badge {
display: inline-block;
border-radius: 4px;
padding: 0 5px;
font-size: 0.78em;
line-height: 1.6;
font-family: var(--ha-font-family-body);
}
.badge.required {
background: color-mix(
in srgb,
var(--error-color, #db4437) 15%,
transparent
);
color: var(--error-color, #db4437);
}
.badge.type {
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
color: var(--primary-text-color);
}
.desc {
color: var(--secondary-text-color);
margin-bottom: 4px;
}
.meta {
display: flex;
gap: 6px;
align-items: baseline;
color: var(--secondary-text-color);
}
.meta-label {
font-size: 0.85em;
opacity: 0.75;
flex-shrink: 0;
}
.meta code {
font-family: var(--ha-font-family-code);
font-size: 0.9em;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-code-editor-yaml-hover": HaCodeEditorYamlHover;
}
}
+123 -147
View File
@@ -32,21 +32,16 @@ import { consume } from "@lit/context";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { getEntityContext } from "../common/entity/context/get_entity_context";
import { computeDeviceName } from "../common/entity/compute_device_name";
import { computeEntityName } from "../common/entity/compute_entity_name";
import { computeAreaName } from "../common/entity/compute_area_name";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { copyToClipboard } from "../common/util/copy-clipboard";
import { haStyleScrollbar } from "../resources/styles";
import {
buildEntityCompletions,
buildDeviceCompletions,
buildAreaCompletions,
buildFloorCompletions,
buildLabelCompletions,
} from "../resources/ha_completion_items";
import type {
JinjaArgType,
HassArgHoverContext,
} from "../resources/jinja_ha_completions";
import type { YamlFieldSchemaMap } from "../resources/yaml_field_schema";
import "./ha-code-editor-yaml-hover";
import type { HomeAssistant } from "../types";
import { showToast } from "../util/toast";
import { documentationUrl } from "../util/documentation-url";
@@ -95,14 +90,6 @@ export class HaCodeEditor extends ReactiveElement {
@property() public mode = "yaml";
/**
* Optional field schema for YAML mode. When set, the editor will provide
* field-aware key/value completions, hover tooltips, and linting for the
* known fields described by this map.
*/
@property({ attribute: false })
public yamlFieldSchema?: YamlFieldSchemaMap;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@@ -182,12 +169,6 @@ export class HaCodeEditor extends ReactiveElement {
private _completionInfoDestroy?: () => void;
// Stored YAML syntax error set by setYamlError(); consumed by _yamlSyntaxLinter.
private _yamlSyntaxError: {
mark?: { position: number; line: number; column: number };
reason?: string;
} | null = null;
private _completionInfoRequest = 0;
private _completionInfoKey?: string;
@@ -222,10 +203,6 @@ export class HaCodeEditor extends ReactiveElement {
* Push a YAML parse error (or null to clear) into the lint gutter as a
* diagnostic. Avoids re-parsing the document — the caller (ha-yaml-editor)
* already has the error from its own js-yaml load() call.
*
* Stores the error and triggers forceLinting() so the yamlLintCompartment
* linter re-runs and returns it as a diagnostic — rather than calling
* setDiagnostics() which would wipe diagnostics from other linters.
*/
public setYamlError(
err: {
@@ -233,10 +210,27 @@ export class HaCodeEditor extends ReactiveElement {
reason?: string;
} | null
): void {
this._yamlSyntaxError = err;
if (this.codemirror && this._loadedCodeMirror) {
this._loadedCodeMirror.forceLinting(this.codemirror);
if (!this.codemirror || !this._loadedCodeMirror) return;
let diagnostics: {
from: number;
to: number;
severity: "error";
message: string;
}[] = [];
if (err) {
const doc = this.codemirror.state.doc;
const pos = err.mark ? Math.min(err.mark.position, doc.length) : 0;
const line = doc.lineAt(pos);
const message = `${
err.reason ||
this._i18n?.localize("ui.components.yaml-editor.error") ||
"YAML syntax error"
}${err.mark ? ` (${this._i18n?.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
diagnostics = [{ from: pos, to: line.to, severity: "error", message }];
}
this.codemirror.dispatch(
this._loadedCodeMirror.setDiagnostics(this.codemirror.state, diagnostics)
);
}
public connectedCallback() {
@@ -297,7 +291,9 @@ export class HaCodeEditor extends ReactiveElement {
effects: [
this._loadedCodeMirror!.langCompartment!.reconfigure(this._mode),
this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
this._buildYamlSyntaxLinter()
this.lint && !this.readOnly
? [this._loadedCodeMirror!.lintGutter()]
: []
),
],
});
@@ -309,23 +305,20 @@ export class HaCodeEditor extends ReactiveElement {
this._loadedCodeMirror!.EditorView!.editable.of(!this.readOnly)
),
this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
this._buildYamlSyntaxLinter()
this.lint && !this.readOnly
? [this._loadedCodeMirror!.lintGutter()]
: []
),
],
});
this._updateToolbarButtons();
}
if (changedProps.has("lint") || changedProps.has("yamlFieldSchema")) {
if (changedProps.has("lint")) {
transactions.push({
effects: this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
this._buildYamlSyntaxLinter()
),
});
}
if (changedProps.has("yamlFieldSchema") || changedProps.has("readOnly")) {
transactions.push({
effects: this._loadedCodeMirror!.yamlSchemaCompartment!.reconfigure(
this._buildSchemaLinter()
this.lint && !this.readOnly
? [this._loadedCodeMirror!.lintGutter()]
: []
),
});
}
@@ -379,65 +372,6 @@ export class HaCodeEditor extends ReactiveElement {
return this._loadedCodeMirror!.langs[this.mode];
}
private _buildSchemaLinter() {
if (!this._loadedCodeMirror || !this.yamlFieldSchema || this.readOnly) {
return [];
}
const schema = this.yamlFieldSchema;
return [
this._loadedCodeMirror.linter(
(view) =>
this._loadedCodeMirror!.haYamlLintSource(
view,
schema,
this._i18n?.localize
),
{ delay: 500 }
),
];
}
/**
* Builds the yamlLintCompartment extensions: a linter that surfaces the
* stored _yamlSyntaxError (set by setYamlError), plus the lint gutter when
* either syntax linting or schema linting is active.
*
* Using a linter() instead of setDiagnostics() means this linter's
* diagnostics are managed independently of the schema linter's diagnostics —
* they don't overwrite each other.
*/
private _buildYamlSyntaxLinter() {
if (this.readOnly) return [];
const showGutter = this.lint || !!this.yamlFieldSchema;
const extensions: Extension[] = [];
if (showGutter) {
extensions.push(this._loadedCodeMirror!.lintGutter());
}
if (this.lint) {
extensions.push(
this._loadedCodeMirror!.linter(
(view) => {
const err = this._yamlSyntaxError;
if (!err) return [];
const doc = view.state.doc;
const pos = err.mark ? Math.min(err.mark.position, doc.length) : 0;
const line = doc.lineAt(pos);
const message = `${
err.reason ||
this._i18n?.localize("ui.components.yaml-editor.error") ||
"YAML syntax error"
}${err.mark ? ` (${this._i18n?.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
return [
{ from: pos, to: line.to, severity: "error" as const, message },
];
},
{ delay: 0 }
)
);
}
return extensions;
}
private _createCodeMirror() {
if (!this._loadedCodeMirror) {
throw new Error("Cannot create editor before CodeMirror is loaded");
@@ -486,10 +420,7 @@ export class HaCodeEditor extends ReactiveElement {
this.linewrap ? this._loadedCodeMirror.EditorView.lineWrapping : []
),
this._loadedCodeMirror.yamlLintCompartment.of(
this._buildYamlSyntaxLinter()
),
this._loadedCodeMirror.yamlSchemaCompartment.of(
this._buildSchemaLinter()
this.lint && !this.readOnly ? [this._loadedCodeMirror.lintGutter()] : []
),
this._loadedCodeMirror.EditorView.updateListener.of(this._onUpdate),
this._loadedCodeMirror.tooltips({
@@ -505,21 +436,6 @@ export class HaCodeEditor extends ReactiveElement {
),
{ hoverTime: 300 }
),
...(this.mode === "yaml"
? [
this._loadedCodeMirror.hoverTooltip(
(view, pos) =>
this.yamlFieldSchema
? this._loadedCodeMirror!.haYamlHoverSource(view, pos, {
schema: this.yamlFieldSchema,
localize: this._i18n?.localize,
hassContext: this._hassArgHoverContext(),
})
: null,
{ hoverTime: 300 }
),
]
: []),
...(this.placeholder ? [placeholder(this.placeholder)] : []),
];
@@ -527,19 +443,6 @@ export class HaCodeEditor extends ReactiveElement {
const completionSources: CompletionSource[] = [
this._loadedCodeMirror.haJinjaCompletionSource,
];
if (this.mode === "yaml") {
completionSources.push(
this._loadedCodeMirror.haYamlCompletionSource(() => ({
schema: this.yamlFieldSchema,
localize: this._i18n?.localize,
states: this._states,
devices: this._registries?.devices,
areas: this._registries?.areas,
floors: this._registries?.floors,
labels: this._labels,
}))
);
}
if (this.autocompleteEntities) {
completionSources.push(this._entityCompletions.bind(this));
}
@@ -550,7 +453,6 @@ export class HaCodeEditor extends ReactiveElement {
this._loadedCodeMirror.autocompletion({
override: completionSources,
maxRenderedOptions: 10,
activateOnCompletion: (completion) => completion.type === "yaml-key",
}),
this._loadedCodeMirror.closeBrackets(),
this._loadedCodeMirror.closeBracketsOverride,
@@ -851,6 +753,19 @@ export class HaCodeEditor extends ReactiveElement {
this._states![key]
);
const entityName = computeEntityName(
this._states![key],
this._registries!.entities,
this._registries!.devices
);
const deviceName = context.device
? computeDeviceName(context.device)
: undefined;
const areaName = context.area ? computeAreaName(context.area) : undefined;
const floorName = context.floor
? computeFloorName(context.floor)
: undefined;
const completionItems: CompletionItem[] = [
{
label: this._i18n!.localize(
@@ -858,31 +773,39 @@ export class HaCodeEditor extends ReactiveElement {
),
value: formattedState,
subValue:
// If the state exactly matches the formatted state, don't show the raw state
this._states![key].state === formattedState
? undefined
: this._states![key].state,
},
];
if (context.device && context.device.name) {
if (entityName) {
completionItems.push({
label: this._i18n!.localize(
"ui.components.entity.entity-picker.entity"
),
value: entityName,
});
}
if (deviceName) {
completionItems.push({
label: this._i18n!.localize("ui.components.device-picker.device"),
value: context.device.name,
value: deviceName,
});
}
if (context.area && context.area.name) {
if (areaName) {
completionItems.push({
label: this._i18n!.localize("ui.components.area-picker.area"),
value: context.area.name,
value: areaName,
});
}
if (context.floor && context.floor.name) {
if (floorName) {
completionItems.push({
label: this._i18n!.localize("ui.components.floor-picker.floor"),
value: context.floor.name,
value: floorName,
});
}
@@ -1106,9 +1029,23 @@ export class HaCodeEditor extends ReactiveElement {
});
};
private _getStates = memoizeOne((states: HassEntities): Completion[] =>
buildEntityCompletions(states)
);
private _getStates = memoizeOne((states: HassEntities): Completion[] => {
if (!states) {
return [];
}
const options = Object.keys(states).map((key) => ({
type: "variable",
label: states[key].attributes.friendly_name
? `${states[key].attributes.friendly_name} ${key}` // label is used for searching, so include both name and entity_id here
: key,
displayLabel: key,
detail: states[key].attributes.friendly_name,
apply: key,
}));
return options;
});
// Map of HA Jinja function name → (arg index → JinjaArgType).
// Derived from the snippet definitions in jinja_ha_completions.ts.
@@ -1503,7 +1440,18 @@ export class HaCodeEditor extends ReactiveElement {
private _getDevices = memoizeOne(
(devices: HomeAssistant["devices"]): Completion[] =>
buildDeviceCompletions(devices)
Object.values(devices)
.filter((device) => !device.disabled_by)
.map((device) => {
const name = computeDeviceName(device);
return {
type: "variable",
label: `${name} ${device.id}`,
displayLabel: name ?? device.id,
detail: device.id,
apply: device.id,
};
})
);
/** Build a CompletionResult for device IDs, with `from` set inside the quotes. */
@@ -1522,7 +1470,17 @@ export class HaCodeEditor extends ReactiveElement {
}
private _getAreas = memoizeOne(
(areas: HomeAssistant["areas"]): Completion[] => buildAreaCompletions(areas)
(areas: HomeAssistant["areas"]): Completion[] =>
Object.values(areas).map((area) => {
const name = computeAreaName(area) ?? area.area_id;
return {
type: "variable",
label: `${name} ${area.area_id}`, // label is used for searching, so include both name and ID here
displayLabel: name,
detail: area.area_id,
apply: area.area_id,
};
})
);
/** Build a CompletionResult for area IDs, with `from` set inside the quotes. */
@@ -1542,7 +1500,16 @@ export class HaCodeEditor extends ReactiveElement {
private _getFloors = memoizeOne(
(floors: HomeAssistant["floors"]): Completion[] =>
buildFloorCompletions(floors)
Object.values(floors).map((floor) => {
const name = computeFloorName(floor) ?? floor.floor_id;
return {
type: "variable",
label: `${name} ${floor.floor_id}`, // label is used for searching, so include both name and ID here
displayLabel: name,
detail: floor.floor_id,
apply: floor.floor_id,
};
})
);
/** Build a CompletionResult for floor IDs, with `from` set inside the quotes. */
@@ -1562,7 +1529,16 @@ export class HaCodeEditor extends ReactiveElement {
private _getLabels = memoizeOne(
(labels: LabelRegistryEntry[]): Completion[] =>
buildLabelCompletions(labels)
labels.map((label) => {
const name = label.name.trim() || label.label_id;
return {
type: "variable",
label: `${name} ${label.label_id}`, // label is used for searching, so include both name and ID here
displayLabel: name,
detail: label.label_id,
apply: label.label_id,
};
})
);
/** Build a CompletionResult for label IDs, with `from` set inside the quotes. */
+11 -3
View File
@@ -143,7 +143,8 @@ export class HaControlSelect extends LitElement {
? repeat(
this.options,
(option) => option.value,
(option) => this._renderOption(option)
(option, index) =>
this._renderOption(option, index === this._tabbableIndex)
)
: nothing
}
@@ -151,7 +152,14 @@ export class HaControlSelect extends LitElement {
`;
}
private _renderOption(option: ControlSelectOption) {
/* a radio group with no selection puts its first option in the tab sequence */
private get _tabbableIndex() {
const selectedIndex =
this.options?.findIndex((option) => option.value === this.value) ?? -1;
return selectedIndex === -1 ? 0 : selectedIndex;
}
private _renderOption(option: ControlSelectOption, tabbable: boolean) {
const isSelected = this.value === option.value;
return html`
@@ -162,7 +170,7 @@ export class HaControlSelect extends LitElement {
selected: isSelected,
})}
role="radio"
tabindex=${isSelected ? "0" : "-1"}
tabindex=${tabbable ? "0" : "-1"}
.value=${option.value}
aria-checked=${isSelected ? "true" : "false"}
aria-label=${ifDefined(option.ariaLabel ?? option.label)}
+3 -2
View File
@@ -68,6 +68,7 @@ export class HaDurationInput extends LitElement {
{ label: "-", iconPath: mdiMinusThick, value: "-" },
]}
.active=${this._negative ? "-" : "+"}
.disabled=${this.disabled}
@value-changed=${this._negativeChanged}
></ha-button-toggle-group>
`
@@ -235,8 +236,8 @@ export class HaDurationInput extends LitElement {
ev.stopPropagation();
const negative = (ev.detail?.value || ev.target.value) === "-";
this._toggleNegative = negative;
const value = this.data;
if (value) {
if (this.data) {
const value = { ...this.data };
FIELDS.forEach((t) => {
if (value[t]) {
value[t] = negative ? -Math.abs(value[t]) : Math.abs(value[t]);
+79
View File
@@ -0,0 +1,79 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "./ha-svg-icon";
/**
* Centered placeholder for a surface that has nothing to show, with an icon, a
* heading, an optional description and optional actions.
*
* @slot - Actions that help the user fill the surface, e.g. a button.
*/
@customElement("ha-empty-state")
export class HaEmptyState extends LitElement {
/** SVG path of the icon shown above the heading. */
@property() public icon?: string;
@property() public heading?: string;
@property() public description?: string;
protected render() {
return html`
<div class="content">
${
this.icon
? html`<ha-svg-icon .path=${this.icon}></ha-svg-icon>`
: nothing
}
${this.heading ? html`<h2>${this.heading}</h2>` : nothing}
${this.description ? html`<p>${this.description}</p>` : nothing}
<slot></slot>
</div>
`;
}
static styles = css`
:host {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
height: 100%;
width: 100%;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--ha-space-4);
box-sizing: border-box;
max-width: 500px;
padding: var(--ha-space-8) var(--ha-space-4);
text-align: center;
}
ha-svg-icon {
--mdc-icon-size: var(--ha-empty-state-icon-size, 64px);
color: var(--secondary-text-color);
}
h2 {
margin: 0;
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
}
p {
margin: 0;
color: var(--secondary-text-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-empty-state": HaEmptyState;
}
}
+43 -49
View File
@@ -2,7 +2,12 @@ import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { fireEvent } from "../common/dom/fire_event";
@@ -34,11 +39,11 @@ export class HaFilterBlueprints extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _blueprints?: Blueprints;
@query("ha-list") private _list?: HTMLElement;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -56,7 +61,6 @@ export class HaFilterBlueprints extends LitElement {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -71,29 +75,38 @@ export class HaFilterBlueprints extends LitElement {
: nothing
}
</div>
${
this._blueprints && this._shouldRender
? html`
<ha-list
@selected=${this._blueprintsSelected}
multi
class="ha-scrollbar"
>
${Object.entries(this._blueprints).map(([id, blueprint]) =>
"error" in blueprint
? nothing
: html`<ha-check-list-item
.value=${id}
.selected=${(this.value || []).includes(id)}
>
${blueprint.metadata.name || id}
</ha-check-list-item>`
)}
</ha-list>
`
: nothing
}
</ha-expansion-panel>
${
this._panel.showContent
? html`
<div class="content" ${ref(this._content)}>
${
this._blueprints
? html`
<ha-list
@selected=${this._blueprintsSelected}
multi
class="ha-scrollbar"
>
${Object.entries(this._blueprints).map(
([id, blueprint]) =>
"error" in blueprint
? nothing
: html`<ha-check-list-item
.value=${id}
.selected=${(this.value || []).includes(id)}
>
${blueprint.metadata.name || id}
</ha-check-list-item>`
)}
</ha-list>
`
: nothing
}
</div>
`
: nothing
}
`;
}
@@ -104,19 +117,6 @@ export class HaFilterBlueprints extends LitElement {
this._blueprints = await fetchBlueprints(this.hass, this.type);
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (this.narrow || !this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -191,17 +191,11 @@ export class HaFilterBlueprints extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
ha-list {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
min-height: 0;
}
.header {
display: flex;
+23 -53
View File
@@ -8,9 +8,14 @@ import {
mdiTag,
} from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import type { CategoryRegistryEntry } from "../data/category_registry";
@@ -47,9 +52,9 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
@state() private _categories: CategoryRegistryEntry[] = [];
@state() private _shouldRender = false;
private _content = createRef<HTMLElement>();
@query("ha-list") private _list?: HTMLElement;
private _panel = new FilterPanelController(this, this._content);
protected hassSubscribeRequiredHostProps = ["scope"];
@@ -70,7 +75,6 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -85,9 +89,11 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
: nothing
}
</div>
${
this._shouldRender
? html`
</ha-expansion-panel>
${
this._panel.showContent
? html`
<div class="content" ${ref(this._content)}>
<ha-list
@selected=${this._categorySelected}
class="ha-scrollbar"
@@ -158,34 +164,17 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
</ha-list-item>`
)}
</ha-list>
`
: nothing
}
</ha-expansion-panel>
${
this.expanded
? html`<ha-list-item
graphic="icon"
@click=${this._addCategory}
class="add"
>
<ha-svg-icon slot="graphic" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.category.editor.add")}
</ha-list-item>`
<ha-list-item graphic="icon" @click=${this._addCategory}>
<ha-svg-icon slot="graphic" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.category.editor.add")}
</ha-list-item>
</div>
`
: nothing
}
`;
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - (49 + 48)}px`;
}, 300);
}
}
private _handleAction(ev: HaDropdownSelectEvent) {
const categoryId = (ev.currentTarget as any).categoryId;
const action = ev.detail.item.value;
@@ -244,10 +233,6 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
});
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -287,19 +272,8 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
:host {
border-bottom: 1px solid var(--divider-color);
position: relative;
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
@@ -325,6 +299,8 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
color: var(--text-primary-color);
}
ha-list {
flex: 1;
min-height: 0;
--mdc-list-item-meta-size: auto;
--mdc-list-side-padding-right: var(--ha-space-1);
--mdc-list-side-padding-left: var(--ha-space-4);
@@ -339,12 +315,6 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
.warning {
color: var(--error-color);
}
.add {
position: absolute;
bottom: 0;
right: 0;
left: 0;
}
`,
];
}
+72 -97
View File
@@ -3,7 +3,12 @@ import { mdiFilterVariantRemove } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../common/entity/compute_device_name";
@@ -59,13 +64,15 @@ export class HaFilterDevices extends LitElement {
@property({ type: Boolean }) public narrow = false;
@state() private _shouldRender = false;
@state() private _filter?: string;
@query("ha-list-selectable-virtualized")
private _listElement?: HaListSelectableVirtualized;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -77,26 +84,11 @@ export class HaFilterDevices extends LitElement {
}
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded || !this._listElement) {
return;
}
this._listElement.style.height = `${this.clientHeight - 49 - 4 - 38}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 38px - height of the search input
}, 300);
}
}
protected render() {
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -112,31 +104,33 @@ export class HaFilterDevices extends LitElement {
: nothing
}
</div>
${
this._shouldRender
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
@keydown=${this._handleSearchKeydown}
>
</ha-input-search>
<ha-list-selectable-virtualized
multi
.rows=${this._devices(
this._devicesReg,
this._filter || "",
this._localize,
this._states,
this._i18n.locale.language
)}
.rowRenderer=${this._renderItem}
@ha-list-item-selected=${this._handleAdded}
@ha-list-item-deselected=${this._handleRemoved}
></ha-list-selectable-virtualized>`
: nothing
}
</ha-expansion-panel>
${
this._panel.showContent
? html`<div class="content" ${ref(this._content)}>
<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
@keydown=${this._handleSearchKeydown}
>
</ha-input-search>
<ha-list-selectable-virtualized
multi
.rows=${this._devices(
this._devicesReg,
this._filter || "",
this._localize,
this._states,
this._i18n.locale.language
)}
.rowRenderer=${this._renderItem}
@ha-list-item-selected=${this._handleAdded}
@ha-list-item-deselected=${this._handleRemoved}
></ha-list-selectable-virtualized>
</div>`
: nothing
}
`;
}
@@ -177,10 +171,6 @@ export class HaFilterDevices extends LitElement {
this.value = (this.value ?? []).filter((deviceId) => deviceId !== id);
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -271,58 +261,43 @@ export class HaFilterDevices extends LitElement {
this._listElement?.clearSelection();
}
static styles = css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
display: flex;
flex-direction: column;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
:host([expanded]) ha-expansion-panel {
flex: 1;
min-height: 0;
}
ha-list-selectable-virtualized {
flex: 1;
min-height: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`;
static styles = [
filterPanelStyles,
css`
ha-list-selectable-virtualized {
flex: 1;
min-height: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`,
];
}
declare global {
+55 -71
View File
@@ -1,11 +1,16 @@
import { consume, type ContextType } from "@lit/context";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
@@ -41,18 +46,17 @@ export class HaFilterDomains extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
protected render() {
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -67,46 +71,48 @@ export class HaFilterDomains extends LitElement {
: nothing
}
</div>
${
this._shouldRender
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@selected=${this._handleItemSelected}
multi
>
${repeat(
this._domains(
this._states,
this._localize,
this._i18n.locale.language,
this._filter,
this.value
),
(i) => i,
(domain) =>
html`<ha-check-list-item
.value=${domain}
.selected=${(this.value || []).includes(domain)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.domain=${domain}
brand-fallback
></ha-domain-icon>
${domainToName(this._localize, domain)}
</ha-check-list-item>`
)}
</ha-list> `
: nothing
}
</ha-expansion-panel>
${
this._panel.showContent
? html`<div class="content" ${ref(this._content)}>
<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@selected=${this._handleItemSelected}
multi
>
${repeat(
this._domains(
this._states,
this._localize,
this._i18n.locale.language,
this._filter,
this.value
),
(i) => i,
(domain) =>
html`<ha-check-list-item
.value=${domain}
.selected=${(this.value || []).includes(domain)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.domain=${domain}
brand-fallback
></ha-domain-icon>
${domainToName(this._localize, domain)}
</ha-check-list-item>`
)}
</ha-list>
</div>`
: nothing
}
`;
}
@@ -139,22 +145,6 @@ export class HaFilterDomains extends LitElement {
}
);
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -199,24 +189,18 @@ export class HaFilterDomains extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
ha-list {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
min-height: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: initial;
margin-inline-start: auto;
margin-inline-end: 8px;
}
ha-check-list-item {
+21 -37
View File
@@ -2,8 +2,13 @@ import { consume, type ContextType } from "@lit/context";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeStateDomain } from "../common/entity/compute_state_domain";
@@ -52,11 +57,11 @@ export class HaFilterEntities extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -78,7 +83,6 @@ export class HaFilterEntities extends LitElement {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -93,9 +97,11 @@ export class HaFilterEntities extends LitElement {
: nothing
}
</div>
${
this._shouldRender
? html`
</ha-expansion-panel>
${
this._panel.showContent
? html`
<div class="content" ${ref(this._content)}>
<ha-input-search
appearance="outlined"
.value=${this._filter}
@@ -118,25 +124,13 @@ export class HaFilterEntities extends LitElement {
>
</lit-virtualizer>
</ha-list>
`
: nothing
}
</ha-expansion-panel>
</div>
`
: nothing
}
`;
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
}, 300);
}
}
private _keyFunction = (entity) => entity?.entity_id;
private _renderItem = (entity) =>
@@ -173,10 +167,6 @@ export class HaFilterEntities extends LitElement {
listItem.selected = this.value?.includes(value);
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -255,17 +245,11 @@ export class HaFilterEntities extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
ha-list {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
min-height: 0;
}
.header {
display: flex;
+510
View File
@@ -0,0 +1,510 @@
import { consume, type ContextType } from "@lit/context";
import {
mdiChevronDown,
mdiChevronUp,
mdiFilterVariantRemove,
mdiShape,
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { computeRTL } from "../common/util/compute_rtl";
import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext, statesContext } from "../data/context";
import {
computeDeviceClassName,
NO_DEVICE_CLASS,
} from "../data/entity/device_class";
import {
entityTypeKey,
parseEntityType,
usedEntityTypes,
} from "../data/entity/entity_type";
import { domainToName } from "../data/integration";
import "./ha-domain-icon";
import "./ha-expansion-panel";
import "./ha-icon-button";
import "./ha-svg-icon";
import "./ha-tree-indicator";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
import "./item/ha-list-item-option";
import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-selectable";
import type { HaListSelectable } from "./list/ha-list-selectable";
// Core picks this one from the battery level, so it has no usable default.
const FIXED_TYPE_ICONS: Record<string, string> = {
"sensor/battery": "mdi:battery",
};
interface TypeRow {
key: string;
domain: string;
deviceClass?: string;
name: string;
deviceClasses?: string[];
expanded?: boolean;
last?: boolean;
}
@customElement("ha-filter-entity-types")
export class HaFilterEntityTypes extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: statesContext, subscribe: true })
@state()
private _states!: ContextType<typeof statesContext>;
@consume({ context: internationalizationContext, subscribe: true })
@state()
private _i18n!: ContextType<typeof internationalizationContext>;
@property({ attribute: false }) public value?: string[];
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _filter?: string;
@state() private _expandedDomains = new Set<string>();
@query("ha-list-selectable") private _list?: HaListSelectable;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
protected render() {
const rows = this._rows(
this._states,
this._localize,
this._i18n.locale.language,
this._filter,
this._expandedDomains
);
const rtl = computeRTL(
this._i18n.language,
this._i18n.translationMetadata.translations
);
const count = this._count(this.value, this._types(this._states));
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this._localize("ui.components.filter-entity-types.caption")}
${
count
? html`<div class="badge">${count}</div>
<ha-icon-button
.path=${mdiFilterVariantRemove}
@click=${this._clearFilter}
></ha-icon-button>`
: nothing
}
</div>
</ha-expansion-panel>
${
this._panel.showContent
? html`<div class="content" ${ref(this._content)}>
<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list-selectable
multi
controlled
aria-label=${this._localize(
"ui.components.filter-entity-types.caption"
)}
@ha-list-item-selected=${this._handleItemToggled}
@ha-list-item-deselected=${this._handleItemToggled}
>
${repeat(
rows,
(row) => row.key,
(row) => this._renderRow(row, rtl)
)}
</ha-list-selectable>
</div>`
: nothing
}
`;
}
private _renderRow(row: TypeRow, rtl: boolean) {
const selected = this._isSelected(row);
const expandable = !!row.deviceClasses?.length;
return html`
<ha-list-item-option
appearance="checkbox"
selection-position="end"
class=${classMap({ child: !!row.deviceClass, rtl })}
.value=${row.key}
.selected=${selected}
.indeterminate=${!selected && this._isPartiallySelected(row)}
>
${
row.deviceClass
? html`<ha-tree-indicator
slot="start"
.end=${!!row.last}
></ha-tree-indicator>`
: nothing
}
${
row.deviceClass === NO_DEVICE_CLASS
? html`<ha-svg-icon slot="start" .path=${mdiShape}></ha-svg-icon>`
: html`<ha-domain-icon
slot="start"
.icon=${FIXED_TYPE_ICONS[row.key]}
.domain=${row.domain}
.deviceClass=${row.deviceClass}
.state=${row.domain === "binary_sensor" ? "on" : undefined}
?brand-fallback=${!row.deviceClass}
></ha-domain-icon>`
}
<span slot="headline">${row.name}</span>
${
expandable
? html`<ha-icon-button
slot="end"
data-domain=${row.domain}
.path=${row.expanded ? mdiChevronUp : mdiChevronDown}
.label=${this._localize(
row.expanded
? "ui.components.filter-entity-types.collapse"
: "ui.components.filter-entity-types.expand"
)}
@click=${this._toggleDomain}
@keydown=${this._handleChevronKeydown}
></ha-icon-button>`
: nothing
}
</ha-list-item-option>
`;
}
private _types = memoizeOne(usedEntityTypes);
// A selected domain counts for the classes it stands for, so that collapsing
// the last one does not drop the count to one.
private _count = memoizeOne(
(value: string[] | undefined, types: Map<string, string[]>): number =>
(value ?? []).reduce((count, key) => {
const { domain, deviceClass } = parseEntityType(key);
return (
count +
(deviceClass ? 1 : Math.max(types.get(domain)?.length ?? 0, 1))
);
}, 0)
);
private _rows = memoizeOne(
(
states: ContextType<typeof statesContext>,
localize: LocalizeFunc,
language: string | undefined,
filter: string | undefined,
expandedDomains: Set<string>
): TypeRow[] => {
const types = this._types(states);
const domains = [...types.keys()]
.map((domain) => ({ domain, name: domainToName(localize, domain) }))
.sort((a, b) => stringCompare(a.name, b.name, language));
const rows: TypeRow[] = [];
for (const { domain, name } of domains) {
const deviceClasses = types
.get(domain)!
.map((deviceClass) => ({
deviceClass,
name: this._deviceClassName(localize, domain, deviceClass),
}))
.sort((a, b) => {
if (a.deviceClass === NO_DEVICE_CLASS) {
return 1;
}
if (b.deviceClass === NO_DEVICE_CLASS) {
return -1;
}
return stringCompare(a.name, b.name, language);
});
const matchingClasses = deviceClasses.filter((entry) =>
this._matches(filter, entry.deviceClass, entry.name)
);
const domainMatches = this._matches(filter, domain, name);
if (!domainMatches && !matchingClasses.length) {
continue;
}
// Only a search that matched nothing but device classes unfolds them.
const revealed = !!filter && !domainMatches;
const expanded = revealed || expandedDomains.has(domain);
rows.push({
key: domain,
domain,
name,
deviceClasses: deviceClasses.map((entry) => entry.deviceClass),
expanded,
});
if (!deviceClasses.length || !expanded) {
continue;
}
const children = revealed ? matchingClasses : deviceClasses;
children.forEach((entry, index) => {
rows.push({
key: entityTypeKey(domain, entry.deviceClass),
domain,
deviceClass: entry.deviceClass,
name: entry.name,
last: index === children.length - 1,
});
});
}
return rows;
}
);
private _deviceClassName(
localize: LocalizeFunc,
domain: string,
deviceClass: string
): string {
return deviceClass === NO_DEVICE_CLASS
? localize("ui.components.filter-entity-types.no_device_class")
: computeDeviceClassName(localize, domain, deviceClass);
}
private _matches(
filter: string | undefined,
slug: string,
name: string
): boolean {
return (
!filter ||
slug.toLowerCase().includes(filter) ||
name.toLowerCase().includes(filter)
);
}
private _isSelected(row: TypeRow): boolean {
const value = this.value;
if (!value?.length) {
return false;
}
return value.includes(row.domain) || value.includes(row.key);
}
private _isPartiallySelected(row: TypeRow): boolean {
if (row.deviceClass || !this.value?.length) {
return false;
}
return this.value.some(
(key) => parseEntityType(key).domain === row.domain && key !== row.domain
);
}
public willUpdate(changed: PropertyValues<this>) {
super.willUpdate(changed);
if (changed.has("expanded") && this.expanded) {
this._expandedDomains = new Set(
(this.value ?? [])
.map((key) => parseEntityType(key))
.filter((type) => type.deviceClass)
.map((type) => type.domain)
);
}
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
// The list activates the focused row on Enter and Space, which would select
// the domain instead of expanding it.
private _handleChevronKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.stopPropagation();
}
}
private _toggleDomain(ev: Event) {
ev.stopPropagation();
const { domain } = (ev.currentTarget as HTMLElement).dataset;
if (!domain) {
return;
}
const expandedDomains = new Set(this._expandedDomains);
if (!expandedDomains.delete(domain)) {
expandedDomains.add(domain);
}
this._expandedDomains = expandedDomains;
}
private _handleItemToggled(ev: CustomEvent<number>) {
// The list indexes its items by registration order, which a search reorders,
// so read the key off the clicked option instead.
const option = this._list?.items[ev.detail] as HaListItemOption | undefined;
const key = option?.value;
if (!key) {
return;
}
const { domain, deviceClass } = parseEntityType(key);
const value = new Set(this.value ?? []);
const siblings = (this._types(this._states).get(domain) ?? []).map(
(entry) => entityTypeKey(domain, entry)
);
// Drops the classes the domain no longer exposes too, so that a stale key
// can never sit next to the domain that covers it.
const selectDomain = () => {
value.forEach((selected) => {
if (parseEntityType(selected).domain === domain) {
value.delete(selected);
}
});
value.add(domain);
};
if (!deviceClass) {
if (!value.delete(domain)) {
selectDomain();
}
} else if (value.delete(domain)) {
siblings.forEach((sibling) => {
if (sibling !== key) {
value.add(sibling);
}
});
} else if (!value.delete(key)) {
value.add(key);
if (siblings.length && siblings.every((sibling) => value.has(sibling))) {
selectDomain();
}
}
this.value = [...value];
fireEvent(this, "data-table-filter-changed", {
value: this.value.length ? this.value : undefined,
items: undefined,
});
}
private _clearFilter(ev: Event) {
ev.preventDefault();
this.value = undefined;
fireEvent(this, "data-table-filter-changed", {
value: undefined,
items: undefined,
});
}
private _handleSearchChange(ev: InputEvent) {
const target = ev.target as HaInputSearch;
this._filter = (target.value ?? "").toLowerCase();
}
static get styles(): CSSResultGroup {
return [
filterPanelStyles,
css`
/* The list scrolls through its own container, not through the host. */
ha-list-selectable {
display: flex;
flex: 1;
min-height: 0;
}
ha-list-selectable::part(base) {
flex: 1;
min-height: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
/* Keeps a row that carries the chevron as tall as one that does not. */
ha-list-item-option {
--ha-row-item-padding-block: var(--ha-space-2);
}
ha-list-item-option ha-icon-button {
--ha-icon-button-size: 32px;
}
.child::part(base) {
padding-inline-start: 48px;
}
ha-tree-indicator {
width: 56px;
position: absolute;
top: 0px;
left: 0px;
}
.rtl ha-tree-indicator {
right: 0px;
left: initial;
transform: scaleX(-1);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-entity-types": HaFilterEntityTypes;
}
}
+21 -32
View File
@@ -4,8 +4,13 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeRTL } from "../common/util/compute_rtl";
@@ -64,10 +69,12 @@ export class HaFilterFloorAreas extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@query("ha-list-selectable") private _list?: HaListSelectable;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -86,7 +93,6 @@ export class HaFilterFloorAreas extends LitElement {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -107,9 +113,11 @@ export class HaFilterFloorAreas extends LitElement {
: nothing
}
</div>
${
this._shouldRender
? html`
</ha-expansion-panel>
${
this._panel.showContent
? html`
<div class="content" ${ref(this._content)}>
<ha-list-selectable
class="ha-scrollbar"
multi
@@ -154,10 +162,10 @@ export class HaFilterFloorAreas extends LitElement {
(area) => this._renderArea(area)
)}
</ha-list-selectable>
`
: nothing
}
</ha-expansion-panel>
</div>
`
: nothing
}
`;
}
@@ -243,19 +251,6 @@ export class HaFilterFloorAreas extends LitElement {
};
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -347,17 +342,11 @@ export class HaFilterFloorAreas extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
ha-list-selectable {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
min-height: 0;
}
.header {
display: flex;
+60 -72
View File
@@ -1,11 +1,16 @@
import type { SelectedDetail } from "@material/mwc-list";
import { consume, type ContextType } from "@lit/context";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
@@ -44,11 +49,11 @@ export class HaFilterIntegrations extends LitElement {
Object.values(manifests)
);
@state() private _shouldRender = false;
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
protected render() {
const manifests = this._manifests
@@ -59,7 +64,6 @@ export class HaFilterIntegrations extends LitElement {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -74,67 +78,57 @@ export class HaFilterIntegrations extends LitElement {
: nothing
}
</div>
${
manifests && this._shouldRender
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@selected=${this._itemSelected}
multi
>
${repeat(
this._integrations(
this._localize,
manifests,
this._filter,
this.value,
this._i18n.locale.language
),
(i) => i.domain,
(integration) =>
html`<ha-check-list-item
.value=${integration.domain}
.selected=${(this.value || []).includes(
integration.domain
)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.domain=${integration.domain}
brand-fallback
></ha-domain-icon>
${integration.name}
</ha-check-list-item>`
)}
</ha-list> `
: nothing
}
</ha-expansion-panel>
${
this._panel.showContent
? html`<div class="content" ${ref(this._content)}>
${
manifests
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@selected=${this._itemSelected}
multi
>
${repeat(
this._integrations(
this._localize,
manifests,
this._filter,
this.value,
this._i18n.locale.language
),
(i) => i.domain,
(integration) =>
html`<ha-check-list-item
.value=${integration.domain}
.selected=${(this.value || []).includes(
integration.domain
)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.domain=${integration.domain}
brand-fallback
></ha-domain-icon>
${integration.name}
</ha-check-list-item>`
)}
</ha-list>`
: nothing
}
</div>`
: nothing
}
`;
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -213,17 +207,11 @@ export class HaFilterIntegrations extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
ha-list {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
min-height: 0;
}
.header {
display: flex;
+62 -94
View File
@@ -1,11 +1,16 @@
import { consume, type ContextType } from "@lit/context";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiCog, mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { navigate } from "../common/navigate";
@@ -44,11 +49,11 @@ export class HaFilterLabels extends LitElement {
@state()
private _labels?: LabelRegistryEntry[];
@state() private _shouldRender = false;
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
private _filteredLabels = memoizeOne(
// `_value` used to recalculate the memoization when the selection changes
@@ -75,7 +80,6 @@ export class HaFilterLabels extends LitElement {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -90,89 +94,66 @@ export class HaFilterLabels extends LitElement {
: nothing
}
</div>
${
this._shouldRender
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
@selected=${this._labelSelected}
class="ha-scrollbar"
multi
>
${repeat(
this._filteredLabels(
this._labels || [],
this._filter,
this._i18n.locale.language,
this.value
),
(label) => label.label_id,
(label) =>
html`<ha-check-list-item
.value=${label.label_id}
.selected=${(this.value || []).includes(label.label_id)}
hasMeta
>
<ha-label
.color=${label.color}
.description=${label.description}
>
${
label.icon
? html`<ha-icon
slot="icon"
.icon=${label.icon}
></ha-icon>`
: nothing
}
${label.name}
</ha-label>
</ha-check-list-item>`
)}
</ha-list> `
: nothing
}
</ha-expansion-panel>
${
this.expanded
? html`<ha-list-item
graphic="icon"
@click=${this._manageLabels}
class="add"
>
<ha-svg-icon slot="graphic" .path=${mdiCog}></ha-svg-icon>
${this._localize("ui.panel.config.labels.manage_labels")}
</ha-list-item>`
this._panel.showContent
? html`<div class="content" ${ref(this._content)}>
<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
@selected=${this._labelSelected}
class="ha-scrollbar"
multi
>
${repeat(
this._filteredLabels(
this._labels || [],
this._filter,
this._i18n.locale.language,
this.value
),
(label) => label.label_id,
(label) =>
html`<ha-check-list-item
.value=${label.label_id}
.selected=${(this.value || []).includes(label.label_id)}
hasMeta
>
<ha-label
.color=${label.color}
.description=${label.description}
>
${
label.icon
? html`<ha-icon
slot="icon"
.icon=${label.icon}
></ha-icon>`
: nothing
}
${label.name}
</ha-label>
</ha-check-list-item>`
)}
</ha-list>
<ha-list-item graphic="icon" @click=${this._manageLabels}>
<ha-svg-icon slot="graphic" .path=${mdiCog}></ha-svg-icon>
${this._localize("ui.panel.config.labels.manage_labels")}
</ha-list-item>
</div>`
: nothing
}
`;
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - (49 + 48 + 32 + 4)}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
// 48px - height of ha-list-item
}, 300);
}
}
private _manageLabels() {
navigate("/config/labels");
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -227,18 +208,11 @@ export class HaFilterLabels extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
:host {
position: relative;
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
ha-list {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
min-height: 0;
}
.header {
display: flex;
@@ -267,12 +241,6 @@ export class HaFilterLabels extends LitElement {
.warning {
color: var(--error-color);
}
.add {
position: absolute;
bottom: 0;
right: 0;
left: 0;
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
+73
View File
@@ -0,0 +1,73 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "./chips/ha-assist-chip";
import "./ha-svg-icon";
/**
* Chip that opens a filter pane, with a badge showing how many filters are
* active.
*/
@customElement("ha-filter-pane-chip")
export class HaFilterPaneChip extends LitElement {
@property() public label = "";
/** SVG path of the leading icon. */
@property() public path?: string;
/** Number of active filters, shown as a badge when there is at least one. */
@property({ type: Number }) public count = 0;
@property({ type: Boolean }) public active = false;
@property({ type: Boolean }) public disabled = false;
protected render() {
return html`
<ha-assist-chip
.label=${this.label}
.active=${this.active}
.disabled=${this.disabled}
>
${
this.path
? html`<ha-svg-icon slot="icon" .path=${this.path}></ha-svg-icon>`
: nothing
}
</ha-assist-chip>
${this.count ? html`<div class="badge">${this.count}</div>` : nothing}
`;
}
static styles = css`
:host {
position: relative;
display: inline-block;
--ha-assist-chip-container-shape: 10px;
}
.badge {
position: absolute;
top: -4px;
right: -4px;
inset-inline-end: -4px;
inset-inline-start: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0 2px;
color: var(--text-primary-color);
pointer-events: none;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-pane-chip": HaFilterPaneChip;
}
}
+182
View File
@@ -0,0 +1,182 @@
import { mdiFilterVariant, mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-adaptive-dialog";
import "./ha-button";
import "./ha-dialog-footer";
import "./ha-filter-pane-chip";
import "./ha-icon-button";
/**
* Filter pane for a filtered page: a column next to the content on wide
* screens, a bottom sheet on narrow ones. Mirrors the filter pane of
* `hass-tabs-subpage-data-table` for pages that are not a data table.
*
* The page keeps ownership of whether the pane is shown, so that it can also
* open it from elsewhere (e.g. an empty state) and hide its own toolbar chip
* while it is open.
*
* @slot - Filter panels, e.g. `ha-filter-domains`.
*/
@customElement("ha-filter-pane")
export class HaFilterPane extends LitElement {
@property({ type: Boolean, reflect: true }) public narrow = false;
/** Header label, defaults to "Filters". */
@property() public label?: string;
/** SVG path of the header chip icon. */
@property() public path = mdiFilterVariant;
/** Number of active filters, shows the clear button when above zero. */
@property({ type: Number }) public count = 0;
/**
* Number of results the current filters resolve to, shown on the narrow
* confirm button. Leave undefined when the page shows everything.
*/
@property({ attribute: false }) public resultCount?: number;
@property({ type: Boolean }) public disabled = false;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
protected render() {
const label =
this.label ?? this._localize("ui.components.subpage-data-table.filters");
if (this.narrow) {
return html`
<ha-adaptive-dialog
open
flexcontent
.headerTitle=${label}
@closed=${this._close}
>
${this._renderClearButton("headerActionItems")}
<div class="sheet-content">
<slot></slot>
</div>
<ha-dialog-footer slot="footer">
<ha-button slot="primaryAction" data-dialog="close">
${
this.resultCount === undefined
? this._localize("ui.common.close")
: this._localize(
"ui.components.subpage-data-table.show_results",
{ number: this.resultCount }
)
}
</ha-button>
</ha-dialog-footer>
</ha-adaptive-dialog>
`;
}
return html`
<div class="header">
<ha-filter-pane-chip
active
.label=${label}
.path=${this.path}
.disabled=${this.disabled}
@click=${this._close}
></ha-filter-pane-chip>
${this._renderClearButton()}
</div>
<div class="content ha-scrollbar">
<slot></slot>
</div>
`;
}
private _renderClearButton(slot?: string) {
if (!this.count) {
return nothing;
}
return html`
<ha-icon-button
slot=${ifDefined(slot)}
.path=${mdiFilterVariantRemove}
.disabled=${this.disabled}
.label=${this._localize("ui.components.subpage-data-table.clear_filter")}
@click=${this._clear}
></ha-icon-button>
`;
}
private _close() {
fireEvent(this, "close-filter-pane");
}
private _clear() {
fireEvent(this, "clear-filter");
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
display: flex;
flex-direction: column;
flex: 0 0 var(--ha-filter-pane-width, 320px);
width: var(--ha-filter-pane-width, 320px);
box-sizing: border-box;
overflow: hidden;
border-inline-end: 1px solid var(--divider-color);
}
/* The bottom sheet positions itself, so the pane takes no space. */
:host([narrow]) {
display: contents;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ha-space-4);
box-sizing: border-box;
height: 56px;
flex-shrink: 0;
padding: 0 16px;
background: var(--primary-background-color);
border-bottom: 1px solid var(--divider-color);
}
.content,
.sheet-content {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow-y: auto;
}
ha-adaptive-dialog {
--dialog-content-padding: 0;
/* Fixed height so the sheet does not resize while filtering. */
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-pane": HaFilterPane;
}
interface HASSDomEvents {
"close-filter-pane": undefined;
}
}
+22 -45
View File
@@ -1,8 +1,13 @@
import type { List, SelectedDetail } from "@material/mwc-list";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-check-list-item";
@@ -27,9 +32,9 @@ export class HaFilterStates extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
private _content = createRef<HTMLElement>();
@query("ha-list") private _list!: List;
private _panel = new FilterPanelController(this, this._content);
protected render() {
if (!this.states) {
@@ -40,7 +45,6 @@ export class HaFilterStates extends LitElement {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -55,9 +59,11 @@ export class HaFilterStates extends LitElement {
: nothing
}
</div>
${
this._shouldRender
? html`
</ha-expansion-panel>
${
this._panel.showContent
? html`
<div class="content" ${ref(this._content)}>
<ha-list
@selected=${this._statesSelected}
multi
@@ -82,36 +88,13 @@ export class HaFilterStates extends LitElement {
</ha-check-list-item>`
)}
</ha-list>
`
: nothing
}
</ha-expansion-panel>
</div>
`
: nothing
}
`;
}
protected willUpdate(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
this._shouldRender = true;
}
}
protected updated(changed: PropertyValues<this>) {
if ((changed.has("expanded") || changed.has("states")) && this.expanded) {
setTimeout(async () => {
if (!this.expanded) return;
const list = this._list;
if (!list) {
return;
}
list.style.height = `${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -152,17 +135,11 @@ export class HaFilterStates extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
ha-list {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
min-height: 0;
}
.header {
display: flex;
+20 -34
View File
@@ -2,8 +2,13 @@ import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { fireEvent } from "../common/dom/fire_event";
@@ -34,16 +39,15 @@ export class HaFilterVoiceAssistants extends LitElement {
@state() private _voiceAssistantOptions: string[] = [];
@state() private _shouldRender = false;
private _content = createRef<HTMLElement>();
@query("ha-list") private _list?: HTMLElement;
private _panel = new FilterPanelController(this, this._content);
protected render() {
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -58,9 +62,11 @@ export class HaFilterVoiceAssistants extends LitElement {
: nothing
}
</div>
${
this._shouldRender
? html`<ha-list
</ha-expansion-panel>
${
this._panel.showContent
? html`<div class="content" ${ref(this._content)}>
<ha-list
@selected=${this._assistantsSelected}
class="ha-scrollbar"
multi
@@ -83,10 +89,10 @@ export class HaFilterVoiceAssistants extends LitElement {
${voiceAssistants[voiceAssistantId].name}
</ha-check-list-item>`
)}
</ha-list> `
: nothing
}
</ha-expansion-panel>
</ha-list>
</div>`
: nothing
}
`;
}
@@ -95,19 +101,6 @@ export class HaFilterVoiceAssistants extends LitElement {
this._voiceAssistantOptions = Object.keys(voiceAssistants);
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -148,18 +141,11 @@ export class HaFilterVoiceAssistants extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
:host {
position: relative;
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
ha-list {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
min-height: 0;
}
.header {
display: flex;
@@ -0,0 +1,25 @@
import { DEFAULT_MIN_KELVIN } from "../../common/color/convert-light-color";
import type { Selector } from "../../data/selector";
/**
* Value a selector already displays when no field value is set.
* Used when enabling an optional service/trigger/condition field.
*/
export const getSelectorFallbackValue = (selector: Selector): unknown => {
if ("constant" in selector) {
return selector.constant?.value;
}
if ("boolean" in selector) {
return false;
}
if ("number" in selector) {
return selector.number?.min ?? 0;
}
if ("color_temp" in selector) {
if (selector.color_temp?.unit === "kelvin") {
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
}
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
}
return undefined;
};
+2
View File
@@ -43,6 +43,8 @@ const CUSTOM_ICONS: Record<string, () => Promise<string>> = {
import("../resources/esphome-logo-svg").then((mod) => mod.mdiEsphomeLogo),
matter: () =>
import("../resources/matter-logo-svg").then((mod) => mod.mdiMatterLogo),
mqtt: () =>
import("../resources/mqtt-logo-svg").then((mod) => mod.mdiMqttLogo),
};
@customElement("ha-icon")
@@ -16,16 +16,13 @@ import "../ha-input-helper-text";
import "../ha-select-box";
import type { SelectBoxOption } from "../ha-select-box";
export const TRIGGER_BEHAVIORS: AutomationBehaviorTriggerMode[] = [
const TRIGGER_BEHAVIORS: AutomationBehaviorTriggerMode[] = [
"each",
"first",
"all",
];
export const CONDITION_BEHAVIORS: AutomationBehaviorConditionMode[] = [
"any",
"all",
];
const CONDITION_BEHAVIORS: AutomationBehaviorConditionMode[] = ["any", "all"];
@customElement("ha-selector-automation_behavior")
export class HaSelectorAutomationBehavior extends LitElement {
@@ -81,6 +81,7 @@ export class HaChooseSelector extends LitElement {
.required=${this.required}
@value-changed=${this._handleValueChanged}
.helper=${this.helper}
.localizeValue=${this.localizeValue}
></ha-selector>`;
}
@@ -107,7 +108,12 @@ export class HaChooseSelector extends LitElement {
: {
[this._activeChoice!]: this.value,
};
this._activeChoice = ev.detail?.value || ev.target.value;
const choice = ev.detail?.value || ev.target.value;
this._activeChoice = choice;
if (choice && "constant" in this.selector.choose.choices[choice].selector) {
value[choice] =
this.selector.choose.choices[choice].selector.constant?.value;
}
fireEvent(this, "value-changed", {
value: {
...value,
+3 -14
View File
@@ -35,6 +35,7 @@ import {
} from "../data/selector";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import { documentationUrl } from "../util/documentation-url";
import { getSelectorFallbackValue } from "./ha-form/get-selector-fallback-value";
import "./ha-checkbox";
import type { HaCheckbox } from "./ha-checkbox";
import "./ha-icon-button";
@@ -799,20 +800,8 @@ export class HaServiceControl extends LitElement {
let defaultValue = field?.default;
if (
defaultValue == null &&
field?.selector &&
"constant" in field.selector
) {
defaultValue = field.selector.constant?.value;
}
if (
defaultValue == null &&
field?.selector &&
"boolean" in field.selector
) {
defaultValue = false;
if (defaultValue == null && field?.selector) {
defaultValue = getSelectorFallbackValue(field.selector);
}
if (defaultValue != null) {
+1
View File
@@ -664,6 +664,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
display: flex;
flex-direction: column;
overflow: hidden;
overscroll-behavior: contain;
-ms-user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
+267
View File
@@ -0,0 +1,267 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { ensureArray } from "../common/array/ensure-array";
import { fireEvent } from "../common/dom/fire_event";
import type { DataTableFiltersValue } from "../data/data_table_filters";
import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
import { entityTypeFilterFunc } from "../data/entity/entity_type";
import type { EntitySources } from "../data/entity/entity_sources";
import type { HomeAssistant } from "../types";
import "./ha-filter-entity-types";
import "./ha-filter-integrations";
import "./ha-target-picker";
/**
* Ways to narrow down the entities a target selection resolves to. Not to be
* confused with `EntitySources`, which maps an entity to its integration.
*/
export interface SourceFilters {
/** Domains (`sensor`) and domains narrowed to a device class (`sensor/power`). */
types?: string[];
integrations?: string[];
}
const TARGET_KEYS = [
"floor_id",
"area_id",
"device_id",
"entity_id",
"label_id",
] as const;
/** Number of picked targets, no matter which type they are. */
export const countTargets = (target: HassServiceTarget): number =>
TARGET_KEYS.reduce(
(count, key) => count + (target[key] ? ensureArray(target[key]).length : 0),
0
);
/** Number of filters that have at least one option selected. */
export const countSourceFilters = (filters: SourceFilters): number =>
Object.values(filters).filter((value) => value?.length).length;
/**
* Matches an entity against the selected filters: it is kept when it matches
* every filter that has a selection. Undefined when nothing is selected.
*/
export const sourceFilterFunc = (
filters: SourceFilters,
states: HomeAssistant["states"],
entities: HomeAssistant["entities"],
entitySources?: EntitySources
): ((entityId: string) => boolean) | undefined => {
const matchesType = filters.types?.length
? entityTypeFilterFunc(filters.types, states)
: undefined;
const integrations = filters.integrations?.length
? filters.integrations
: undefined;
if (!matchesType && !integrations) {
return undefined;
}
return (entityId: string) => {
if (matchesType && !matchesType(entityId)) {
return false;
}
if (integrations) {
const integration =
entities[entityId]?.platform ?? entitySources?.[entityId]?.domain;
if (!integration || !integrations.includes(integration)) {
return false;
}
}
return true;
};
};
/** Narrows entity IDs down by the selected filters. */
export const applySourceFilters = (
entityIds: string[],
filters: SourceFilters,
states: HomeAssistant["states"],
entities: HomeAssistant["entities"],
entitySources?: EntitySources
): string[] => {
const matches = sourceFilterFunc(filters, states, entities, entitySources);
return matches ? entityIds.filter(matches) : entityIds;
};
/**
* Picker for what a page shows: the targets to include, narrowed down by
* entity type and integration. Meant to be placed in an `ha-filter-pane`.
*
* The pages resolve every entity of a target, secondary ones included, so the
* target picker counts them too.
*/
@customElement("ha-sources-picker")
export class HaSourcesPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value: HassServiceTarget = {};
@property({ attribute: false }) public filters: SourceFilters = {};
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
@property({ attribute: false }) public entitySources?: EntitySources;
/** Explains what the page shows while no target is picked. */
@property() public description?: string;
@property({ type: Boolean }) public disabled = false;
// Only one filter panel is expanded at a time, so that the expanded one can
// use the height that is left in the pane.
@state() private _expandedFilter?: keyof SourceFilters;
protected render() {
const noTargets = countTargets(this.value) === 0;
return html`
${
this.description && noTargets
? html`<div class="description">${this.description}</div>`
: nothing
}
<ha-target-picker
class=${classMap({ "no-padding-top": noTargets })}
.hass=${this.hass}
.value=${this.value}
.entityFilter=${this.entityFilter}
.activeFilter=${this._activeFilter(
this.filters,
this.hass.states,
this.hass.entities,
this.entitySources
)}
.primaryEntitiesOnly=${false}
.disabled=${this.disabled}
@value-changed=${this._targetsChanged}
></ha-target-picker>
<div
class=${classMap({ filters: true, expanded: !!this._expandedFilter })}
>
<ha-filter-entity-types
.value=${this.filters.types}
.expanded=${this._expandedFilter === "types"}
@data-table-filter-changed=${this._typesChanged}
@expanded-changed=${this._typesExpanded}
></ha-filter-entity-types>
<ha-filter-integrations
.value=${this.filters.integrations}
.expanded=${this._expandedFilter === "integrations"}
@data-table-filter-changed=${this._integrationsChanged}
@expanded-changed=${this._integrationsExpanded}
></ha-filter-integrations>
</div>
`;
}
private _activeFilter = memoizeOne(sourceFilterFunc);
protected firstUpdated() {
// The filter panels label themselves with keys from the config panel.
this.hass.loadFragmentTranslation("config");
}
private _targetsChanged(ev: CustomEvent) {
ev.stopPropagation();
fireEvent(this, "value-changed", { value: ev.detail.value || {} });
}
private _typesChanged(ev: CustomEvent) {
this._filterChanged("types", ev);
}
private _integrationsChanged(ev: CustomEvent) {
this._filterChanged("integrations", ev);
}
private _filterChanged(key: keyof SourceFilters, ev: CustomEvent) {
ev.stopPropagation();
const value = ev.detail.value as DataTableFiltersValue;
fireEvent(this, "source-filters-changed", {
value: {
...this.filters,
[key]: Array.isArray(value) && value.length ? value : undefined,
},
});
}
private _typesExpanded(ev: CustomEvent) {
this._filterExpanded("types", ev);
}
private _integrationsExpanded(ev: CustomEvent) {
this._filterExpanded("integrations", ev);
}
private _filterExpanded(key: keyof SourceFilters, ev: CustomEvent) {
if (ev.detail.expanded) {
this._expandedFilter = key;
} else if (this._expandedFilter === key) {
this._expandedFilter = undefined;
}
}
static styles = css`
/* The sections are laid out by the pane, so that an expanded filter
panel can use the height that is left. */
:host {
display: contents;
}
.description {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
min-height: 92px;
margin: var(--ha-space-4) var(--ha-space-4) 0;
padding: 0 var(--ha-space-6);
border-radius: var(--ha-border-radius-lg);
background-color: var(--ha-color-fill-neutral-quiet-resting);
text-align: center;
color: var(--secondary-text-color);
}
ha-target-picker {
display: block;
flex: none;
padding: var(--ha-space-4);
}
/* The description already spaces the picker from the pane header. */
ha-target-picker.no-padding-top {
padding-top: 0;
}
.filters {
display: flex;
flex-direction: column;
flex: 1 0 auto;
border-top: 1px solid var(--divider-color);
}
/* An expanded panel sizes itself to the space that is left over. */
.filters.expanded {
flex: 1 1 auto;
min-height: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-sources-picker": HaSourcesPicker;
}
interface HASSDomEvents {
"source-filters-changed": { value: SourceFilters };
}
}
+68 -302
View File
@@ -41,9 +41,6 @@ import { domainToName } from "../data/integration";
import { getLabels, labelComboBoxKeys } from "../data/label/label_picker";
import type { LabelRegistryEntry } from "../data/label/label_registry";
import {
areaMeetsFilter,
deviceMeetsFilter,
entityRegMeetsFilter,
getTargetComboBoxItemType,
type TargetItem,
type TargetType,
@@ -66,7 +63,6 @@ import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import "./ha-svg-icon";
import "./ha-tree-indicator";
import "./target-picker/ha-target-picker-item-group";
import "./target-picker/ha-target-picker-value-chip";
const SEPARATOR = "________";
const CREATE_ID = "___create-new-entity___";
@@ -85,8 +81,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property() public helper?: string;
@property({ type: Boolean, reflect: true }) public compact = false;
@property({ attribute: false }) public createDomains?: string[];
@property({ type: Boolean, attribute: "primary-entities-only" })
@@ -114,9 +108,14 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
@property({ type: Boolean, reflect: true }) public disabled = false;
/**
* Entities that pass the filters the page currently has on. Narrows the
* counts, unlike `entityFilter`, which says what can be picked at all.
*/
@property({ attribute: false })
public activeFilter?: (entityId: string) => boolean;
@property({ attribute: "add-on-top", type: Boolean }) public addOnTop = false;
@property({ type: Boolean, reflect: true }) public disabled = false;
@state() private _selectedSection?: TargetTypeFloorless;
@@ -158,6 +157,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
excludeDevices,
value,
idPrefix,
nested: true,
})
);
@@ -252,119 +252,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
Fuse.createIndex(keys, states);
protected render() {
if (this.addOnTop) {
return html` ${this._renderPicker()} ${this._renderItems()} `;
}
return html` ${this._renderItems()} ${this._renderPicker()} `;
}
private _renderValueChips() {
const entityIds = this.value?.entity_id
? ensureArray(this.value.entity_id)
: [];
const deviceIds = this.value?.device_id
? ensureArray(this.value.device_id)
: [];
const areaIds = this.value?.area_id ? ensureArray(this.value.area_id) : [];
const floorIds = this.value?.floor_id
? ensureArray(this.value.floor_id)
: [];
const labelIds = this.value?.label_id
? ensureArray(this.value.label_id)
: [];
if (
!entityIds.length &&
!deviceIds.length &&
!areaIds.length &&
!floorIds.length &&
!labelIds.length
) {
return nothing;
}
return html`
<div class="items">
${
floorIds.length
? floorIds.map(
(floor_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="floor"
.itemId=${floor_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing
}
${
areaIds.length
? areaIds.map(
(area_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="area"
.itemId=${area_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing
}
${
deviceIds.length
? deviceIds.map(
(device_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="device"
.itemId=${device_id}
.compositeSplits=${this._compositeSplits}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing
}
${
entityIds.length
? entityIds.map(
(entity_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="entity"
.itemId=${entity_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing
}
${
labelIds.length
? labelIds.map(
(label_id) => html`
<ha-target-picker-value-chip
.hass=${this.hass}
type="label"
.itemId=${label_id}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
`
)
: nothing
}
</div>
`;
}
private _renderValueGroups() {
const entityIds = this.value?.entity_id
? ensureArray(this.value.entity_id)
@@ -403,6 +293,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.items=${{ entity: entityIds }}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.activeFilter=${this.activeFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
@@ -423,6 +314,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.items=${{ device: deviceIds }}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.activeFilter=${this.activeFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
@@ -446,6 +338,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.activeFilter=${this.activeFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
@@ -465,6 +358,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.items=${{ label: labelIds }}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.activeFilter=${this.activeFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
@@ -478,9 +372,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
private _renderItems() {
return html`
${this.compact ? this._renderValueChips() : this._renderValueGroups()}
`;
return html` ${this._renderValueGroups()} `;
}
private _renderPicker() {
@@ -655,152 +547,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
});
}
private _handleExpand(ev: HASSDomEvent<HASSDomEvents["expand-target-item"]>) {
const type = ev.detail.type;
const itemId = ev.detail.id;
const newAreas: string[] = [];
const newDevices: string[] = [];
const newEntities: string[] = [];
if (type === "floor") {
Object.values(this.hass.areas).forEach((area) => {
if (
area.floor_id === itemId &&
!this.value!.area_id?.includes(area.area_id) &&
areaMeetsFilter(
area,
this.hass.devices,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
newAreas.push(area.area_id);
}
});
} else if (type === "area") {
Object.values(this.hass.devices).forEach((device) => {
if (
device.area_id === itemId &&
!this.value!.device_id?.includes(device.id) &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
newDevices.push(device.id);
}
});
Object.values(this.hass.entities).forEach((entity) => {
if (
entity.area_id === itemId &&
!this.value!.entity_id?.includes(entity.entity_id) &&
entityRegMeetsFilter(
entity,
false,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
newEntities.push(entity.entity_id);
}
});
} else if (type === "device") {
Object.values(this.hass.entities).forEach((entity) => {
if (
entity.device_id === itemId &&
!this.value!.entity_id?.includes(entity.entity_id) &&
entityRegMeetsFilter(
entity,
false,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
newEntities.push(entity.entity_id);
}
});
} else if (type === "label") {
Object.values(this.hass.areas).forEach((area) => {
if (
area.labels.includes(itemId) &&
!this.value!.area_id?.includes(area.area_id) &&
areaMeetsFilter(
area,
this.hass.devices,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
newAreas.push(area.area_id);
}
});
Object.values(this.hass.devices).forEach((device) => {
if (
device.labels.includes(itemId) &&
!this.value!.device_id?.includes(device.id) &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
newDevices.push(device.id);
}
});
Object.values(this.hass.entities).forEach((entity) => {
if (
entity.labels.includes(itemId) &&
!this.value!.entity_id?.includes(entity.entity_id) &&
entityRegMeetsFilter(
entity,
true,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
newEntities.push(entity.entity_id);
}
});
} else {
return;
}
let value = this.value;
if (newEntities.length) {
value = this._addItems(value, "entity_id", newEntities);
}
if (newDevices.length) {
value = this._addItems(value, "device_id", newDevices);
}
if (newAreas.length) {
value = this._addItems(value, "area_id", newAreas);
}
value = this._removeItem(value, type, itemId);
fireEvent(this, "value-changed", { value });
}
private _handleReplace(
ev: HASSDomEvent<HASSDomEvents["replace-target-item"]>
) {
@@ -840,17 +586,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
this._replaceTargetAnchor = undefined;
}
private _addItems(
value: this["value"],
type: string,
ids: string[]
): this["value"] {
return {
...value,
[type]: value![type] ? ensureArray(value![type])!.concat(ids) : ids,
};
}
private _removeItem(
value: this["value"],
type: TargetType,
@@ -1001,6 +736,28 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
if (!filterType || filterType === "device") {
const selectedDeviceIds = targetValue?.device_id
? replacingDeviceId
? ensureArray(targetValue.device_id).filter(
(deviceId) => deviceId !== replacingDeviceId
)
: ensureArray(targetValue.device_id)
: undefined;
// A selected parent device already targets its children, so exclude
// those children from the picker too (mirrors selecting a floor
// removing its areas from the list).
const excludeDeviceIds = selectedDeviceIds
? [
...selectedDeviceIds,
...Object.values(this.hass.devices)
.filter(
(device) =>
device.parent_device_id !== null &&
selectedDeviceIds.includes(device.parent_device_id)
)
.map((device) => device.id),
]
: undefined;
let deviceItems = this._getDevicesMemoized(
this.hass,
configEntryLookup,
@@ -1008,26 +765,41 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
includeDeviceClasses,
deviceFilter,
entityFilter,
targetValue?.device_id
? replacingDeviceId
? ensureArray(targetValue.device_id).filter(
(deviceId) => deviceId !== replacingDeviceId
)
: ensureArray(targetValue.device_id)
: undefined,
excludeDeviceIds,
replacingDeviceId,
`device${SEPARATOR}`
).sort(this._sortBySortingLabel);
);
// getDevices already returns child devices nested under their parent
// with the top-level devices sorted; keep that order rather than
// re-sorting by label, which would separate children from their parent.
if (searchTerm) {
// Keep the nested parent-then-children order (sort=false), matching
// the areas group; the default sorted search would reorder matches by
// relevance and pull children above their parent.
deviceItems = this._filterGroup(
"device",
deviceItems,
searchTerm,
deviceComboBoxKeys
deviceComboBoxKeys,
false
);
}
// Recompute the tree "last child" flag over the (possibly filtered)
// list so the last visible child of each parent draws its end connector.
deviceItems = deviceItems.map((item, index) => {
if (!(item as DevicePickerItem).is_child) {
return item;
}
const nextItem = deviceItems[index + 1] as
DevicePickerItem | undefined;
return {
...item,
last: !nextItem || !nextItem.is_child,
};
});
if (!filterType && deviceItems.length) {
// show group title
items.push(localize("ui.components.target-picker.type.devices"));
@@ -1245,7 +1017,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
let hasFloor = false;
let rtl = false;
let showEntityId = false;
if (type === "area" || type === "floor") {
const isChildDeviceRow =
type === "device" && !!(item as DevicePickerItem).is_child;
if (type === "area" || type === "floor" || isChildDeviceRow) {
rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
@@ -1265,27 +1039,27 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.type=${type === "empty" ? "text" : "button"}
class=${type === "empty" ? "empty" : ""}
style=${
(item as FloorComboBoxItem).type === "area" && hasFloor
((item as FloorComboBoxItem).type === "area" && hasFloor) ||
isChildDeviceRow
? "--md-list-item-leading-space: var(--ha-space-12);"
: ""
}
>
${
(item as FloorComboBoxItem).type === "area" && hasFloor
((item as FloorComboBoxItem).type === "area" && hasFloor) ||
isChildDeviceRow
? html`
<ha-tree-indicator
style=${styleMap({
width: "var(--ha-space-12)",
position: "absolute",
top: "0",
height: "100%",
left: rtl ? undefined : "var(--ha-space-1)",
right: rtl ? "var(--ha-space-1)" : undefined,
transform: rtl ? "scaleX(-1)" : "",
})}
.end=${
(item as FloorComboBoxItem & { last?: boolean | undefined })
.last
}
.end=${(item as { last?: boolean }).last}
slot="start"
></ha-tree-indicator>
`
@@ -1387,13 +1161,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
width: 100%;
}
.items {
z-index: 2;
display: flex;
flex-wrap: wrap;
padding: var(--ha-space-2) 0;
gap: var(--ha-space-2);
}
.item-groups {
overflow: hidden;
border: var(--ha-border-width-sm) solid var(--divider-color);
@@ -1409,7 +1176,6 @@ declare global {
interface HASSDomEvents {
"remove-target-item": TargetItem;
"expand-target-item": TargetItem;
"replace-target-item": TargetItem;
"migrate-target-item": { id: string; replacements: string[] };
"remove-target-group": string;
+24 -3
View File
@@ -8,10 +8,31 @@ export class HaTreeIndicator extends LitElement {
public end?: boolean = false;
protected render(): TemplateResult {
// preserveAspectRatio="none" lets the connector stretch to the host box, so
// it can span the full height of a taller row instead of being letterboxed
// to a square in the middle. non-scaling-stroke keeps the line width and
// dash pattern identical no matter how far it is stretched.
return html`
<svg width="100%" height="100%" viewBox="0 0 48 48">
<line x1="24" y1="0" x2="24" y2=${this.end ? "24" : "48"}></line>
<line x1="24" y1="24" x2="36" y2="24"></line>
<svg
width="100%"
height="100%"
viewBox="0 0 48 48"
preserveAspectRatio="none"
>
<line
x1="24"
y1="0"
x2="24"
y2=${this.end ? "24" : "48"}
vector-effect="non-scaling-stroke"
></line>
<line
x1="24"
y1="24"
x2="36"
y2="24"
vector-effect="non-scaling-stroke"
></line>
</svg>
`;
}
+1 -11
View File
@@ -8,7 +8,6 @@ import { consume } from "@lit/context";
import { fireEvent } from "../common/dom/fire_event";
import { copyToClipboard } from "../common/util/copy-clipboard";
import { haStyle } from "../resources/styles";
import type { YamlFieldSchemaMap } from "../resources/yaml_field_schema";
import { showToast } from "../util/toast";
import "./ha-button";
import "./ha-code-editor";
@@ -33,14 +32,6 @@ export class HaYamlEditor extends LitElement {
@property({ attribute: false }) public yamlSchema: Schema = YAML11_SCHEMA;
/**
* Optional field schema for YAML mode. When provided, the code editor will
* offer field-aware key/value completions, hover tooltips, and linting.
* This is forwarded directly to ha-code-editor.
*/
@property({ attribute: false })
public yamlFieldSchema?: YamlFieldSchemaMap;
@property({ attribute: false }) public defaultValue?: any;
@property({ attribute: "is-valid", type: Boolean }) public isValid = true;
@@ -132,9 +123,8 @@ export class HaYamlEditor extends LitElement {
.inDialog=${this.inDialog}
mode="yaml"
lint
.autocompleteEntities=${!this.yamlFieldSchema}
autocomplete-entities
autocomplete-icons
.yamlFieldSchema=${this.yamlFieldSchema}
.error=${this.isValid === false}
@value-changed=${this._onChange}
@editor-save=${this._onEditorSave}
@@ -24,6 +24,7 @@ export type HaListItemOptionSelectionPosition = "start" | "end";
* @cssprop --ha-list-item-selected-background - Background color when selected (`appearance="line"`).
*
* @attr {boolean} selected - Whether the option is selected. Set by the parent `ha-list-selectable`.
* @attr {boolean} indeterminate - Draws the checkbox in an indeterminate state, for a row that stands for a partially selected set.
* @attr {string} value - Value identifying the option.
* @attr {("line"|"checkbox")} appearance - Visual style. "line" highlights the row; "checkbox" renders an `ha-checkbox`.
* @attr {("start"|"end")} selection-position - Side the checkbox sits on when `appearance="checkbox"`.
@@ -32,6 +33,8 @@ export type HaListItemOptionSelectionPosition = "start" | "end";
export class HaListItemOption extends HaListItemBase {
@property({ type: Boolean, reflect: true }) public selected = false;
@property({ type: Boolean, reflect: true }) public indeterminate = false;
@property({ type: String }) public value?: string;
@property({ type: String, reflect: true })
@@ -80,6 +83,7 @@ export class HaListItemOption extends HaListItemBase {
return html`<div part="checkbox" class="checkbox" inert>
<ha-checkbox
.checked=${this.selected}
.indeterminate=${this.indeterminate}
.disabled=${this.disabled}
></ha-checkbox>
</div>`;
@@ -10,6 +10,8 @@ export const SelectableMixin = <T extends Constructor<HaListBase>>(
class SelectableClass extends superClass {
@property({ type: Boolean, reflect: true }) public multi = false;
@property({ type: Boolean, reflect: true }) public controlled = false;
protected override readonly hostRole = "listbox";
public connectedCallback(): void {
@@ -67,15 +69,19 @@ export const SelectableMixin = <T extends Constructor<HaListBase>>(
`ha-list-item-${el.selected ? "deselected" : "selected"}`,
index
);
el.toggleAttribute("selected");
if (!this.controlled) {
el.toggleAttribute("selected");
}
return;
}
if (!el.selected) {
fireEvent(this, "ha-list-item-selected", index);
// deselect the other optional selected item
this.clearSelection();
el.toggleAttribute("selected", true);
if (!this.controlled) {
// deselect the other optional selected item
this.clearSelection();
el.toggleAttribute("selected", true);
}
}
}
}
@@ -20,6 +20,9 @@ import { HaListVirtualized } from "./ha-list-virtualized";
*
* @attr {boolean} multi - Whether multiple options can be selected at once. In
* single-select mode, selecting a row clears any previous selection.
* @attr {boolean} controlled - Only notify on click, never toggle a row's `selected` state.
* Set it when the consumer derives `selected` from its own state, for example when a row stands
* for a group whose click also changes its children.
*
* @fires ha-list-item-selected - Fires when the user selects a row.
* `detail` is the row's index (number).
@@ -11,6 +11,9 @@ import { SelectableMixin } from "./ha-list-selectable-mixin";
* Toggle single vs multi selection via the `multi` attribute.
*
* @attr {boolean} multi - Whether multiple options can be selected at once.
* @attr {boolean} controlled - Only notify on click, never toggle an option's `selected` state.
* Set it when the consumer derives `selected` from its own state, for example when a row stands
* for a group whose click also changes its children.
*
* @fires ha-list-item-selected - An option was selected. `detail: number` (option index).
* @fires ha-list-item-deselected - An option was deselected (multi mode only). `detail: number` (option index).
@@ -118,6 +118,16 @@ class DialogTargetDetails extends LitElement implements HassDialog {
);
};
private _combinedFilter = memoizeOne(
(
entityFilter: HaEntityPickerEntityFilterFunc | undefined,
activeFilter: (entityId: string) => boolean
): HaEntityPickerEntityFilterFunc =>
(stateObj) =>
(!entityFilter || entityFilter(stateObj)) &&
activeFilter(stateObj.entity_id)
);
private _selectorTarget() {
return this._params?.selector?.target || null;
}
@@ -127,6 +137,8 @@ class DialogTargetDetails extends LitElement implements HassDialog {
return nothing;
}
const { activeFilter } = this._params;
let deviceFilter: HaDevicePickerDeviceFilterFunc | undefined;
let entityFilter: HaEntityPickerEntityFilterFunc | undefined;
let includeDomains: string[] | undefined;
@@ -145,6 +157,10 @@ class DialogTargetDetails extends LitElement implements HassDialog {
primaryEntitiesOnly = this._params.primaryEntitiesOnly;
}
if (activeFilter) {
entityFilter = this._combinedFilter(entityFilter, activeFilter);
}
const waitingForSources =
this._params.selector &&
this._hasIntegration(this._params.selector) &&
@@ -11,6 +11,7 @@ export interface TargetDetailsDialogParams {
selector?: TargetSelector;
deviceFilter?: HaDevicePickerDeviceFilterFunc;
entityFilter?: HaEntityPickerEntityFilterFunc;
activeFilter?: (entityId: string) => boolean;
includeDomains?: string[];
includeDeviceClasses?: string[];
primaryEntitiesOnly?: boolean;
@@ -34,6 +34,9 @@ export class HaTargetPickerItemGroup extends LitElement {
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
@property({ attribute: false })
public activeFilter?: (entityId: string) => boolean;
/**
* Show only targets with entities from specific domains.
* @type {Array}
@@ -88,6 +91,7 @@ export class HaTargetPickerItemGroup extends LitElement {
.itemId=${item}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.activeFilter=${this.activeFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
@@ -92,6 +92,13 @@ export class HaTargetPickerItemRow extends LitElement {
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
/**
* Entities that pass the filters the page currently has on. Narrows the
* count, and the target details, but not what the target resolves to.
*/
@property({ attribute: false })
public activeFilter?: (entityId: string) => boolean;
/**
* Show only targets with entities from specific domains.
* @type {Array}
@@ -189,7 +196,7 @@ export class HaTargetPickerItemRow extends LitElement {
}
</div>
<div slot="headline">${(canMigrate && replacement?.name) || name}</div>
<span slot="headline">${(canMigrate && replacement?.name) || name}</span>
${
notFound || (context && !this.hideContext)
? html`<span slot="supporting-text"
@@ -222,12 +229,7 @@ export class HaTargetPickerItemRow extends LitElement {
${
this.expand || !entries.referenced_entities.length
? html`<span class="main">
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries.referenced_entities.length,
}
)}
${this._entitiesLabel(entries)}
</span>`
: html`<ha-button
appearance="filled"
@@ -235,12 +237,7 @@ export class HaTargetPickerItemRow extends LitElement {
size="xs"
@click=${this._openDetails}
>
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries.referenced_entities.length,
}
)}
${this._entitiesLabel(entries)}
</ha-button>`
}
</div>
@@ -334,6 +331,28 @@ export class HaTargetPickerItemRow extends LitElement {
`;
}
private _entityCounts(entries: ExtractFromTargetResultReferenced) {
const total = entries.referenced_entities.length;
return {
total,
count: this.activeFilter
? entries.referenced_entities.filter(this.activeFilter).length
: total,
};
}
private _entitiesLabel(entries: ExtractFromTargetResultReferenced): string {
const { count, total } = this._entityCounts(entries);
return this.activeFilter
? this.hass.localize(
"ui.components.target-picker.entities_count_filtered",
{ count, total }
)
: this.hass.localize("ui.components.target-picker.entities_count", {
count,
});
}
private _renderEntries() {
const entries = this.parentEntries || this._entries;
@@ -393,6 +412,9 @@ export class HaTargetPickerItemRow extends LitElement {
nextEntries.referenced_entities =
entries?.referenced_entities.filter((entity_id) => {
const entity = this.hass.entities[entity_id];
if (!entity) {
return false;
}
return (
entity.area_id === rowItem ||
!entity.device_id ||
@@ -416,6 +438,9 @@ export class HaTargetPickerItemRow extends LitElement {
this.type === "label" && entries
? entries.referenced_entities.filter((entity_id) => {
const entity = this.hass.entities[entity_id];
if (!entity) {
return false;
}
return (
entity.labels.includes(this.itemId) &&
!entries.referenced_devices.includes(entity.device_id || "")
@@ -424,7 +449,7 @@ export class HaTargetPickerItemRow extends LitElement {
: nextType === "device" && entries
? entries.referenced_entities.filter(
(entity_id) =>
this.hass.entities[entity_id].area_id === this.itemId
this.hass.entities[entity_id]?.area_id === this.itemId
)
: [];
@@ -433,7 +458,7 @@ export class HaTargetPickerItemRow extends LitElement {
? entries.referenced_devices.filter(
(device_id) =>
!devicesInAreas.includes(device_id) &&
this.hass.devices[device_id].labels.includes(this.itemId)
this.hass.devices[device_id]?.labels.includes(this.itemId)
)
: [];
@@ -528,6 +553,12 @@ export class HaTargetPickerItemRow extends LitElement {
entries.referenced_areas = entries.referenced_areas.filter(
(area_id) => {
const area = this.hass.areas[area_id];
// Absent from the registry is not a filter decision: drop the id
// without marking it hidden, so entities targeted through their
// own area or label are not dropped along with it.
if (!area) {
return false;
}
if (
(this.type === "floor" || area.labels.includes(this.itemId)) &&
areaMeetsFilter(
@@ -560,6 +591,9 @@ export class HaTargetPickerItemRow extends LitElement {
entries.referenced_devices = entries.referenced_devices.filter(
(device_id) => {
const device = this.hass.devices[device_id];
if (!device) {
return false;
}
if (
!hiddenAreaIds.includes(device.area_id || "") &&
deviceMeetsFilter(
@@ -585,6 +619,11 @@ export class HaTargetPickerItemRow extends LitElement {
entries.referenced_entities = entries.referenced_entities.filter(
(entity_id) => {
const entity = this.hass.entities[entity_id];
// Core can reference entities that are absent from the display
// registry (e.g. disabled ones expanded from an area).
if (!entity) {
return false;
}
if (hiddenDeviceIds.includes(entity.device_id || "")) {
return false;
}
@@ -796,6 +835,7 @@ export class HaTargetPickerItemRow extends LitElement {
itemId: this.itemId,
deviceFilter: this.deviceFilter,
entityFilter: this.entityFilter,
activeFilter: this.activeFilter,
includeDomains: this.includeDomains,
includeDeviceClasses: this.includeDeviceClasses,
primaryEntitiesOnly: this.primaryEntitiesOnly,
@@ -1,314 +0,0 @@
import "@home-assistant/webawesome/dist/components/tag/tag";
import { consume } from "@lit/context";
import {
mdiAlertOutline,
mdiDevices,
mdiHome,
mdiLabel,
mdiTextureBox,
mdiUnfoldMoreVertical,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../../common/color/compute-color";
import { hex2rgb } from "../../common/color/convert-color";
import { fireEvent } from "../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { slugify } from "../../common/string/slugify";
import { getConfigEntry } from "../../data/config_entries";
import { labelsContext } from "../../data/context";
import type { DeviceCompositeSplits } from "../../data/device/device_registry";
import { domainToName } from "../../data/integration";
import type { LabelRegistryEntry } from "../../data/label/label_registry";
import type { TargetType } from "../../data/target";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import "../ha-domain-icon";
import { floorDefaultIconPath } from "../ha-floor-icon";
import "../ha-icon";
import "../ha-icon-button";
import "../ha-state-icon";
import "../ha-tooltip";
@customElement("ha-target-picker-value-chip")
export class HaTargetPickerValueChip extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public type!: TargetType;
@property({ attribute: "item-id" }) public itemId!: string;
@property({ attribute: false })
public compositeSplits?: DeviceCompositeSplits;
@state() private _domainName?: string;
@state() private _iconImg?: string;
@state()
@consume({ context: labelsContext, subscribe: true })
_labelRegistry!: LabelRegistryEntry[];
protected render() {
const { name, iconPath, fallbackIconPath, stateObject, color } =
this._itemData(this.type, this.itemId);
const split =
this.type === "device" && !this.hass.devices?.[this.itemId]
? this.compositeSplits?.[this.itemId]
: undefined;
// Show the replaced reference using the primary replacement device's name,
// falling back to the first still-existing split device if the primary
// device itself was deleted. If no replacement device exists at all, fall
// back to the normal "not found" display.
const replacementDevice = split
? (split.primary_id && this.hass.devices?.[split.primary_id]) ||
split.split_ids
.map((id) => this.hass.devices?.[id])
.find((device) => device)
: undefined;
const replaced = !!replacementDevice;
const replacedName = replacementDevice
? computeDeviceNameDisplay(
replacementDevice,
this.hass.localize,
this.hass.states
)
: undefined;
return html`
<wa-tag
pill
with-remove
class=${classMap({ [this.type]: true, replaced })}
style=${color ? `--color: rgb(${color});` : ""}
@wa-remove=${this._removeItem}
>
<div class="icon">
${
replaced
? html`<ha-svg-icon .path=${mdiAlertOutline}></ha-svg-icon>`
: iconPath
? html`<ha-icon .icon=${iconPath}></ha-icon>`
: this._iconImg
? html`<img
alt=${this._domainName || ""}
width="24"
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon
.path=${fallbackIconPath}
></ha-svg-icon>`
: stateObject
? html`<ha-state-icon
.stateObj=${stateObject}
></ha-state-icon>`
: nothing
}
</div>
<span class="name">
${
replaced
? replacedName ||
this.hass.localize(
"ui.components.target-picker.replaced_device"
)
: name
}
</span>
${
this.type === "entity" || replaced
? nothing
: html`<ha-tooltip .for="expand-${slugify(this.itemId)}"
>${this.hass.localize(
`ui.components.target-picker.expand_${this.type}_id`
)}
</ha-tooltip>
<ha-icon-button
class="expand-btn mdc-chip__icon mdc-chip__icon--trailing"
.label=${this.hass.localize(
"ui.components.target-picker.expand"
)}
.path=${mdiUnfoldMoreVertical}
hide-title
.id="expand-${slugify(this.itemId)}"
.type=${this.type}
@click=${this._handleExpand}
></ha-icon-button>`
}
</wa-tag>
`;
}
private _itemData = memoizeOne((type: TargetType, itemId: string) => {
if (type === "floor") {
const floor = this.hass.floors?.[itemId];
return {
name: floor?.name || itemId,
iconPath: floor?.icon,
fallbackIconPath: floor ? floorDefaultIconPath(floor) : mdiHome,
};
}
if (type === "area") {
const area = this.hass.areas?.[itemId];
return {
name: area?.name || itemId,
iconPath: area?.icon,
fallbackIconPath: mdiTextureBox,
};
}
if (type === "device") {
const device = this.hass.devices?.[itemId];
if (device?.primary_config_entry) {
this._getDeviceDomain(device.primary_config_entry);
}
return {
name: device
? computeDeviceNameDisplay(
device,
this.hass.localize,
this.hass.states
)
: itemId,
fallbackIconPath: mdiDevices,
};
}
if (type === "entity") {
this._setDomainName(computeDomain(itemId));
const stateObj = this.hass.states[itemId];
return {
name: computeStateName(stateObj) || itemId,
stateObject: stateObj,
};
}
// type label
const label = this._labelRegistry.find((lab) => lab.label_id === itemId);
let color = label?.color ? computeCssColor(label.color) : undefined;
if (color?.startsWith("var(")) {
const computedStyles = getComputedStyle(this);
color = computedStyles.getPropertyValue(
color.substring(4, color.length - 1)
);
}
if (color?.startsWith("#")) {
color = hex2rgb(color).join(",");
}
return {
name: label?.name || itemId,
iconPath: label?.icon,
fallbackIconPath: mdiLabel,
color,
};
});
private _setDomainName(domain: string) {
this._domainName = domainToName(this.hass.localize, domain);
}
private async _getDeviceDomain(configEntryId: string) {
try {
const data = await getConfigEntry(this.hass, configEntryId);
const domain = data.config_entry.domain;
this._iconImg = brandsUrl(
{
domain: domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
);
this._setDomainName(domain);
} catch {
// failed to load config entry -> ignore
}
}
private _removeItem(ev: MouseEvent) {
ev.stopPropagation();
fireEvent(this, "remove-target-item", {
type: this.type,
id: this.itemId,
});
}
private _handleExpand(ev: MouseEvent) {
ev.stopPropagation();
fireEvent(this, "expand-target-item", {
type: this.type,
id: this.itemId,
});
}
static styles = css`
:host {
display: inline-block;
max-width: 100%;
}
wa-tag {
background-color: var(--card-background-color);
border-width: var(--ha-border-width-md);
padding-inline-start: 0;
overflow: hidden;
max-width: 100%;
color: var(--primary-text-color);
}
wa-tag.entity {
border-color: var(--ha-color-green-80);
--background-color: var(--ha-color-green-80);
}
wa-tag.device {
border-color: var(--ha-color-primary-80);
--background-color: var(--ha-color-primary-80);
}
wa-tag.area {
border-color: var(--ha-color-orange-80);
--background-color: var(--ha-color-orange-80);
}
wa-tag.label {
border-color: var(--color);
--background-color: var(--color);
--icon-primary-color: var(--primary-text-color);
}
wa-tag.replaced {
border-color: var(--ha-color-border-warning-normal, var(--warning-color));
--background-color: var(--warning-color);
color: var(--ha-color-on-warning-normal, var(--warning-color));
}
.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon {
background-color: var(--background-color);
border-radius: var(--ha-border-radius-circle);
padding: var(--ha-space-2) var(--ha-space-1);
display: flex;
}
.expand-btn {
--ha-icon-button-size: 16px;
--mdc-icon-size: 14px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-target-picker-value-chip": HaTargetPickerValueChip;
}
}
+51 -11
View File
@@ -1,5 +1,5 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { stopPropagation } from "../../common/dom/stop_propagation";
@@ -25,6 +25,13 @@ export class HaTileContainer extends LitElement {
@property({ attribute: false })
public actionHandlerOptions?: ActionHandlerOptions;
@state() private _hasFeatures = false;
private _handleFeaturesSlotChange(ev: Event) {
this._hasFeatures =
(ev.target as HTMLSlotElement).assignedElements().length > 0;
}
private _handleFocus(ev: FocusEvent) {
if ((ev.target as HTMLElement).matches(":focus-visible")) {
this.setAttribute("focused", "");
@@ -36,8 +43,12 @@ export class HaTileContainer extends LitElement {
}
protected render() {
const containerOrientationClass =
this.featurePosition === "inline" ? "horizontal" : "";
const isInline = this.featurePosition === "inline";
const containerClasses = {
inline: isInline,
"has-features-below": isInline && this._hasFeatures,
"fixed-height": this.fixedInfoHeight,
};
const contentClasses = {
vertical: this.vertical,
"fixed-info-height": this.fixedInfoHeight,
@@ -56,15 +67,21 @@ export class HaTileContainer extends LitElement {
<ha-ripple .disabled=${!this.interactive}></ha-ripple>
</div>
<div
class="container ${containerOrientationClass}"
class="container ${classMap(containerClasses)}"
@action=${stopPropagation}
@click=${stopPropagation}
>
<div class="content ${classMap(contentClasses)}">
<slot name="icon"></slot>
<slot name="info" id="info"></slot>
<div class="row">
<div class="content ${classMap(contentClasses)}">
<slot name="icon"></slot>
<slot name="info" id="info"></slot>
</div>
<slot name="features-inline"></slot>
</div>
<slot name="features"></slot>
<slot
name="features"
@slotchange=${this._handleFeaturesSlotChange}
></slot>
</div>
`;
}
@@ -97,7 +114,13 @@ export class HaTileContainer extends LitElement {
flex-direction: column;
flex: 1;
}
.container.horizontal {
.row {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.container.inline .row {
flex-direction: row;
}
@@ -153,13 +176,30 @@ export class HaTileContainer extends LitElement {
padding: 0 var(--ha-space-3) var(--ha-space-3) var(--ha-space-3);
}
.container.horizontal ::slotted([slot="features"]) {
.container.inline ::slotted([slot="features-inline"]) {
/* size the feature on the 6 column grid track, so it lines up with neighbouring tiles */
width: calc(50% - var(--column-gap, 0px) / 2 - var(--ha-space-3));
flex: none;
--feature-height: var(--ha-space-9);
padding: 0 var(--ha-space-3);
padding-inline-start: 0;
}
/* the inline feature keeps the icon height, unless the card reserves a row it can fill */
.container.inline:not(.has-features-below)
::slotted([slot="features-inline"]),
.container.inline:not(.fixed-height) ::slotted([slot="features-inline"]) {
--feature-height: var(--ha-space-9);
}
.container.inline.has-features-below ::slotted([slot="features"]) {
/* keep both columns under the inline feature, which sits on the grid track */
--ha-card-feature-column-gap: calc(
var(--column-gap, 0px) + var(--ha-space-3) * 2
);
--ha-card-feature-divider: 1px solid var(--ha-color-border-neutral-quiet);
--ha-card-feature-divider-inset: calc(
var(--ha-space-3) + var(--column-gap, 0px) / 2
);
}
[role="button"] {
cursor: pointer;
pointer-events: auto;
+1
View File
@@ -29,6 +29,7 @@ export class HaTraceLogbook extends LitElement {
.hass=${this.hass}
.entries=${this.logbookEntries}
.narrow=${this.narrow}
no-detail
></ha-logbook-renderer>
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
`
@@ -437,6 +437,7 @@ export class HaTracePathDetails extends LitElement {
.hass=${this.hass}
.entries=${entries}
.narrow=${this.narrow}
no-detail
></ha-logbook-renderer>
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
`
+152 -20
View File
@@ -3,6 +3,7 @@ import { computeDeviceNameDisplay } from "../../common/entity/compute_device_nam
import { computeDomain } from "../../common/entity/compute_domain";
import { getDeviceArea } from "../../common/entity/context/get_device_context";
import type { LocalizeFunc } from "../../common/translations/localize";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { computeRTL } from "../../common/util/compute_rtl";
import type { HaDevicePickerDeviceFilterFunc } from "../../components/device/ha-device-picker";
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
@@ -24,12 +25,17 @@ import {
export interface DevicePickerItem extends PickerComboBoxItem {
domain?: string;
domain_name?: string;
// Set when this device is a child rendered indented under its parent.
is_child?: boolean;
// Set on the last child of a parent so the tree connector draws its end.
last?: boolean;
}
export interface DeviceAreaLabel {
areaName?: string;
viaDeviceName?: string;
viaDeviceAreaName?: string;
parentDeviceName?: string;
}
export interface GetDevicesOptions {
@@ -41,6 +47,10 @@ export interface GetDevicesOptions {
excludeDevices?: string[];
value?: string;
idPrefix?: string;
// When set, order the result so children directly follow their parent and
// flag them for indented rendering. Requires the picker to disable its own
// sorting (no-sort) so this order is preserved.
nested?: boolean;
}
export const computeDeviceAreaLabel = (
@@ -69,6 +79,16 @@ export const computeDeviceAreaLabel = (
? computeAreaName(viaDeviceArea)
: undefined;
// A child device is a logical part of its parent. We surface the parent name
// only as a search term (below) — not in the area label, which stays the pure
// (inherited) area. The nested tree rendering communicates the relationship.
const parentDevice = device.parent_device_id
? devices[device.parent_device_id]
: undefined;
const parentDeviceName = parentDevice
? computeDeviceNameDisplay(parentDevice, localize, states)
: undefined;
const isRTL = computeRTL(language, translationMetadata.translations);
const areaName = area
@@ -77,7 +97,7 @@ export const computeDeviceAreaLabel = (
? `${viaDeviceAreaName}${isRTL ? " ◂ " : " ▸ "}${viaDeviceName}`
: viaDeviceName || undefined;
return { areaName, viaDeviceName, viaDeviceAreaName };
return { areaName, viaDeviceName, viaDeviceAreaName, parentDeviceName };
};
export const deviceComboBoxKeys: FuseWeightedKey[] = [
@@ -105,6 +125,14 @@ export const deviceComboBoxKeys: FuseWeightedKey[] = [
name: "search_labels.viaDeviceArea",
weight: 3,
},
{
name: "search_labels.parentDeviceName",
weight: 3,
},
{
name: "search_labels.childDeviceNames",
weight: 3,
},
];
export const getDevices = (
@@ -121,6 +149,7 @@ export const getDevices = (
excludeDevices,
value,
idPrefix = "",
nested,
} = options ?? {};
const devices = Object.values(hass.devices);
@@ -128,26 +157,60 @@ export const getDevices = (
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
if (
includeDomains ||
excludeDomains ||
includeDeviceClasses ||
entityFilter
) {
const filtersEntities =
includeDomains || excludeDomains || includeDeviceClasses || entityFilter;
if (filtersEntities) {
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
}
// Targeting a device also targets its child devices (a parent inherits its
// children's entities), so a device should match an entity-based filter when
// it OR any of its children has a matching entity. Build a parent -> children
// map and resolve each device's effective entity set accordingly. Nesting is
// single-level, so one hop covers it.
const filterChildrenByParent = new Map<string, DeviceRegistryEntry[]>();
if (filtersEntities) {
for (const device of devices) {
if (device.parent_device_id) {
const siblings = filterChildrenByParent.get(device.parent_device_id);
if (siblings) {
siblings.push(device);
} else {
filterChildrenByParent.set(device.parent_device_id, [device]);
}
}
}
}
const effectiveEntities = (
deviceId: string
): EntityRegistryDisplayEntry[] => {
const own = deviceEntityLookup[deviceId] ?? [];
const children = filterChildrenByParent.get(deviceId);
if (!children) {
return own;
}
const combined = [...own];
for (const child of children) {
const childEntities = deviceEntityLookup[child.id];
if (childEntities) {
combined.push(...childEntities);
}
}
return combined;
};
let inputDevices = devices.filter(
(device) => device.id === value || !device.disabled_by
);
if (includeDomains) {
inputDevices = inputDevices.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
const devEntities = effectiveEntities(device.id);
if (!devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) =>
return devEntities.some((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
});
@@ -155,11 +218,11 @@ export const getDevices = (
if (excludeDomains) {
inputDevices = inputDevices.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
const devEntities = effectiveEntities(device.id);
if (!devEntities.length) {
return true;
}
return entities.every(
return devEntities.every(
(entity) => !excludeDomains.includes(computeDomain(entity.entity_id))
);
});
@@ -173,11 +236,11 @@ export const getDevices = (
if (includeDeviceClasses) {
inputDevices = inputDevices.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
const devEntities = effectiveEntities(device.id);
if (!devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
return devEntities.some((entity) => {
const stateObj = hass.states[entity.entity_id];
if (!stateObj) {
return false;
@@ -192,8 +255,8 @@ export const getDevices = (
if (entityFilter) {
inputDevices = inputDevices.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
const devEntities = effectiveEntities(device.id);
if (!devEntities.length) {
return false;
}
return devEntities.some((entity) => {
@@ -222,7 +285,7 @@ export const getDevices = (
deviceEntityLookup[device.id]
);
const { areaName, viaDeviceName, viaDeviceAreaName } =
const { areaName, viaDeviceName, viaDeviceAreaName, parentDeviceName } =
computeDeviceAreaLabel(
device,
hass.areas,
@@ -259,10 +322,79 @@ export const getDevices = (
domainName: domainName || null,
viaDeviceName: viaDeviceName || null,
viaDeviceArea: viaDeviceAreaName || null,
parentDeviceName: parentDeviceName || null,
},
sorting_label: [primary, areaName, domainName].filter(Boolean).join("_"),
};
});
return outputDevices;
if (!nested) {
return outputDevices;
}
// Order children directly after their parent and flag them for indented
// rendering. outputDevices is 1:1 with inputDevices, so we can pair them up.
const itemByDeviceId = new Map<string, DevicePickerItem>();
inputDevices.forEach((device, index) => {
itemByDeviceId.set(device.id, outputDevices[index]);
});
const presentIds = new Set(inputDevices.map((device) => device.id));
const childrenByParent = new Map<string, DeviceRegistryEntry[]>();
const topLevel: DeviceRegistryEntry[] = [];
for (const device of inputDevices) {
const parentId = device.parent_device_id;
// A child whose parent was filtered out is shown as a top-level row.
if (parentId && presentIds.has(parentId)) {
const siblings = childrenByParent.get(parentId);
if (siblings) {
siblings.push(device);
} else {
childrenByParent.set(parentId, [device]);
}
} else {
topLevel.push(device);
}
}
const compareByName = (a: DeviceRegistryEntry, b: DeviceRegistryEntry) =>
caseInsensitiveStringCompare(
itemByDeviceId.get(a.id)!.primary,
itemByDeviceId.get(b.id)!.primary,
hass.locale.language
);
topLevel.sort(compareByName);
const ordered: DevicePickerItem[] = [];
for (const device of topLevel) {
const parentItem = itemByDeviceId.get(device.id)!;
const children = childrenByParent.get(device.id);
if (children) {
children.sort(compareByName);
// Add the children's names to the parent's search terms so a search that
// matches a child keeps the parent visible (mirrors how a floor stays
// visible when one of its areas matches).
ordered.push({
...parentItem,
search_labels: {
...parentItem.search_labels,
childDeviceNames: children
.map((child) => itemByDeviceId.get(child.id)!.primary)
.join(" "),
},
});
children.forEach((child, index) => {
ordered.push({
...itemByDeviceId.get(child.id)!,
is_child: true,
last: index === children.length - 1,
});
});
} else {
ordered.push(parentItem);
}
}
return ordered;
};
+69
View File
@@ -81,6 +81,75 @@ export type DeviceRegistryListEntry =
export const isChildDevice = (device: DeviceRegistryEntry): boolean =>
device.parent_device_id !== null;
/**
* Devices whose effective area is the given area: devices with that area, and
* child devices that inherit it because they have no area of their own. Mirrors
* core's dr.async_entries_for_area, so a child device with a different explicit
* area is not part of its parent's area.
*/
export const devicesInEffectiveArea = (
devices: Record<string, DeviceRegistryEntry>,
areaId: string
): DeviceRegistryEntry[] =>
Object.values(devices).filter((device) => {
if (device.area_id) {
return device.area_id === areaId;
}
if (device.parent_device_id) {
return devices[device.parent_device_id]?.area_id === areaId;
}
return false;
});
export interface DeviceRowItem {
device: DeviceRegistryEntry;
isChild: boolean;
// True for the last child of a parent, so the tree connector draws its end.
isLastChild: boolean;
}
/**
* Order a flat device list so each child directly follows its parent, flagging
* children for indented rendering. The incoming order of the top-level devices
* (and of the children within each parent) is preserved. A child whose parent
* is not in the list is treated as a top-level device.
*/
export const groupDevicesByParent = (
devices: DeviceRegistryEntry[]
): DeviceRowItem[] => {
const presentIds = new Set(devices.map((device) => device.id));
const childrenByParent = new Map<string, DeviceRegistryEntry[]>();
const topLevel: DeviceRegistryEntry[] = [];
for (const device of devices) {
const parentId = device.parent_device_id;
if (parentId && presentIds.has(parentId)) {
const siblings = childrenByParent.get(parentId);
if (siblings) {
siblings.push(device);
} else {
childrenByParent.set(parentId, [device]);
}
} else {
topLevel.push(device);
}
}
const result: DeviceRowItem[] = [];
for (const device of topLevel) {
result.push({ device, isChild: false, isLastChild: false });
const children = childrenByParent.get(device.id) ?? [];
children.forEach((child, index) => {
result.push({
device: child,
isChild: true,
isLastChild: index === children.length - 1,
});
});
}
return result;
};
export type DeviceEntityDisplayLookup = Record<
string,
EntityRegistryDisplayEntry[]
+55
View File
@@ -22,6 +22,7 @@ import {
import type { DateRange } from "../common/datetime/calc_date_range";
import { calcDateRange } from "../common/datetime/calc_date_range";
import { formatTime24h } from "../common/datetime/format_time";
import { DEFAULT_ENTITY_NAME } from "../common/entity/compute_entity_name_display";
import { formatNumber } from "../common/number/format_number";
import { normalizeValueBySIPrefix } from "../common/number/normalize-by-si-prefix";
import { groupBy } from "../common/util/group-by";
@@ -36,6 +37,7 @@ import type {
import {
fetchStatistics,
getDisplayUnit,
getStatisticLabel,
getStatisticMetadata,
VOLUME_UNITS,
} from "./recorder";
@@ -311,6 +313,59 @@ export interface EnergySourceByType {
export const energySourcesByType = (prefs: EnergyPreferences) =>
groupBy(prefs.energy_sources, (item) => item.type) as EnergySourceByType;
/**
* Display name of a configured statistic. A name set by the user always wins;
* otherwise the entity is named the same way the rest of the UI names
* entities, so devices sharing an entity name stay distinguishable.
* Statistics without an entity (external or removed) keep the statistic label.
*/
export const computeEnergyLabel = (
hass: HomeAssistant,
statisticId: string,
statisticsMetaData?: StatisticsMetaData,
customName?: string
): string => {
if (customName) {
return customName;
}
const stateObj = hass.states[statisticId];
if (stateObj) {
return hass.formatEntityName(stateObj, DEFAULT_ENTITY_NAME);
}
return getStatisticLabel(hass, statisticId, statisticsMetaData);
};
/**
* Device labels keyed by statistic id. Cards that show live power or flow
* key their nodes by `stat_rate` instead of `stat_consumption`; devices
* without the requested statistic are left out.
*/
export const computeEnergyDeviceLabels = (
hass: HomeAssistant,
devices: DeviceConsumptionEnergyPreference[],
statsMetadata?: Record<string, StatisticsMetaData>,
statisticKey: "stat_consumption" | "stat_rate" = "stat_consumption"
): Record<string, string> => {
const labels: Record<string, string> = {};
for (const device of devices) {
const statisticId = device[statisticKey];
if (statisticId) {
labels[statisticId] = computeEnergyLabel(
hass,
statisticId,
statsMetadata?.[statisticId],
device.name
);
}
}
return labels;
};
export interface EnergyData {
start: Date;
end?: Date;
+75
View File
@@ -0,0 +1,75 @@
import type { LocalizeFunc } from "../../common/translations/localize";
import { SENSOR_NUMERIC_DEVICE_CLASSES } from "../sensor_numeric_device_classes";
export const NO_DEVICE_CLASS = "none";
// Mirrors core's `<Domain>DeviceClass` enums, until the backend exposes them.
export const DOMAIN_DEVICE_CLASSES: Record<string, readonly string[]> = {
binary_sensor: [
"battery",
"battery_charging",
"carbon_monoxide",
"cold",
"connectivity",
"door",
"garage_door",
"gas",
"heat",
"light",
"lock",
"moisture",
"motion",
"moving",
"occupancy",
"opening",
"plug",
"power",
"presence",
"problem",
"running",
"safety",
"smoke",
"sound",
"tamper",
"update",
"vibration",
"window",
],
button: ["identify", "restart", "update"],
cover: [
"awning",
"blind",
"curtain",
"damper",
"door",
"garage",
"gate",
"shade",
"shutter",
"window",
],
event: ["button", "doorbell", "motion"],
humidifier: ["dehumidifier", "humidifier"],
image_processing: ["alpr", "face", "ocr"],
infrared: ["emitter", "receiver"],
media_player: ["projector", "receiver", "speaker", "tv"],
number: SENSOR_NUMERIC_DEVICE_CLASSES,
sensor: [
...SENSOR_NUMERIC_DEVICE_CLASSES,
"date",
"enum",
"timestamp",
"uptime",
],
switch: ["outlet", "switch"],
update: ["firmware"],
valve: ["gas", "water"],
};
export const computeDeviceClassName = (
localize: LocalizeFunc,
domain: string,
deviceClass: string
): string =>
localize(`component.${domain}.entity_component.${deviceClass}.name`) ||
deviceClass;
+91
View File
@@ -0,0 +1,91 @@
import { computeDomain } from "../../common/entity/compute_domain";
import type { HomeAssistant } from "../../types";
import { DOMAIN_DEVICE_CLASSES, NO_DEVICE_CLASS } from "./device_class";
const SEPARATOR = "/";
export interface EntityType {
domain: string;
deviceClass?: string;
}
export const entityTypeKey = (domain: string, deviceClass?: string): string =>
deviceClass ? `${domain}${SEPARATOR}${deviceClass}` : domain;
export const parseEntityType = (key: string): EntityType => {
const index = key.indexOf(SEPARATOR);
return index === -1
? { domain: key }
: {
domain: key.slice(0, index),
deviceClass: key.slice(index + SEPARATOR.length),
};
};
export const entityTypesNeedStates = (types?: string[]): boolean =>
!!types?.some((key) => key.includes(SEPARATOR));
// A domain worth no split maps to an empty list rather than to its lone bucket.
export const usedEntityTypes = (
states: HomeAssistant["states"]
): Map<string, string[]> => {
const byDomain = new Map<string, Set<string>>();
for (const stateObj of Object.values(states)) {
const domain = computeDomain(stateObj.entity_id);
let classes = byDomain.get(domain);
if (!classes) {
classes = new Set();
byDomain.set(domain, classes);
}
if (domain in DOMAIN_DEVICE_CLASSES) {
classes.add(stateObj.attributes.device_class || NO_DEVICE_CLASS);
}
}
return new Map(
[...byDomain].map(([domain, classes]) => [
domain,
classes.size > 1 ? [...classes] : [],
])
);
};
// Relies on a domain and its device classes never being selected at once.
export const entityTypeFilterFunc = (
types: string[],
states: HomeAssistant["states"]
): ((entityId: string) => boolean) => {
const domains = new Set<string>();
const deviceClasses = new Map<string, Set<string>>();
for (const key of types) {
const { domain, deviceClass } = parseEntityType(key);
if (deviceClass === undefined) {
domains.add(domain);
} else {
let classes = deviceClasses.get(domain);
if (!classes) {
classes = new Set();
deviceClasses.set(domain, classes);
}
classes.add(deviceClass);
}
}
return (entityId: string) => {
const domain = computeDomain(entityId);
if (domains.has(domain)) {
return true;
}
const classes = deviceClasses.get(domain);
if (!classes) {
return false;
}
const stateObj = states[entityId];
if (!stateObj) {
return false;
}
return classes.has(stateObj.attributes.device_class || NO_DEVICE_CLASS);
};
};
+1 -1
View File
@@ -76,7 +76,7 @@ export const getLogbookDataForContext = async (
): Promise<LogbookEntry[]> =>
getLogbookDataFromServer(hass, startDate, undefined, undefined, contextId);
const getLogbookDataFromServer = (
export const getLogbookDataFromServer = (
hass: HomeAssistant,
startDate: string,
endDate?: string,
+5 -2
View File
@@ -2,12 +2,15 @@ import type { Connection } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../../../types";
import type { LovelaceResource } from "../resource";
import type { LovelaceStrategyConfig } from "./strategy";
import type { LovelaceViewRawConfig } from "./view";
import type {
LovelaceDashboardBackgroundConfig,
LovelaceViewRawConfig,
} from "./view";
export interface LovelaceDashboardBaseConfig {}
export interface LovelaceConfig extends LovelaceDashboardBaseConfig {
background?: string;
background?: LovelaceDashboardBackgroundConfig;
views: LovelaceViewRawConfig[];
}
+4 -1
View File
@@ -31,6 +31,9 @@ export interface LovelaceViewBackgroundConfig {
attachment?: "scroll" | "fixed";
}
export type LovelaceDashboardBackgroundConfig =
string | LovelaceViewBackgroundConfig;
export interface LovelaceViewHeaderConfig {
card?: LovelaceCardConfig;
layout?: "start" | "center" | "responsive";
@@ -60,7 +63,7 @@ export interface LovelaceBaseViewConfig {
show_icon_and_title?: boolean;
theme?: string;
panel?: boolean;
background?: string | LovelaceViewBackgroundConfig;
background?: LovelaceDashboardBackgroundConfig;
visible?: boolean | ShowViewConfig[];
subview?: boolean;
back_path?: string;
+67
View File
@@ -45,6 +45,73 @@ export interface MatterNodeDiagnostics {
export type MatterPingResult = Record<string, boolean>;
export type MatterTopologyNodeKind =
"matter" | "border_router" | "thread_unknown" | "wifi_ap";
export type MatterTopologyStrength =
"strong" | "medium" | "weak" | "none" | "unknown";
export interface MatterTopologyDirectionInfo {
strength: MatterTopologyStrength;
lqi?: number | null;
rssi?: number | null;
}
export interface MatterNetworkTopologyNode {
id: string;
kind: MatterTopologyNodeKind;
network_type: string;
node_id?: number | null;
ha_device_id?: string | null;
role?: string | null;
available?: boolean | null;
is_bridge?: boolean | null;
ext_address?: string | null;
rloc16?: number | null;
ext_pan_id?: string | null;
network_name?: string | null;
ssid?: string | null;
bssid?: string | null;
host_name?: string | null;
vendor_name?: string | null;
model_name?: string | null;
last_seen?: number | null;
}
export interface MatterNetworkTopologyConnection {
source: string;
target: string;
network: string;
strength: MatterTopologyStrength;
source_to_target?: MatterTopologyDirectionInfo | null;
target_to_source?: MatterTopologyDirectionInfo | null;
via_route_table?: boolean | null;
path_cost?: number | null;
}
export interface MatterNetworkTopology {
collected_at: number;
nodes: MatterNetworkTopologyNode[];
connections: MatterNetworkTopologyConnection[];
}
export const fetchMatterNetworkTopology = (
hass: HomeAssistant,
refresh = false
): Promise<MatterNetworkTopology> =>
hass.callWS({
type: "matter/network_topology",
refresh,
});
export const subscribeMatterNetworkTopology = (
hass: HomeAssistant,
callback: (topology: MatterNetworkTopology) => void
): Promise<UnsubscribeFunc> =>
hass.connection.subscribeMessage<MatterNetworkTopology>(callback, {
type: "matter/subscribe_network_topology",
});
export interface MatterCommissioningParameters {
setup_pin_code: number;
setup_manual_code: string;
+33 -11
View File
@@ -14,6 +14,7 @@ import type {
import type { HomeAssistant } from "../types";
import {
type DeviceRegistryEntry,
devicesInEffectiveArea,
getDeviceIntegrationLookup,
} from "./device/device_registry";
import type {
@@ -726,9 +727,10 @@ export const expandAreaTarget = (
) => {
const newEntities: string[] = [];
const newDevices: string[] = [];
Object.values(devices).forEach((device) => {
// Devices of an area are its effective-area members: a child device inheriting
// this area counts, a child with a different explicit area does not.
devicesInEffectiveArea(devices, areaId).forEach((device) => {
if (
device.area_id === areaId &&
deviceMeetsTargetSelector(
hass.states,
Object.values(entities),
@@ -790,9 +792,8 @@ export const areaMeetsTargetSelector = (
targetSelector: TargetSelector,
entitySources?: EntitySources
): boolean => {
const hasMatchingdevice = Object.values(devices).some((device) => {
if (
device.area_id === areaId &&
const hasMatchingdevice = devicesInEffectiveArea(devices, areaId).some(
(device) =>
deviceMeetsTargetSelector(
hass.states,
Object.values(entities),
@@ -800,11 +801,7 @@ export const areaMeetsTargetSelector = (
targetSelector,
entitySources
)
) {
return true;
}
return false;
});
);
if (hasMatchingdevice) {
return true;
}
@@ -846,6 +843,8 @@ export const deviceMeetsTargetSelector = (
}
}
if (targetSelector.target?.entity) {
// Only the device's own entities: a child device is reached through the
// device target itself, so a parent must not match on a child's behalf.
const entities = entityRegistry.filter(
(reg) => reg.device_id === device.id
);
@@ -1112,6 +1111,11 @@ export const resolveEntityIDs = (
const targetFloors = new Set(ensureArray(targetPickerValue.floor_id));
const targetLabels = new Set(ensureArray(targetPickerValue.label_id));
// Only a directly targeted device pulls in its child devices. Devices that are
// only reached through a label or an area must not, because core does not
// inherit labels to children and resolves areas by effective area membership.
const directDevices = new Set(targetDevices);
targetLabels.forEach((labelId) => {
const expanded = expandLabelTarget(
hass,
@@ -1131,6 +1135,10 @@ export const resolveEntityIDs = (
expanded.areas.forEach((id) => targetAreas.add(id));
});
// Devices only reached through an area do not pull in entities that are
// explicitly assigned to another area, matching core.
const devicesNotViaArea = new Set(targetDevices);
targetAreas.forEach((areaId) => {
const expanded = expandAreaTarget(
hass,
@@ -1143,6 +1151,16 @@ export const resolveEntityIDs = (
expanded.entities.forEach((id) => targetEntities.add(id));
});
// Targeting a device also targets its child devices, matching core's
// server-side target resolution. Only direct device targets expand this way;
// nesting is single-level, so one pass is enough.
Object.values(devices).forEach((device) => {
if (device.parent_device_id && directDevices.has(device.parent_device_id)) {
targetDevices.add(device.id);
devicesNotViaArea.add(device.id);
}
});
targetDevices.forEach((deviceId) => {
const expanded = expandDeviceTarget(
hass,
@@ -1150,7 +1168,11 @@ export const resolveEntityIDs = (
entities,
targetSelector
);
expanded.entities.forEach((id) => targetEntities.add(id));
expanded.entities.forEach((id) => {
if (devicesNotViaArea.has(deviceId) || !entities[id]?.area_id) {
targetEntities.add(id);
}
});
});
return Array.from(targetEntities);
+8 -4
View File
@@ -7,7 +7,10 @@ import type { CallWS, HomeAssistant } from "../types";
import type { AreaRegistryEntry } from "./area/area_registry";
import type { FloorComboBoxItem } from "./area_floor_picker";
import type { DevicePickerItem } from "./device/device_picker";
import type { DeviceRegistryEntry } from "./device/device_registry";
import {
devicesInEffectiveArea,
type DeviceRegistryEntry,
} from "./device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "./entity/entity";
import type { EntityComboBoxItem } from "./entity/entity_picker";
import type { EntityRegistryDisplayEntry } from "./entity/entity_registry";
@@ -125,9 +128,7 @@ export const areaMeetsFilter = (
entityFilter?: HaEntityPickerEntityFilterFunc,
includeSecondary = false
): boolean => {
const areaDevices = Object.values(devices).filter(
(device) => device.area_id === area.area_id
);
const areaDevices = devicesInEffectiveArea(devices, area.area_id);
if (
areaDevices.some((device) =>
@@ -178,6 +179,9 @@ export const deviceMeetsFilter = (
entityFilter?: HaEntityPickerEntityFilterFunc,
includeSecondary = false
): boolean => {
// Only the device's own entities: child devices are targeted through the
// device itself (see core's target resolution), not by making a parent match
// on behalf of a child.
const devEntities = Object.values(entities).filter(
(entity) => entity.device_id === device.id
);
+3
View File
@@ -184,6 +184,7 @@ export const checkForEntityUpdates = async (
}
showToast(element, {
id: "check-updates",
message: hass.localize("ui.panel.config.updates.checking_updates"),
});
@@ -194,6 +195,7 @@ export const checkForEntityUpdates = async (
if (computeDomain(event.data.entity_id) === "update") {
updated++;
showToast(element, {
id: "check-updates",
message: hass.localize("ui.panel.config.updates.updates_refreshed", {
count: updated,
}),
@@ -216,6 +218,7 @@ export const checkForEntityUpdates = async (
if (updated === 0) {
showToast(element, {
id: "check-updates",
message: hass.localize("ui.panel.config.updates.no_new_updates"),
});
}
-2
View File
@@ -22,7 +22,6 @@ export enum VacuumEntityFeature {
STOP = 8,
RETURN_HOME = 16,
FAN_SPEED = 32,
BATTERY = 64,
STATUS = 128,
SEND_COMMAND = 256,
LOCATE = 512,
@@ -34,7 +33,6 @@ export enum VacuumEntityFeature {
}
interface VacuumEntityAttributes extends HassEntityAttributeBase {
battery_level?: number;
fan_speed?: string;
fan_speed_list?: string[];
[key: string]: any;
+3 -2
View File
@@ -566,9 +566,10 @@ export const getWeatherStateIcon = (
if (userDefinedIcon) {
return html`
<div
style="background-size: cover;${styleMap({
style=${styleMap({
"background-size": "cover",
"background-image": userDefinedIcon,
})}"
})}
></div>
`;
}
+22
View File
@@ -1,6 +1,7 @@
import type { Connection, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../types";
import { callWS } from "../util/websocket";
import type { DeviceRegistryEntry } from "./device/device_registry";
export enum InclusionState {
/** The controller isn't doing anything regarding inclusion. */
@@ -465,6 +466,27 @@ export interface RequestedGrant {
clientSideAuth: boolean;
}
/**
* Get the Z-Wave node ID of a device from its registry identifiers, which have
* the form `<home id>-<node id>[-<manufacturer>:<product type>:<product id>]`.
* Returns undefined for devices without a node, e.g. provisioning entries.
*/
export const getNodeIdFromDevice = (
device: DeviceRegistryEntry
): number | undefined => {
for (const [domain, identifier] of device.identifiers) {
// a provisioning entry is identified by its DSK, whose blocks parse as numbers
if (domain !== "zwave_js" || identifier.startsWith("provision_")) {
continue;
}
const nodeId = parseInt(identifier.split("-")[1]);
if (!isNaN(nodeId)) {
return nodeId;
}
}
return undefined;
};
export const invokeZWaveCCApi = <T = unknown>(
hass: HomeAssistant,
device_id: string,
+5 -15
View File
@@ -7,21 +7,11 @@ import { isNumericEntity } from "../../data/history";
import { CONTINUOUS_DOMAINS } from "../../data/logbook";
import type { HomeAssistant } from "../../types";
export const MORE_INFO_VIEWS = [
"info",
"history",
"settings",
"related",
"add_to",
"details",
] as const;
export type MoreInfoView = (typeof MORE_INFO_VIEWS)[number];
export const isMoreInfoView = (
value: string | undefined
): value is MoreInfoView =>
value !== undefined && (MORE_INFO_VIEWS as readonly string[]).includes(value);
export {
isMoreInfoView,
MORE_INFO_VIEWS,
type MoreInfoView,
} from "./more-info-view";
export const DOMAINS_NO_INFO = ["camera", "configurator"];
/**
+9
View File
@@ -0,0 +1,9 @@
import { createContext } from "@lit/context";
export interface MoreInfoContext {
hash: URLSearchParams;
setHashParam: (key: string, value?: string) => void;
}
export const moreInfoContext =
createContext<MoreInfoContext>("more-info-context");
@@ -159,23 +159,6 @@ class MoreInfoVacuum extends LitElement {
`;
}
// Use deprecated battery_level and battery_icon attributes
if (
supportsFeature(this.stateObj, VacuumEntityFeature.BATTERY) &&
this.stateObj.attributes.battery_level
) {
return html`
<span class="battery" slot="after-time">
<span
>${Math.round(
this.stateObj.attributes.battery_level
)}${blankBeforePercent(this._i18n.locale)}%</span
>
<ha-icon .icon=${this.stateObj.attributes.battery_icon}></ha-icon>
</span>
`;
}
return nothing;
}
@@ -11,6 +11,7 @@ import { formatDateWeekdayShort } from "../../../common/datetime/format_date";
import { formatTime } from "../../../common/datetime/format_time";
import { transform } from "../../../common/decorators/transform";
import { formatNumber } from "../../../common/number/format_number";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert";
import "../../../components/ha-relative-time";
import "../../../components/ha-spinner";
@@ -48,11 +49,16 @@ import type {
HomeAssistantFormatters,
HomeAssistantInternationalization,
} from "../../../types";
import { moreInfoContext, type MoreInfoContext } from "../context";
@customElement("more-info-weather")
class MoreInfoWeather extends LitElement {
@property({ attribute: false }) public stateObj?: WeatherEntity;
@state()
@consume({ context: moreInfoContext, subscribe: true })
private _moreInfoContext?: MoreInfoContext;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: HomeAssistantInternationalization;
@@ -127,15 +133,33 @@ class MoreInfoWeather extends LitElement {
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if ((changedProps.has("stateObj") || !this._subscribed) && this.stateObj) {
if (
(changedProps.has("stateObj") ||
changedProps.has("_moreInfoContext") ||
!this._subscribed) &&
this.stateObj
) {
const oldState = changedProps.get("stateObj") as
WeatherEntity | undefined;
if (
oldState?.entity_id !== this.stateObj?.entity_id ||
changedProps.has("_moreInfoContext") ||
!this._subscribed
) {
this._forecastType = getDefaultForecastType(this.stateObj);
this._subscribeForecastEvents();
const supportedForecastTypes = getSupportedForecastTypes(this.stateObj);
const requestedForecastType =
this._moreInfoContext?.hash.get("forecast");
const selectedForecastType =
supportedForecastTypes.find(
(forecastType) => forecastType === requestedForecastType
) ?? getDefaultForecastType(this.stateObj);
if (selectedForecastType !== requestedForecastType) {
this._moreInfoContext?.setHashParam("forecast", selectedForecastType);
}
if (this._forecastType !== selectedForecastType || !this._subscribed) {
this._forecastType = selectedForecastType;
this._subscribeForecastEvents();
}
}
} else if (changedProps.has("_forecastType")) {
this._subscribeForecastEvents();
@@ -509,8 +533,17 @@ class MoreInfoWeather extends LitElement {
`;
}
private _handleForecastTypeChanged(ev: CustomEvent): void {
private _handleForecastTypeChanged(
ev: HASSDomEvent<{ name: ModernForecastType }>
): void {
if (
!this.stateObj ||
!getSupportedForecastTypes(this.stateObj).includes(ev.detail.name)
) {
return;
}
this._forecastType = ev.detail.name;
this._moreInfoContext?.setHashParam("forecast", this._forecastType);
}
static get styles(): CSSResultGroup {
+62 -2
View File
@@ -17,6 +17,7 @@ import {
mdiTransitConnectionVariant,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { provide } from "@lit/context";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -43,8 +44,10 @@ import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-reques
import {
getHistoryState,
navigate,
replaceCurrentUrl,
updateHistoryState,
} from "../../common/navigate";
import { createMoreInfoUrl } from "../../common/url/more-info-query-params";
import type { LocalizeKeys } from "../../common/translations/localize";
import { computeRTL } from "../../common/util/compute_rtl";
import { withViewTransition } from "../../common/util/view-transition";
@@ -85,6 +88,7 @@ import {
EDITABLE_DOMAINS_WITH_UNIQUE_ID,
type MoreInfoView,
} from "./const";
import { moreInfoContext, type MoreInfoContext } from "./context";
import "./controls/more-info-default";
import type { FavoritesDialogContext } from "./favorites";
import { getFavoritesDialogHandler } from "./favorites";
@@ -102,6 +106,9 @@ export interface MoreInfoDialogParams {
tab?: MoreInfoView;
large?: boolean;
data?: Record<string, any>;
hash?: URLSearchParams;
fromUrl?: boolean;
returnUrl?: string;
parentElement?: LitElement;
}
@@ -147,6 +154,12 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
@state() private _data?: Record<string, any>;
@provide({ context: moreInfoContext })
@state()
private _moreInfoContext: MoreInfoContext = this._createMoreInfoContext();
private _returnUrl?: string;
@state() private _currView: MoreInfoView = DEFAULT_VIEW;
@state() private _initialView: MoreInfoView = DEFAULT_VIEW;
@@ -181,6 +194,8 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
const view = params.view || params.tab || DEFAULT_VIEW;
this._data = params.data;
this._moreInfoContext = this._createMoreInfoContext(params.hash);
this._returnUrl = params.returnUrl;
this._currView = view;
this._initialView = view;
this._childViewStack = [];
@@ -216,6 +231,9 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
private _dialogClosed() {
if (this._returnUrl) {
replaceCurrentUrl(this._returnUrl);
}
this._entityId = undefined;
this._parentEntityIds = [];
this._entry = undefined;
@@ -224,6 +242,8 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
this._initialView = DEFAULT_VIEW;
this._currView = DEFAULT_VIEW;
this._childViewStack = [];
this._moreInfoContext = this._createMoreInfoContext();
this._returnUrl = undefined;
this._isEscapeEnabled = true;
window.removeEventListener("dialog-closed", this._enableEscapeKeyClose);
window.removeEventListener("show-dialog", this._disableEscapeKeyClose);
@@ -271,7 +291,10 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
return entity?.device_id ?? null;
}
private _setView(view: MoreInfoView) {
private _setView(view: MoreInfoView, preserveHash = false) {
if (view !== this._currView && !preserveHash) {
this._moreInfoContext = this._createMoreInfoContext();
}
updateHistoryState({
dialogParams: {
...getHistoryState()?.dialogParams,
@@ -279,6 +302,38 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
},
});
this._currView = view;
this._syncUrl();
}
private _syncUrl() {
if (!this._returnUrl || !this._entityId) {
return;
}
replaceCurrentUrl(
createMoreInfoUrl(this._returnUrl, {
entityId: this._entityId,
view: this._currView,
hash: this._moreInfoContext.hash,
})
);
}
private _createMoreInfoContext(hash?: URLSearchParams): MoreInfoContext {
return {
hash: new URLSearchParams(hash),
setHashParam: (key, value) => this._setHashParam(key, value),
};
}
private _setHashParam(key: string, value?: string) {
const hash = new URLSearchParams(this._moreInfoContext.hash);
if (value) {
hash.set(key, value);
} else {
hash.delete(key);
}
this._moreInfoContext = this._createMoreInfoContext(hash);
this._syncUrl();
}
private _goBack() {
@@ -306,7 +361,9 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
if (this._parentEntityIds.length > 0) {
this._entityId = this._parentEntityIds.pop();
this._currView = DEFAULT_VIEW;
this._moreInfoContext = this._createMoreInfoContext();
this._loadEntityRegistryEntry();
this._syncUrl();
}
}
@@ -1007,19 +1064,22 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
const view = ev.detail.view || ev.detail.tab || DEFAULT_VIEW;
if (entityId === this._entityId) {
this._moreInfoContext = this._createMoreInfoContext(ev.detail.hash);
this._infoEditMode = false;
this._detailsYamlMode = false;
this._setView(view);
this._setView(view, true);
return;
}
this._parentEntityIds = [...this._parentEntityIds, this._entityId!];
this._entityId = entityId;
this._moreInfoContext = this._createMoreInfoContext(ev.detail.hash);
this._currView = view === "details" ? view : DEFAULT_VIEW;
this._initialView = view;
this._infoEditMode = false;
this._detailsYamlMode = false;
this._childViewStack = [];
this._loadEntityRegistryEntry();
this._syncUrl();
}
private _enableEscapeKeyClose = () => {
+15
View File
@@ -0,0 +1,15 @@
export const MORE_INFO_VIEWS = [
"info",
"history",
"settings",
"related",
"add_to",
"details",
] as const;
export type MoreInfoView = (typeof MORE_INFO_VIEWS)[number];
export const isMoreInfoView = (
value: string | undefined
): value is MoreInfoView =>
value !== undefined && (MORE_INFO_VIEWS as readonly string[]).includes(value);
-5
View File
@@ -74,11 +74,6 @@ export class MockVacuumEntity extends MockBaseEntity {
stateAttrs.fan_speed = attrs.fan_speed ?? null;
}
if (supportsFeatureFromAttributes(attrs, VacuumEntityFeature.BATTERY)) {
stateAttrs.battery_level = attrs.battery_level ?? null;
stateAttrs.battery_icon = attrs.battery_icon ?? null;
}
if (supportsFeatureFromAttributes(attrs, VacuumEntityFeature.STATUS)) {
stateAttrs.status = attrs.status ?? null;
}
+27 -58
View File
@@ -28,12 +28,13 @@ import type {
SortingDirection,
} from "../components/data-table/ha-data-table";
import { showDataTableSettingsDialog } from "../components/data-table/show-dialog-data-table-settings";
import "../components/ha-adaptive-dialog";
import "../components/ha-button";
import "../components/ha-dialog";
import "../components/ha-dialog-footer";
import "../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../components/ha-dropdown";
import "../components/ha-dropdown-item";
import "../components/ha-filter-pane-chip";
import "../components/ha-icon-button";
import "../components/ha-svg-icon";
import "../components/input/ha-input-search";
@@ -237,20 +238,13 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
const localize = this.localizeFunc || this.hass.localize;
const showPane = this._showPaneController.value ?? !this.narrow;
const filterButton = this.hasFilters
? html`<div class="relative">
<ha-assist-chip
.label=${localize("ui.components.subpage-data-table.filters")}
.active=${this.filters}
@click=${this._toggleFilters}
>
<ha-svg-icon slot="icon" .path=${mdiFilterVariant}></ha-svg-icon>
</ha-assist-chip>
${
this.filters
? html`<div class="badge">${this.filters}</div>`
: nothing
}
</div>`
? html`<ha-filter-pane-chip
.label=${localize("ui.components.subpage-data-table.filters")}
.path=${mdiFilterVariant}
.count=${this.filters}
.active=${!!this.filters}
@click=${this._toggleFilters}
></ha-filter-pane-chip>`
: nothing;
const selectModeBtn =
@@ -471,18 +465,14 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
? nothing
: html`<div class="pane" slot="pane">
<div class="table-header">
<ha-assist-chip
<ha-filter-pane-chip
.label=${localize(
"ui.components.subpage-data-table.filters"
)}
.path=${mdiFilterVariant}
active
@click=${this._toggleFilters}
>
<ha-svg-icon
slot="icon"
.path=${mdiFilterVariant}
></ha-svg-icon>
</ha-assist-chip>
></ha-filter-pane-chip>
${
this.filters
? html`<ha-icon-button
@@ -576,16 +566,17 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
</hass-tabs-subpage>
${
this.showFilters && !showPane
? html`<ha-dialog
.open=${true}
? html`<ha-adaptive-dialog
open
flexcontent
width="full"
header-title=${localize("ui.components.subpage-data-table.filters")}
@closed=${this._closeFilters}
>
<ha-icon-button
slot="headerNavigationIcon"
data-dialog="close"
.path=${mdiClose}
@click=${this._closeFilters}
.label=${localize(
"ui.components.subpage-data-table.close_filter"
)}
@@ -606,13 +597,13 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
<slot name="filter-pane"></slot>
</div>
<ha-dialog-footer slot="footer">
<ha-button slot="primaryAction" @click=${this._closeFilters}>
<ha-button slot="primaryAction" data-dialog="close">
${localize("ui.components.subpage-data-table.show_results", {
number: this.data.length,
})}
</ha-button>
</ha-dialog-footer>
</ha-dialog>`
</ha-adaptive-dialog>`
: nothing
}
`;
@@ -885,24 +876,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
padding: 16px;
}
.badge {
position: absolute;
top: -4px;
right: -4px;
inset-inline-end: -4px;
inset-inline-start: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
.narrow-header-row {
display: flex;
align-items: center;
@@ -951,11 +924,8 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
gap: var(--ha-space-2);
}
.relative {
position: relative;
}
ha-assist-chip {
ha-assist-chip,
ha-filter-pane-chip {
--ha-assist-chip-container-shape: 10px;
--ha-assist-chip-container-color: var(--card-background-color);
}
@@ -965,20 +935,19 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
--md-assist-chip-trailing-space: 8px;
}
ha-dialog {
ha-adaptive-dialog {
--dialog-content-padding: 0;
/* Fixed height so the sheet does not resize while filtering. */
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
--ha-dialog-min-height: calc(var(--safe-height) - var(--ha-space-20));
}
.filter-dialog-content {
height: calc(
100vh -
70px - var(--header-height, 0px) - var(
--safe-area-inset-top,
0px
) - var(--safe-area-inset-bottom, 0px)
);
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow-y: auto;
}
ha-dropdown ha-assist-chip {
+40
View File
@@ -6,12 +6,16 @@ import { storage } from "../common/decorators/storage";
import { isNavigationClick } from "../common/dom/is-navigation-click";
import { navigate } from "../common/navigate";
import type { LocalizeFunc } from "../common/translations/localize";
import { decodeMoreInfoUrl } from "../common/url/more-info-query-params";
import { extractSearchParamsObject } from "../common/url/search-params";
import { afterNextRender } from "../common/util/render-status";
import { fetchHttpConfig } from "../data/http";
import type { HttpConfigState } from "../data/http";
import type { WindowWithPreloads } from "../data/preloads";
import type { RecorderInfo } from "../data/recorder";
import { getRecorderInfo } from "../data/recorder";
import { showHttpPendingConfigDialog } from "../dialogs/http-pending-config/show-dialog-http-pending-config";
import { showMoreInfoDialog } from "../dialogs/more-info/show-ha-more-info-dialog";
import "../resources/custom-card-support";
import { HassElement } from "../state/hass-element";
import QuickBarMixin from "../state/quick-bar-mixin";
@@ -144,6 +148,7 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
// the shadow root. Appending the dialog before that render would let it
// tear the freshly-added dialog straight back out of the DOM.
this.checkHttpPendingConfig();
this._restoreMoreInfoFromUrl();
}
}
@@ -178,6 +183,12 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
// Handle history changes
window.addEventListener("popstate", () => updateRoute());
// Restore a more-info dialog deep-linked in the current URL, if any.
window.addEventListener("location-changed", () =>
this._restoreMoreInfoFromUrl()
);
window.addEventListener("popstate", () => this._restoreMoreInfoFromUrl());
// Handle clicking on links
window.addEventListener("click", (ev) => {
const href = isNavigationClick(ev);
@@ -297,6 +308,35 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
});
}
private _restoreMoreInfoFromUrl() {
// Only restore once the main UI is rendered so the dialog has access to
// the loaded entities.
if (this.render !== this.renderHass) {
return;
}
const searchParams = extractSearchParamsObject();
if (!searchParams["more-info-entity-id"]) {
return;
}
const { entityId, view, hash } = decodeMoreInfoUrl(
window.location.search,
window.location.hash
);
if (!entityId) {
return;
}
// Wait for the next render to ensure the view is fully loaded
// because the more info dialog is closed when the url changes
afterNextRender(() => {
showMoreInfoDialog(this, {
entityId,
view,
hash,
fromUrl: true,
});
});
}
protected async checkDataBaseMigration() {
if (__DEMO__) {
this._databaseMigration = false;
@@ -49,6 +49,7 @@ const SUPPORTED_UI_TYPES = [
const secretTag = defineScalarTag("!secret", {
resolve: (data) => `!secret ${data}`,
identify: () => false,
});
const ADDON_YAML_SCHEMA = YAML11_SCHEMA.withTags(secretTag);
@@ -545,7 +545,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
${this._currentAddon.description}.<br />
<div class="description-text">${this._currentAddon.description}</div>
${this.i18n.localize(
"ui.panel.config.apps.dashboard.visit_app_page",
{
@@ -1658,6 +1658,15 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
color: var(--primary-color);
}
.description:dir(rtl) > .description-text {
text-align: right;
direction: ltr;
}
.long-description {
direction: ltr;
}
img.logo {
max-width: 100%;
max-height: 40px;
@@ -1,7 +1,6 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-yaml-editor";
@@ -9,7 +8,6 @@ import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import { COLLAPSIBLE_ACTION_ELEMENTS } from "../../../../data/action";
import { migrateAutomationAction, type Action } from "../../../../data/script";
import type { HomeAssistant } from "../../../../types";
import { actionSchemaKey, actionToYamlSchema } from "../yaml_schema_helpers";
import "../ha-automation-editor-warning";
import { editorStyles, indentStyle } from "../styles";
import {
@@ -43,14 +41,6 @@ export default class HaAutomationActionEditor extends LitElement {
@query(COLLAPSIBLE_ACTION_ELEMENTS.join(", "))
private _collapsibleElement?: ActionElement;
private _actionYamlSchema = memoizeOne(
(
actionKey: string | undefined,
services: HomeAssistant["services"],
localize: HomeAssistant["localize"]
) => actionToYamlSchema(actionKey, services, localize)
);
protected render() {
const yamlMode = this.yamlMode || !this.uiSupported;
const type = getAutomationActionType(this.action);
@@ -84,11 +74,6 @@ export default class HaAutomationActionEditor extends LitElement {
.defaultValue=${this.action}
@value-changed=${this._onYamlChange}
.readOnly=${this.disabled}
.yamlFieldSchema=${this._actionYamlSchema(
actionSchemaKey(this.action),
this.hass.services,
this.hass.localize
)}
></ha-yaml-editor>
`
: html`
@@ -370,6 +370,7 @@ export class HaAutomationAddSearch extends LitElement {
width: "var(--ha-space-12)",
position: "absolute",
top: "0",
height: "100%",
left: rtl ? undefined : "var(--ha-space-1)",
right: rtl ? "var(--ha-space-1)" : undefined,
transform: rtl ? "scaleX(-1)" : "",
@@ -11,10 +11,6 @@ import { expandConditionWithShorthand } from "../../../../data/automation";
import type { ConditionDescription } from "../../../../data/condition";
import { COLLAPSIBLE_CONDITION_ELEMENTS } from "../../../../data/condition";
import type { HomeAssistant } from "../../../../types";
import {
builtInConditionSchema,
conditionDescriptionToSchema,
} from "../yaml_schema_helpers";
import "../ha-automation-editor-warning";
import { editorStyles, indentStyle } from "../styles";
import type { ConditionElement } from "./ha-automation-condition-row";
@@ -48,22 +44,6 @@ export default class HaAutomationConditionEditor extends LitElement {
@query(COLLAPSIBLE_CONDITION_ELEMENTS.join(", "))
private _collapsibleElement?: ConditionElement;
// Memoized on the condition type rather than the condition itself: the
// condition object gets a new identity on every keystroke, and the schema
// only depends on its type.
private _conditionYamlSchema = memoizeOne(
(
conditionType: string,
description: ConditionDescription | undefined,
localize: HomeAssistant["localize"]
) => {
if (!description) {
return builtInConditionSchema(conditionType, localize);
}
return conditionDescriptionToSchema(conditionType, description, localize);
}
);
private _processedCondition = memoizeOne((condition) =>
expandConditionWithShorthand(condition)
);
@@ -102,11 +82,6 @@ export default class HaAutomationConditionEditor extends LitElement {
.defaultValue=${this.condition}
@value-changed=${this._onYamlChange}
.readOnly=${this.disabled}
.yamlFieldSchema=${this._conditionYamlSchema(
condition.condition,
this.description,
this.hass.localize
)}
></ha-yaml-editor>
`
: html`
@@ -32,54 +32,6 @@ const numericStateConditionStruct = object({
enabled: optional(boolean()),
});
export const YAML_SCHEMA = [
{ name: "entity_id", required: true, selector: { entity: {} } },
{
name: "attribute",
selector: { attribute: { hide_attributes: NON_NUMERIC_ATTRIBUTES } },
context: { filter_entity: "entity_id" },
},
{
name: "above",
selector: {
number: {
mode: "box",
min: Number.MIN_SAFE_INTEGER,
max: Number.MAX_SAFE_INTEGER,
step: 0.1,
},
},
},
{
name: "below",
selector: {
number: {
mode: "box",
min: Number.MIN_SAFE_INTEGER,
max: Number.MAX_SAFE_INTEGER,
step: 0.1,
},
},
},
{ name: "value_template", selector: { template: {} } },
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string => {
switch (fieldName) {
case "entity_id":
return localize("ui.components.entity.entity-picker.entity");
case "attribute":
return localize("ui.components.entity.entity-attribute-picker.attribute");
default:
return localize(
`ui.panel.config.automation.editor.triggers.type.numeric_state.${fieldName}` as any
);
}
};
@customElement("ha-automation-condition-numeric_state")
export default class HaNumericStateCondition extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -290,7 +242,20 @@ export default class HaNumericStateCondition extends LitElement {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string => computeLabel(schema.name, this.hass.localize);
): string => {
switch (schema.name) {
case "entity_id":
return this.hass.localize("ui.components.entity.entity-picker.entity");
case "attribute":
return this.hass.localize(
"ui.components.entity.entity-attribute-picker.attribute"
);
default:
return this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.numeric_state.${schema.name}`
);
}
};
static styles = css`
:host {
@@ -7,6 +7,7 @@ import { createDurationData } from "../../../../../common/datetime/create_durati
import { durationDataToSeconds } from "../../../../../common/datetime/duration_to_seconds";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../../common/dom/stop_propagation";
import { getSelectorFallbackValue } from "../../../../../components/ha-form/get-selector-fallback-value";
import "../../../../../components/ha-checkbox";
import "../../../../../components/ha-selector/ha-selector";
import "../../../../../components/ha-settings-row";
@@ -429,20 +430,8 @@ export class HaPlatformCondition extends LitElement {
Object.entries(this.description).find(([k, _value]) => k === key)?.[1];
let defaultValue = field?.default;
if (
defaultValue == null &&
field?.selector &&
"constant" in field.selector
) {
defaultValue = field.selector.constant?.value;
}
if (
defaultValue == null &&
field?.selector &&
"boolean" in field.selector
) {
defaultValue = false;
if (defaultValue == null && field?.selector) {
defaultValue = getSelectorFallbackValue(field.selector);
}
if (defaultValue != null) {
@@ -19,7 +19,6 @@ import "../../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
import type { StateCondition } from "../../../../../data/automation";
import { STATE_CONDITION_HIDDEN_ATTRIBUTES } from "../../../../../data/entity/entity_attributes";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { HomeAssistant } from "../../../../../types";
import { forDictStruct } from "../../structs";
import type { ConditionElement } from "../ha-automation-condition-row";
@@ -35,7 +34,7 @@ const stateConditionStruct = object({
enabled: optional(boolean()),
});
export const SCHEMA = [
const SCHEMA = [
{ name: "entity_id", required: true, selector: { entity: {} } },
{
name: "attribute",
@@ -62,26 +61,6 @@ export const SCHEMA = [
{ name: "for", selector: { duration: {} } },
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string => {
switch (fieldName) {
case "entity_id":
return localize("ui.components.entity.entity-picker.entity");
case "attribute":
return localize("ui.components.entity.entity-attribute-picker.attribute");
case "for":
return localize(
"ui.panel.config.automation.editor.triggers.type.state.for"
);
default:
return localize(
`ui.panel.config.automation.editor.conditions.type.state.${fieldName}` as any
);
}
};
@customElement("ha-automation-condition-state")
export class HaStateCondition extends LitElement implements ConditionElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -146,7 +125,24 @@ export class HaStateCondition extends LitElement implements ConditionElement {
private _computeLabelCallback = (
schema: SchemaUnion<typeof SCHEMA>
): string => computeLabel(schema.name, this.hass.localize);
): string => {
switch (schema.name) {
case "entity_id":
return this.hass.localize("ui.components.entity.entity-picker.entity");
case "attribute":
return this.hass.localize(
"ui.components.entity.entity-attribute-picker.attribute"
);
case "for":
return this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.state.for`
);
default:
return this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.state.${schema.name}`
);
}
};
static styles = css`
:host {
@@ -14,29 +14,6 @@ type FormType = "before" | "after" | "between";
const BEFORE_DEFAULT = "sunrise";
const AFTER_DEFAULT = "sunset";
export const YAML_SCHEMA = [
{
name: "before",
type: "select" as const,
options: [["sunrise", "sunrise"] as const, ["sunset", "sunset"] as const],
},
{ name: "before_offset", selector: { duration: { allow_negative: true } } },
{
name: "after",
type: "select" as const,
options: [["sunrise", "sunrise"] as const, ["sunset", "sunset"] as const],
},
{ name: "after_offset", selector: { duration: { allow_negative: true } } },
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.conditions.type.sun.${fieldName}` as any
);
@customElement("ha-automation-condition-sun")
export class HaSunCondition extends LitElement implements ConditionElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -177,7 +154,10 @@ export class HaSunCondition extends LitElement implements ConditionElement {
private _computeLabelCallback = (schema: {
name: "before" | "after";
}): string => computeLabel(schema.name, this.hass.localize);
}): string =>
this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.sun.${schema.name}`
);
private _typeSelected(ev: HaSelectSelectEvent): void {
const value = ev.detail.value as FormType;
@@ -5,20 +5,11 @@ import type { HomeAssistant } from "../../../../../types";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
import "../../../../../components/ha-form/ha-form";
import { fireEvent } from "../../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
export const SCHEMA = [
const SCHEMA = [
{ name: "value_template", required: true, selector: { template: {} } },
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.conditions.type.template.${fieldName}` as any
);
@customElement("ha-automation-condition-template")
export class HaTemplateCondition extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -52,7 +43,10 @@ export class HaTemplateCondition extends LitElement {
private _computeLabelCallback = (
schema: SchemaUnion<typeof SCHEMA>
): string => computeLabel(schema.name, this.hass.localize);
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.template.${schema.name}`
);
}
declare global {
@@ -13,24 +13,6 @@ import type { ConditionElement } from "../ha-automation-condition-row";
const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
export const YAML_SCHEMA = [
{ name: "after", selector: { time: {} } },
{ name: "before", selector: { time: {} } },
{
name: "weekday",
type: "multi_select" as const,
options: DAYS.map((d) => [d, d] as const),
},
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.conditions.type.time.${fieldName}` as any
);
@customElement("ha-automation-condition-time")
export class HaTimeCondition extends LitElement implements ConditionElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -202,7 +184,10 @@ export class HaTimeCondition extends LitElement implements ConditionElement {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string => computeLabel(schema.name, this.hass.localize);
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.time.${schema.name}`
);
}
declare global {
@@ -13,7 +13,6 @@ import {
type Trigger,
type TriggerCondition,
} from "../../../../../data/automation";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { HomeAssistant } from "../../../../../types";
const getTriggersIds = (triggers: Trigger[]): string[] => {
@@ -23,20 +22,6 @@ const getTriggersIds = (triggers: Trigger[]): string[] => {
return Array.from(new Set(triggerIds));
};
// Static YAML schema — trigger IDs are dynamic at runtime, so we use a
// plain text selector here to at least provide key completion.
export const YAML_SCHEMA = [
{ name: "id", required: true, selector: { text: { multiple: true } } },
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.conditions.type.trigger.${fieldName}` as any
);
@customElement("ha-automation-condition-trigger")
export class HaTriggerCondition extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -109,7 +94,10 @@ export class HaTriggerCondition extends LitElement {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string => computeLabel(schema.name, this.hass.localize);
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.trigger.${schema.name}`
);
private _automationUpdated(config?: AutomationConfig) {
this._triggerIds = config?.triggers

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