Compare commits

..

172 Commits

Author SHA1 Message Date
Simon Lamon
bfcab8172e Missing toggle 2026-05-01 09:46:51 +00:00
ildar170975
c78cfb4012 Picture card: fix default tap_action (#51819)
* fix default tap_action

* fix default tap_action

* Update hui-picture-card.ts

* use hasAnyAction
2026-05-01 09:50:17 +02:00
ildar170975
09e993ffd6 Helpers, Automations, Scenes & Scripts data tables: add a search by a label (#51794)
* allow to search by a label

* allow to search by a label

* allow to search by a label

* allow to search by a label
2026-05-01 09:44:59 +02:00
Aidan Timson
f8f175426d Improve spacing on assist devtools (#51805) 2026-05-01 09:23:05 +02:00
renovate[bot]
89e3687f22 Update dependency typescript-eslint to v8.59.1 (#51818)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-01 09:36:39 +03:00
ildar170975
18a20576a9 hui-picture-header-footer: use hasAnyAction (#51821)
use hasAnyAction
2026-05-01 09:35:45 +03:00
ildar170975
8ee41e5d9b ha-chart-base: fix vertical misalignment in legend (#51816)
vert alignment of value
2026-05-01 09:35:00 +03:00
Brooke Hatton
cac31ac55a Adjust Copy for maintenance summary card and include unavailable device count (#51815)
* Adjust Copy For summary card

* Further tweak copy and include unavailable devices
2026-04-30 16:47:09 -04:00
Matthias de Baat
8f002f2783 Promote backup encryption key and reorganize backup page (#51806)
* Promote backup encryption key and reorganize backup page

* Polish

* More polish

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-04-30 14:29:16 +00:00
Aidan Timson
df754fcd0d Add gap between hui editors and previews on mobile (#51811) 2026-04-30 15:00:03 +03:00
Aidan Timson
bc4437b3b5 Ally: Add aria labels to ha-icon-button and hui-root (#51784)
* Ally: Add aria labels to ha-icon-button and hui-root

* use aria-hidden

* Add hidden content for label to satisfy ally review

* Make fix in button instead (probably should update upstream)

* Aria label (pending wa update)
2026-04-30 09:20:56 +00:00
Wendelin
c99b43dcf3 Use input button slots for a11y (#51801)
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 09:12:23 +00:00
Bram Kragten
8945b917b3 Add tooltips for Jinja editors (#51792)
* Add descriptions to Jinja2 tags, filters, expressions, tests and variables

All standard Jinja2 tags, filters, and expression completions now carry
info and detail strings so the autocomplete info popover shows meaningful
documentation when users browse them — not just HA-specific functions.

* Add keyboard shortcut tip to the template developer tool

A ha-tip below the editor card now shows users that Ctrl+Space triggers
autocomplete, Ctrl+F opens the search panel, and F11 toggles fullscreen,
making the editor's built-in features more discoverable.

* Add hover tooltips for Jinja2 functions, filters and expressions

Hovering over a function, filter, tag, test, or variable name inside a
Jinja2 template shows a tooltip with its signature and description.
Non-tag completions also get a help-circle icon linking to the
corresponding Home Assistant template-functions documentation page.

The tooltip is rendered as a custom Lit element (ha-code-editor-jinja-hover)
that takes the Completion object and an optional docUrl as properties.

The tooltip source (haJinjaHoverSource) is wired into ha-code-editor
via CodeMirror's hoverTooltip extension. The documentationUrl() helper
is used so the link points to the correct subdomain (www / rc / next)
based on the running HA version.

* Add hover tooltips for Jinja2 hover + arg value tooltips for entity/device/area

Wire haJinjaHoverSource into ha-code-editor via CodeMirror hoverTooltip.
Two types of hover are now shown in jinja2/yaml mode:

- Hovering a function/filter/tag/expression name shows its signature,
  description, and a doc-link icon (non-tags only).
- Hovering a string-literal argument of a known HA Jinja function (e.g.
  states(), device_name(), area_entities()) shows the friendly name,
  current state, device, and area for entity_id arguments; the device
  name and area for device_id arguments; and the area name for area_id
  arguments. The same applies to states["entity_id"] subscripts.

The arg-value tooltip reuses CompletionItem / ha-code-editor-completion-items
(the same component used for autocomplete info popovers) via a new
ha-code-editor-jinja-arg-hover element. HA registry data is passed from
ha-code-editor via a HassArgHoverContext interface to keep jinja_ha_completions.ts
free of HomeAssistant type imports.

* only add tip for autocomplete

* review
2026-04-30 12:07:50 +03:00
Bram Kragten
4d75ea5198 Add inline YAML linting to the yaml code editor (#51791) 2026-04-30 08:42:12 +00:00
Wendelin
ba3a63f856 Fix ha-select undefined value (#51800)
Fix ha-select undefined

Co-authored-by: Copilot <copilot@github.com>
2026-04-30 10:25:26 +03:00
renovate[bot]
fd25d38be6 Update dependency jsdom to v29.1.0 (#51798)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-30 10:18:18 +03:00
Wendelin
ac22374a00 Hide tooltip on mobile clients in ha-sidebar component (#51799) 2026-04-30 10:17:44 +03:00
AlCalzone
de529cc26b Expose Z-Wave exclusion instructions when removing device (#51788)
* Expose Z-Wave exclusion instructions when removing device

* text tweaks

* Apply suggestion from @MindFreeze

* Apply suggestions from code review

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

* bring back comment

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-04-30 06:06:21 +00:00
Aidan Timson
126db3e8df Refactor events devtools tab layout and events output card (#51789)
* Only show events output when there are any, types and margin

* Refactor to use pagination

* Fix

* Simplify, remove pinning and auto-follow, stay on event 1 and allow the user to move around manually

* Show info why only 30 events are keps

* Increase bufffer limit to 100, add explainer and tip when rolls over

* Update disclaimer

* Use buffer position and total instead of event id + total in counter

* Use fixed height and constrain editor

* Cleanup

* Cleanup

* Fix narrow layouts
2026-04-30 08:42:30 +03:00
Matthias de Baat
ed6fd59968 Move preview device analytics button to card (#51787)
* Move preview device analytics button to card

* Add icon back
2026-04-29 17:36:06 +02:00
Paul Bottein
962e941ec9 Merge branch 'master' into dev
# Conflicts:
#	build-scripts/gulp/download-translations.js
#	pyproject.toml
#	src/components/chart/ha-network-graph.ts
#	src/components/date-picker/date-range-picker.ts
#	src/components/date-picker/styles.ts
#	src/components/entity/ha-entity-name-picker.ts
#	src/components/ha-gauge.ts
#	src/components/ha-selector/ha-selector-numeric-threshold.ts
#	src/components/ha-toast.ts
#	src/components/input/ha-input.ts
#	src/data/icons.ts
#	src/dialogs/make-dialog-manager.ts
#	src/panels/config/automation/action/ha-automation-action-row.ts
#	src/panels/config/automation/add-automation-element-dialog.ts
#	src/panels/config/automation/condition/ha-automation-condition-row.ts
#	src/panels/config/automation/condition/types/ha-automation-condition-platform.ts
#	src/panels/config/automation/target/ha-automation-row-targets.ts
#	src/panels/config/automation/trigger/ha-automation-trigger-row.ts
#	src/panels/config/automation/trigger/types/ha-automation-trigger-platform.ts
#	src/panels/config/developer-tools/action/developer-tools-action.ts
#	src/panels/config/devices/ha-config-device-page.ts
#	src/panels/config/entities/entity-registry-settings-editor.ts
#	src/panels/config/ha-panel-config.ts
#	src/panels/config/integrations/integration-panels/zha/zha-device-card.ts
#	src/panels/lovelace/card-features/hui-trend-graph-card-feature.ts
#	src/panels/lovelace/cards/hui-gauge-card.ts
#	src/panels/lovelace/cards/hui-history-graph-card.ts
#	src/panels/lovelace/cards/hui-map-card.ts
#	src/panels/lovelace/sections/hui-section.ts
#	src/panels/lovelace/views/hui-view-footer.ts
#	src/state/quick-bar-mixin.ts
#	yarn.lock
2026-04-29 16:30:58 +02:00
Paul Bottein
31495b2de9 Bumped version to 20260429.0 2026-04-29 16:17:10 +02:00
Bram Kragten
f71dcaeac1 Add automation behavior selector (#30322)
* Add automation behavior selector

* Use mode option instead

* update design

* remove label

* Update src/translations/en.json

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

* Update src/translations/en.json

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

---------

Co-authored-by: Wendelin <w@pe8.at>
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2026-04-29 16:12:17 +02:00
Wendelin
26b1bfbe20 Automations: Flatten triggers/conditions list in pickers (#51785)
* Flat add trigger condition collections

* fix list order

Co-authored-by: Copilot <copilot@github.com>

---------

Co-authored-by: Copilot <copilot@github.com>
2026-04-29 16:11:42 +02:00
Tom Carpenter
c6026507a5 Fix errors loading the demo site (#51695) 2026-04-29 12:04:51 +01:00
Bram Kragten
53b5b43c33 Remove domain prefix from actions (#51278)
* Remove domain prefix from actions

* Only do it for entity domains

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2026-04-29 10:44:20 +00:00
Marcin Bauer
cb31afa432 config-flow: rename "Device created" heading to "Name and assign" (#51782)
The old heading "Device created" was a past-tense status message that didn't
communicate what the user needs to do next. The dialog contains a name field
and an area picker, so "Name and assign" better reflects the user's actual
task at this step.

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 13:39:26 +03:00
Wendelin
d5deff34ea Redesign automation row indicators (#51737)
add new triggered, test and ran chips
2026-04-29 12:04:27 +02:00
Marcin Bauer
1aed9f8b1f Add Ctrl/Cmd+Click legend solo shortcut to shortcuts dialog (#51781)
Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 08:17:09 +00:00
Petar Petrov
34ec052568 Open more-info from energy devices detail graph legend (#51778)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-04-29 07:16:01 +00:00
Petar Petrov
65f11ebc3f Fix duplicate ha-panel-custom on first mount (#51779) 2026-04-29 08:52:25 +02:00
ildar170975
36cadeef40 hui-gauge-card: fix displayed value & unit (#51751)
* Create compute_entity_unit_display.ts

* use computeEntityUnitDisplay()

* fix displayed value & unit

* use value AND valueText

* Update src/common/entity/compute_entity_unit_display.ts

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>

* Apply suggestions from code review

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

---------

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-04-29 06:16:14 +00:00
Bram Kragten
1ed1b7bfda Check for unknown value every render if value is unknown (#51760)
* Check for unknown value every render if value is unknown

* fix race in pickers that add items from the picker

* fix: use value imports for LitElement, html, nothing from lit
2026-04-29 08:55:03 +03:00
ildar170975
e7d9b5348e Dev tools -> Template: align buttons (#51775)
* align buttons

* prettier
2026-04-29 08:53:22 +03:00
Simone Chemelli
fa2c0278cb Add UPTIME sensor device class (#51716)
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 07:20:40 +02:00
Wendelin
fdd0636d9a Media player more info add controls tooltips (#51765)
Add tooltips to media player controls for improved accessibility

Co-authored-by: Copilot <copilot@github.com>
2026-04-29 07:20:02 +02:00
karwosts
39d1173a98 Copy new automation trace picker for scripts (#51776)
* Copy new automation trace picker for scripts

* Use shared component
2026-04-29 07:17:48 +02:00
Bram Kragten
907687c16d Don't just check system domains, for usage, but any domain (#51746) 2026-04-28 21:51:10 +02:00
Joep Meindertsma
3e17779550 feat: Split legend interaction (click vs label) with hover effects (#28517)
* feat: Split legend interaction (click vs label) with icons and hover effects

* Address review feedback on chart legend split

- Rename `externalHiddenState` to `clickLabelForMoreInfo` and the event
  `chart-legend-click` to `legend-label-click` (the `chart-` prefix is
  reserved for echarts-proxy events).
- Use proper translation keys
  `ui.components.history_charts.{toggle_visibility,show_more_info}`
  instead of `ui.common.*` which don't exist.
- Drop the redundant tooltip on the label when it would duplicate the
  icon's "Toggle visibility" tooltip.
- Resolve the legend dataset id back to a real `entity_id` before
  opening more-info: strip known climate-attribute suffixes for history
  charts (`-current_temperature`, `-target_temperature*`, `-heating`/
  `-cooling`/`-drying`/`-fan`), and skip `isExternalStatistic` ids for
  statistics charts. Only fire `hass-more-info` if `hass.states[id]`
  resolves.

* Address followup review feedback on chart legend

- Restore stat-type suffix stripping in statistics chart. When the chart
  has a single entity, ha-chart-base falls back to raw series ids
  (`${statistic_id}-${type}`) for the legend, so clicking
  "sensor.foo (max)" otherwise tries to open `sensor.foo-max`.
- Cover humidifier multi-attribute datasets in the entity-id resolver
  alongside climate / water_heater (`-current_humidity`,
  `-target_humidity`, `-humidifying`, `-on`; `-drying` is already
  contributed by CLIMATE_MODE_CONFIGS). Rename the constant to
  ENTITY_DATASET_SUFFIXES to reflect the broader scope.
- Convert the two legend click targets to `<button type="button">` so
  Tab navigation, Enter/Space activation, and screen-reader semantics
  work. Add `aria-pressed` to the visibility toggle and a
  `:focus-visible` outline. Read the dataset id from `data-id` instead
  of `parentElement.id` so the handlers don't depend on DOM nesting.
- Scope the label hover underline to `.label-clickable` so charts that
  don't opt into more-info (network, sankey, sunburst, energy) keep the
  legacy non-underlined toggle behavior. Render the "Show more info"
  tooltip only when the click will actually fire.
- Drop the redundant physical `margin-right: 0` on the legend toggle;
  `margin-inline-end: 0` alone preserves the click-area extension on
  the start side in RTL.

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-04-28 21:41:42 +02:00
Paul Bottein
c91802d552 Use full width name field for heading badge editor (#51769) 2026-04-28 21:37:02 +02:00
renovate[bot]
40f3d68f8b Update dependency @rspack/dev-server to v2.0.1 (#51764)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-28 21:30:29 +02:00
Paul Bottein
3aa2352f03 Reorder built-in summaries and custom shortcuts in one list (#51763)
* Reorder built-in summaries and custom shortcuts in one list

* Use summary term
2026-04-28 21:29:10 +02:00
puddly
cc98634fad Add recommended ports to serial selector (#51773)
* Allow ha-form to forward context to selectors

* Categorize ports into recommended/not recommended

* Utilize the interface description

* Ignore copy/pasted product -> interface description
2026-04-28 14:46:28 -04:00
ildar170975
d61829e4d4 Dev tools -> Templates: add ha-scrollbar to render-pane (#51770) 2026-04-28 15:39:29 +00:00
Wendelin
95ce9cb8c1 Add input number unit picker (#51768)
Add unit of measurement options and integrate selector in input number form

Co-authored-by: Copilot <copilot@github.com>
2026-04-28 17:25:32 +02:00
Paul Bottein
846a20dd13 Revert global change from tooltip PR (#51766) 2026-04-28 17:06:54 +02:00
Aidan Timson
36031c7365 Fix capitalisation of integration on system log detail (#51762) 2026-04-28 13:27:18 +02:00
Paulus Schoutsen
f99d95232a Show all the power buttons when media player is in assumed state (#51740) 2026-04-28 11:26:06 +02:00
Petar Petrov
5b4c08ad04 Add color and current temperature options to forecast features (#51761) 2026-04-28 11:20:31 +02:00
Petar Petrov
b0b2d84287 Add precipitation visualization to forecast tile features (#51733) 2026-04-28 08:28:15 +01:00
Wendelin
0a43c29fea Simplify and fix target entity count (#51739)
* Simplify and fix target entity count

* Review

Co-authored-by: Copilot <copilot@github.com>

---------

Co-authored-by: Copilot <copilot@github.com>
2026-04-28 06:31:36 +00:00
arcsur
61a8df1fa9 Modified the chart legend for climate temperature data sets (#51719)
* Modified the chart legend for climate temperature data sets to display the temperature value, not the overall entity state (previously hvac_mode).

* Use the helper computeAttributeValueDisplay in src/common/entity/compute_attribute_display.ts to format the temperature attribute values.

Co-authored-by: Copilot <copilot@github.com>

* Update src/components/chart/state-history-chart-line.ts

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-04-28 06:29:45 +00:00
mwolter805
ba3b335f47 Fix ha-panel-custom not restoring after suspendWhenHidden disconnect (#51727)
* fix: rebuild ha-panel-custom on reconnect after suspendWhenHidden

Why:
HA's PartialPanelResolver._onHidden() starts a 5-minute timer when a tab
is backgrounded. When it fires, non-iframe / non-app custom panels
(component_name="custom" without embed_iframe) are removed from the DOM,
which triggers ha-panel-custom.disconnectedCallback() -> _cleanupPanel().
That nulls _setProperties and destroys the child custom-panel element.

When the user returns, _onVisible() re-appends the same <ha-panel-custom>
element. ReactiveElement's base connectedCallback schedules an update,
but update() only calls _createPanel(this.panel) when
changedProps.has("panel") && !deepEqual(oldPanel, this.panel) -- the
panel reference hasn't changed, so nothing happens. _setProperties is
also undefined, so the property-forwarding branch exits early too.
Result: <ha-panel-custom> is present in the DOM but empty -- the user
sees a blank panel until they hard-reload.

Add a connectedCallback() override that rebuilds when the element was
previously cleaned up. Three guards keep it scoped to the recovery path:
  - !this._setProperties: sentinel for "_cleanupPanel ran".
    _setProperties is set inside _createPanel's success paths and only
    nulled in _cleanupPanel.
  - !this.hasChildNodes(): defends against the async window inside
    loadCustomPanel(config).then(...) for non-iframe panels, where a
    rapid detach->attach cycle could otherwise call _createPanel twice
    and append duplicate elements.
  - this.panel: skips first-mount, when the router hasn't assigned a
    panel yet. The existing update() path handles initial _createPanel
    via the changedProps.has("panel") branch.

Mirrors the existing disconnectedCallback override for symmetry.
10 lines + comment, no other file changes.

Affects every non-iframe sidebar custom panel in the HACS ecosystem
(Alarmo, Browser Mod, Homematic(IP) Local, MeshCore, others -- ~60k
combined HA-analytics installs). Reopens dormant home-assistant/frontend
issue #14510 (filed 2022-12-02, stale-bot closed without a fix).

Tests:
- yarn build succeeds locally; the affected chunk is captured for the
  pre-PR local-test on a real HA host.
- Manual repro on user's HA host (10.10.21.221) with the MeshCore
  custom panel: baseline (no patch) reproduces the blank-panel symptom
  after a 6-minute backgrounded tab. Patched chunk: panel rebuilds
  within ~2 seconds of returning. Exemption paths (iframe panel, app
  panel, custom panel with embed_iframe=true) verified to remain
  unaffected. Original chunk restored after testing.
- yarn lint and yarn test pass before PR submission (run before
  Phase 9).

* chore: trigger CLA recheck after author-email link
2026-04-28 08:34:00 +03:00
Wendelin
5449f31162 Fix target picker secondary entities support (#51729)
Add support for secondary entity filtering in target picker
2026-04-28 08:25:41 +03:00
Aidan Timson
1a992aa5f7 Change media browser player to generic picker component (#51734)
* Change media browser player to generic picker component

* Add disabled item support

* Use disabled items

* Sort as original

* Fix divider styling

* Cleanup

Co-authored-by: Copilot <copilot@github.com>

* Remove memo

Co-authored-by: Copilot <copilot@github.com>

* Typing

Co-authored-by: Copilot <copilot@github.com>

* Dont allow disabled items

---------

Co-authored-by: Copilot <copilot@github.com>
2026-04-28 08:24:29 +03:00
ildar170975
6303affe17 Dialogs: call "enlarge" on a wider area (#51750)
* increase an active "enlarge" area

Added a class to make the title span enlargeable.

* increase an active "enlarge" area

* increase an active "enlarge" area
2026-04-28 08:18:30 +03:00
karwosts
fc0ac85223 More detailed trace selector via generic-picker (#51752) 2026-04-28 08:14:32 +03:00
dependabot[bot]
2efc1a658f Bump brace-expansion from 1.1.12 to 1.1.14 (#51753)
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.12 to 1.1.14.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.14)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-28 08:13:32 +03:00
renovate[bot]
371642ef3f Update formatjs monorepo (#51747)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-28 06:54:17 +02:00
Wendelin
aff9ec4345 Improve debug callWs logs (#51741) 2026-04-27 17:36:58 +02:00
Paul Bottein
80e37e7136 Use current entity in state card conditions (#51708) 2026-04-27 14:59:05 +01:00
Yosi Levy
54a234debd Toast location RTL fix (#51735) 2026-04-27 14:04:33 +01:00
Wendelin
eeb0fb3e4d Automation editor: Fix no target set in some actions (#51642)
Refactor target extraction in HaAutomationActionRow for improved legacy support
2026-04-27 14:45:34 +03:00
JLo
d5dc40fa1f Add entity context to media browser player picker (#51732)
* Add entity context to media browser player picker

Show area and device as a secondary line in both the active player pill
and the player picker dropdown, so users can identify speakers when
multiple players share similar names.

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

* Update src/panels/media-browser/ha-bar-media-player.ts

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2026-04-27 10:32:39 +00:00
Paul Bottein
df12232e93 Add attribute support for numeric state and state (#51706) 2026-04-27 10:11:25 +01:00
Wendelin
1a2e63a5ba Add floor/area: Replace popover with dropdown (#51730) 2026-04-27 08:43:05 +01:00
karwosts
581ba23f4e Stabilize more-info group rendering (#51725) 2026-04-27 08:20:23 +03:00
renovate[bot]
5dda82a8ac Update dependency terser-webpack-plugin to v5.5.0 (#51728)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-27 05:53:31 +02:00
Petar Petrov
5a296fadec Minimize Sankey flow crossings with barycenter sort (#51682)
* Minimize Sankey flow crossings with barycenter sort

* Cache section id-index maps across barycenter sweeps

* Thread graph edges through to sortNodesInSections
2026-04-26 21:42:26 +02:00
dependabot[bot]
5dc99a3dbd Bump actions/setup-node from 6.3.0 to 6.4.0 (#51722)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.3.0 to 6.4.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](53b83947a5...48b55a011b)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 6.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-26 13:53:02 +02:00
dependabot[bot]
2f36c64a21 Bump github/codeql-action from 4.35.1 to 4.35.2 (#51721)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.35.1 to 4.35.2.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](c10b8064de...95e58e9a2c)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.35.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-26 13:52:59 +02:00
dependabot[bot]
59cfc82e7a Bump actions/cache from 5.0.4 to 5.0.5 (#51720)
Bumps [actions/cache](https://github.com/actions/cache) from 5.0.4 to 5.0.5.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](668228422a...27d5ce7f10)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: 5.0.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-26 13:52:56 +02:00
Paul Bottein
edc36b28a6 Refactor home panel editor (#51701) 2026-04-25 15:48:12 +02:00
Logan Rosen
7b58162f81 Drop eslint-config-airbnb-base and cherry-pick rules (#51627)
Remove the unmaintained eslint-config-airbnb-base dependency (last
updated Nov 2021, no flat config support) along with its FlatCompat
shim infrastructure.

Replace with js.configs.recommended as the base config and explicitly
cherry-pick ~40 high-value safety and style rules from airbnb-base
that aren't already covered by other configs.

Remove 27 rule disables that only existed to suppress airbnb opinions,
and 5 dead TypeScript rule disables for rules no longer in the config.

Fix 4 real bugs caught by the newly added no-constant-binary-expression
rule where template literals were always truthy, making fallback values
unreachable.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-25 14:16:53 +03:00
dependabot[bot]
9e9f247c79 Bump vite from 8.0.2 to 8.0.9 (#51707)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-04-24 21:16:28 +02:00
Bram Kragten
b95fce5f24 Bumped version to 20260325.8 2026-04-24 17:26:41 +02:00
karwosts
f6abbe80a2 Remove spurious map in original-states view (#51696) 2026-04-24 17:24:44 +02:00
Wendelin
71301ef5be Remove allow-mode-change in quick search (#51634)
Remove allow-mode-change attribute from ha-adaptive-dialog in QuickBar component
2026-04-24 17:24:43 +02:00
ildar170975
4af618ac6f Gauge card: fix a height in Horizontal stack (#51626)
remove styles for ":host"
2026-04-24 17:24:42 +02:00
Simon Lamon
5a21ef67cd Adjust gauge again (#51613) 2026-04-24 17:24:41 +02:00
karwosts
251b9a1b94 Fix gauge missing label on load (#51584) 2026-04-24 17:24:40 +02:00
karwosts
cadaa47bf0 Fix gauge segmentLabels, cleanup sorting (#51583) 2026-04-24 17:24:39 +02:00
Jan Čermák
7ec864dc6d Fix rendering of select with multiple options in SupervisorAppConfig (#51585)
If the app config contains a schema field like this one:

```
privileges:
  - "list(ALTER|CREATE|...|UPDATE)?"
```

it was rendered incorrectly as a drop-down where only one item can be
selected - but this is wrong because of the preceding `-` denoting it
should be a list containing the listed values. Supervisor translated
this to an entry of type `select` with `multiple: true`. The `multiple`
flag wasn't passed along, with the flag set the field renders as
expected.

Fixes #51533
2026-04-24 17:08:12 +02:00
Wendelin
680ceb73e9 ha-select allow number values (#51564) 2026-04-24 17:08:11 +02:00
Wendelin
ab31771055 Improve view footer card visibility handling (#51549) 2026-04-24 17:08:10 +02:00
Wendelin
07b9a6e287 Hide footer if card is not visible (#51544)
* Fix footer visibility logic in render method

* use card-visibility-changed
2026-04-24 17:08:09 +02:00
Wendelin
be2c90cd1c Fix register admin quick search shortcuts (#51540) 2026-04-24 17:08:08 +02:00
Raphael Hehl
2af4ff7c8f Fix history/sensor cards stuck loading after backend restart (#51531)
* Fix history/sensor cards stuck loading after backend restart

- Add { resubscribe: false } to history subscriptions to prevent
  corrupt HistoryStream state on auto-resubscription
- Add connection-status handlers to re-subscribe on reconnect
- Add sentinel pattern to prevent re-entrant async subscriptions
- Add shouldUpdate/updated retry when components become available
- Clear sensor device classes cache on WS error
- Clear _error on reconnect so cards can retry
- Add .catch() on unsubscribe to handle dead subscriptions

* Fix type annotation for callWS in getSensorNumericDeviceClasses

* Address review: type connection-status handlers, add reconnect to history panel

- Use HASSDomEvent<ConnectionStatus> instead of (ev as CustomEvent).detail
  for proper type safety on all connection-status handlers
- Add connection-status handler to ha-panel-history so it re-subscribes
  after backend restart (addresses concern about resubscribe: false)

* Address review: sentinel pattern, reconnect handling, stale data reset

- Add sentinel pattern to ha-more-info-history, ha-panel-history,
  hui-history-graph-card to prevent re-entrant subscription races
- Refactor hui-trend-graph-card-feature from SubscribeMixin to manual
  subscription management with connection-status reconnect support
- Reset stale history/statistics data on reconnect in
  hui-history-graph-card and hui-map-card before re-subscribing
- Wrap fetchStatistics and getSensorNumericDeviceClasses calls in
  ha-panel-history with try/catch to handle errors gracefully
- Chain .catch directly on subscribeHistoryStatesTimeWindow in
  hui-trend-graph-card-feature to avoid detached-promise race condition

* Centralize history stream reconnect handling in data layer

Move the reconnect logic from every consumer into `subscribeHistoryStream`
in data/history.ts. The helper listens to the connection's `ready` event
itself, and on reconnect creates a fresh `HistoryStream` and rebuilds
params (so `start_time` for the time-window variant is re-anchored to
"now"). `resubscribe: false` stays as an internal implementation detail.

Removes the duplicated `_handleConnectionStatus` boilerplate and
`connection-status` window listeners from all six history consumers.

* Render subscription errors and make _error reactive

`_error` was declared as a plain string field in hui-graph-header-footer
and ha-more-info-history (non-reactive) and typed as Error/string while
being assigned the WS error object. hui-trend-graph-card-feature had it
reactive but never rendered it.

Align all three with the hui-history-graph-card pattern: reactive
`{ code, message }` and a user-visible error branch in render(). Without
this, a failed subscription would leave the component stuck on a spinner
forever.

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-04-24 17:08:07 +02:00
Paulus Schoutsen
8139b60248 Add radio_frequency domain entity platform (#51693)
Co-authored-by: balloob <1444314+balloob@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-04-24 15:34:08 +01:00
Wendelin
386372ad00 Use control switch for entity toggle (#51654) 2026-04-24 16:12:33 +02:00
Petar Petrov
9151b200a1 Add energy grid balance card (#51480)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-04-24 14:04:15 +02:00
Bram Kragten
f7e92b484a Bumped version to 20260325.7 2026-04-10 17:44:15 +02:00
Aidan Timson
9fab7bafdb Allow quick search for non-admins, while hiding inaccessible areas (#51456)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-04-10 17:40:25 +02:00
Petar Petrov
0dabb02007 Fix dialog show animation broken by connectedCallback _open sync (#51450) 2026-04-10 17:38:06 +02:00
Petar Petrov
5b73e86786 Fix toast race condition causing stuck notifications (#51447) 2026-04-10 17:38:05 +02:00
Timothy
144d7c5c3f Android externalAppV2 (#51446) 2026-04-10 17:37:08 +02:00
Petar Petrov
8b396dc640 Preserve browser back/forward keyboard shortcuts in tab group (#51439) 2026-04-10 17:33:34 +02:00
Aidan Timson
9bf48d30ab Handle lazy loaded entity registry when editing scripts from more info (#51438)
* Handle lazy loaded entity registry when editing scripts from more info

* Remove extra check

* Fix type of mixin
2026-04-10 17:33:33 +02:00
GeorgeZ83
35fee46f5b Fix media browser dialog window (#51423)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-04-10 17:33:32 +02:00
Niccolò Betto
9ac6636029 Fix code input dialog undefined value concatenation (#51399) 2026-04-10 17:33:31 +02:00
Simon Lamon
136462114d Increase gauge thickness for accessibility reasons (#51382)
Increase thickness for accessability reasons
2026-04-10 17:33:30 +02:00
Bram Kragten
cf542197e0 Bumped version to 20260325.6 2026-04-03 13:01:51 +02:00
Bram Kragten
5c2627624a Always add options object to triggers and conditions (#51394) 2026-04-03 13:01:42 +02:00
Petar Petrov
698ded9d85 Only use inflight map for pending fragment translation loads (#51393) 2026-04-03 13:01:42 +02:00
Tim Ittermann
9a7fb96873 fix: null value error on ha-form-integer (#51385)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2026-04-03 13:01:41 +02:00
Petar Petrov
204c5b5e14 Fix fragment translation race condition returning stale localize (#51381) 2026-04-03 13:01:40 +02:00
Petar Petrov
8ea3acfa98 Load energy translations in dashboard strategy before generating view titles (#51376) 2026-04-03 13:01:39 +02:00
Wendelin
306739773e Fix login on legacy browsers (#51373) 2026-04-03 13:01:38 +02:00
Petar Petrov
8b3fa3adac Fix next_flow dialog closing immediately after rendering (#51369) 2026-04-03 13:01:37 +02:00
Petar Petrov
37a1d59a24 Fix statistics-graph card not rendering self-imported stats (#51367) 2026-04-03 13:01:37 +02:00
Trevin Chow
6812884e00 Guard against orphaned label references in device list (#51359) 2026-04-03 13:01:36 +02:00
Trevin Chow
bf7ef1f7ae Fix TypeError in Voice Assistants expose page with manual entity filters (#51357) 2026-04-03 13:01:35 +02:00
Paul Bottein
fe57f601ba Fix device page entity names not refreshing after device rename (#51355) 2026-04-03 13:01:34 +02:00
Wendelin
c89d478440 Fix input hint height (#51351) 2026-04-03 13:01:33 +02:00
Petar Petrov
fa27d26e5f Fix history-graph card not showing first value (#51350) 2026-04-03 13:01:32 +02:00
Wendelin
18f411ef53 Fix generic picker filter section padding (#51334)
Fix padding in picker section for improved layout
2026-04-03 13:01:31 +02:00
Wendelin
24826e92f0 Fix picker search padding (#51331) 2026-04-03 13:01:30 +02:00
Wendelin
ea9d369d88 Fix date input field shrink (#51330) 2026-04-03 13:01:29 +02:00
Bram Kragten
a9b026d0ef Bumped version to 20260325.5 2026-04-01 11:15:10 +02:00
Petar Petrov
35339906ec Fix layout of compare card in water/gas views (#51329) 2026-04-01 11:14:50 +02:00
Wendelin
ce23f716cc Improve dialog open logic (#51328) 2026-04-01 11:14:49 +02:00
Petar Petrov
aaf8fa199f Await energy translation fragment before generating dashboard strategy (#51327) 2026-04-01 11:14:48 +02:00
Aidan Timson
fba430d507 Fix target item loading error (#51326) 2026-04-01 11:14:47 +02:00
Petar Petrov
59361cbd38 Fix ZHA device count not including devices without entities (#51322) 2026-04-01 11:14:46 +02:00
Petar Petrov
b558117d8c Use ha-card-border-color for integration cards instead of divider-color (#51321) 2026-04-01 11:14:45 +02:00
Petar Petrov
a7c8347751 Fix Fill example data inserting incorrect datetime format (#51320) 2026-04-01 11:14:44 +02:00
Wendelin
31ca9c849a Remove target description (#51315) 2026-04-01 11:14:43 +02:00
Bram Kragten
6252d7e8f5 Bumped version to 20260325.4 2026-03-31 15:36:46 +02:00
Bram Kragten
f42986adf6 Make translation downloading async (#51314) 2026-03-31 15:36:31 +02:00
Bram Kragten
9e70ea3723 Bumped version to 20260325.3 2026-03-31 14:58:38 +02:00
Bram Kragten
de3b7bf513 Fix has target check for actions (#51309) 2026-03-31 14:58:19 +02:00
Petar Petrov
2c5f491c9e Use boundaryFilter data zoom mode only for line charts (#51307) 2026-03-31 14:58:18 +02:00
Wendelin
1ef13c5100 Fix automation add TCA dialog sometimes not opening (#51306) 2026-03-31 14:58:17 +02:00
Aidan Timson
c166335aca Fix above/below numeric state entity formatting (#51298) 2026-03-31 14:51:11 +02:00
Petar Petrov
c64ec21eca Fix x-axis labels for statistics graph month/year periods (#51295) 2026-03-31 14:51:10 +02:00
Norbert Rittel
8d62056f4a Change picker descriptions of triggers to match new style (#51294) 2026-03-31 14:51:09 +02:00
Bram Kragten
62e73608b6 Triggers/conditions Add usage and grouping to new multi domains (#51287) 2026-03-31 14:51:08 +02:00
Wendelin
aa66d8891c Improve date-range-picker mobile presets (#51285) 2026-03-31 14:51:07 +02:00
Paul Bottein
494a96c635 Hide section when all cards are hidden (#51281) 2026-03-31 14:51:06 +02:00
Petar Petrov
36d77f54ce Disable physics by default for large networks (#51277) 2026-03-31 14:51:05 +02:00
Wendelin
12fec9f580 Fix ha-dropdown z-index for legacy browsers (#51276) 2026-03-31 14:51:04 +02:00
Bram Kragten
5f1f55448a Numeric threshold selector: remove duplicate uom from input (#51275) 2026-03-31 14:51:03 +02:00
Paul Bottein
837e345ecf Reduce heading button badge font size and fix alignement (#51274)
Title: Reduce heading button badge font size and fix alignement
2026-03-31 14:51:02 +02:00
Wendelin
0929d7d18a Remove mobile-specific styles for date-range-picker (#51273)
Remove mobile-specific styles for date-picker component
2026-03-31 14:51:01 +02:00
Aidan Timson
70991d3c1e Limit ha-toast width to window, refactor CSS (#51272)
* Limit `ha-toast` width to window and use safe width

* Query assigned slots to stop actions display

* Constrain max-width

* Increase start/end padding
2026-03-31 14:51:00 +02:00
Wendelin
82e5bd62a1 Fix time input background (#51270)
Fix input color tokens
2026-03-31 14:50:59 +02:00
Wendelin
b8adf4e866 Fix date-range-picker preset selection (#51269) 2026-03-31 14:50:58 +02:00
Tom Carpenter
111be984e0 Add date range picker time validation (#51267)
* Fix base time inputs reportValidity() function

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

* Validate the date range picker time inputs

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

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

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

* Validate time inputs on save not value update

In the value changed callback, the update 24->12hr input correction will not have been updated and therefore they will report invalid.
2026-03-31 14:50:57 +02:00
Tom Carpenter
78a2cbb532 Fix new date-range-picker rendering on small screens (#51257) 2026-03-31 14:50:56 +02:00
ildar170975
34b09b140b Map card editor: use context in attribute selector (#30393)
use context in attribute selector
2026-03-31 14:50:55 +02:00
Simon Lamon
f173f901c5 Gauge improvements (#30368)
* Gauge last improvements

* Change needle

* Fixup

* Feedback comments

* Update src/components/ha-gauge.ts

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-03-31 14:50:55 +02:00
Paul Bottein
ebb6ac8d8b Bumped version to 20260325.2 2026-03-27 22:09:10 +01:00
Wendelin
abe214a33a Fix picker field disabled background (#30385) 2026-03-27 22:08:51 +01:00
Paul Bottein
248332ae27 Revert entity naming change (#30384) 2026-03-27 22:08:50 +01:00
Wendelin
82fc2fccdc Automation add TCA: Fix classMap usage (#30380) 2026-03-27 22:08:49 +01:00
Marcin Bauer
c8f30a7ee4 Use dedicated tab copy in automation add dialogs (#30378)
Co-authored-by: Wendelin <w@pe8.at>
2026-03-27 22:08:48 +01:00
Norbert Rittel
77f48d91cd Shorten collection_key_description to fit available space (#30376) 2026-03-27 22:08:47 +01:00
Paul Bottein
caa707a7b1 Only display entity name instead of friendly name in state info (#30365) 2026-03-27 22:08:46 +01:00
Petar Petrov
0bed0fa37e Fix negative currency display on sensor card (#30359) 2026-03-27 22:08:46 +01:00
Bram Kragten
5b6309d984 Numeric threshold selector fixes (#30350)
* Update numeric threshold

* Update ha-selector-numeric-threshold.ts
2026-03-27 22:08:45 +01:00
Aidan Timson
264818bc70 Fix floating ha-toast (#30344) 2026-03-27 22:08:44 +01:00
Bram Kragten
d664ab6836 Bumped version to 20260325.1 2026-03-26 17:08:11 +01:00
Bram Kragten
a6c4184054 Replace ua-parser-js with simple regexs (#30355) 2026-03-26 17:07:45 +01:00
karwosts
cb6985eb7c Stabilize map colors (#30354) 2026-03-26 17:07:44 +01:00
Bram Kragten
d466ab63bd Add target error badge if target is missing (#30352)
* Add target error badge if target is missing

* Don't show for newly added items
2026-03-26 17:07:40 +01:00
Paul Bottein
1132cdb364 Replace computeLovelaceEntityName with hass.formatEntityName (#30351) 2026-03-26 17:07:39 +01:00
Paul Bottein
0f9d48a03d Use hardcoded label for temperature and humidity sensor in climate dashboard (#30348)
* Only use entity name for climate view sensors

* Use hardcoded text
2026-03-26 17:07:38 +01:00
Paul Bottein
7e085d9b08 Fix stack card scrollbar clipping box-shadows (#30346)
* Fix stack card scrollbar clipping box-shadows

* Remove grid options

* Remove scrollbar
2026-03-26 17:07:37 +01:00
Timothy
1a62c7296c Set tap highlight color to transparent for button (#30340) 2026-03-26 17:07:36 +01:00
Petar Petrov
be1921229c Fix energy pie chart legend showing raw data instead of formatted values (#30339) 2026-03-26 17:07:34 +01:00
Paul Bottein
640558ad35 Add composed/text mode toggle to entity name picker (#30337) 2026-03-26 17:07:33 +01:00
sir-Unknown
99636c9719 Fix calendar event description not preserving line breaks (#30329)
Add `white-space: pre-line` to the event description style so that
newlines in the calendar event description are rendered correctly
instead of being collapsed into a single line.
2026-03-26 17:07:32 +01:00
273 changed files with 10178 additions and 5353 deletions

View File

@@ -30,7 +30,7 @@ jobs:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -66,7 +66,7 @@ jobs:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -31,7 +31,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -42,7 +42,7 @@ jobs:
- name: Build resources
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages
- name: Setup lint cache
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
node_modules/.cache/prettier
@@ -67,7 +67,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -87,7 +87,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

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

View File

@@ -31,7 +31,7 @@ jobs:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -67,7 +67,7 @@ jobs:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -24,7 +24,7 @@ jobs:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -29,7 +29,7 @@ jobs:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -30,7 +30,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -39,7 +39,7 @@ jobs:
uses: home-assistant/actions/helpers/verify-version@f6f29a7ee3fa0eccadf3620a7b9ee00ab54ec03b # master
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
@@ -104,7 +104,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
- name: Install dependencies

View File

@@ -42,7 +42,7 @@ class HcDemo extends HassElement {
this._updateHass(hassUpdate),
};
const hass = (this.hass = provideHass(this, initial));
const hass = provideHass(this, initial, true);
mockHistory(hass);

View File

@@ -39,7 +39,7 @@ export class HaDemo extends HomeAssistantAppEl {
this._updateHass(hassUpdate),
};
const hass = (this.hass = provideHass(this, initial));
const hass = provideHass(this, initial, true);
const localizePromise =
// @ts-ignore
this._loadFragmentTranslations(hass.language, "page-demo").then(

View File

@@ -1,4 +1,5 @@
import type { LocalizeFunc } from "../../../src/common/translations/localize";
import type { LovelaceInfo } from "../../../src/data/lovelace/resource";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import {
selectedDemoConfig,
@@ -27,6 +28,9 @@ export const mockLovelace = (
);
});
hass.mockWS("lovelace/info", () =>
Promise.resolve({ resource_mode: "storage" } as LovelaceInfo)
);
hass.mockWS("lovelace/config/save", () => Promise.resolve());
hass.mockWS("lovelace/resources", () => Promise.resolve([]));
hass.mockWS("lovelace/dashboards/list", () => Promise.resolve([]));

View File

@@ -15,7 +15,6 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const generateMeanStatistics = (
start: Date,
end: Date,
// eslint-disable-next-line default-param-last
period: "5minute" | "hour" | "day" | "month" = "hour",
maxDiff: number
): StatisticValue[] => {
@@ -49,7 +48,6 @@ const generateMeanStatistics = (
const generateSumStatistics = (
start: Date,
end: Date,
// eslint-disable-next-line default-param-last
period: "5minute" | "hour" | "day" | "month" = "hour",
initValue: number,
maxDiff: number
@@ -86,7 +84,6 @@ const generateSumStatistics = (
const generateCurvedStatistics = (
start: Date,
end: Date,
// eslint-disable-next-line default-param-last
_period: "5minute" | "hour" | "day" | "month" = "hour",
initValue: number,
maxDiff: number,

View File

@@ -1,58 +1,56 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockSensor = (hass: MockHomeAssistant) => {
hass.mockWS("sensor/numeric_device_classes", () => [
{
numeric_device_classes: [
"volume_storage",
"gas",
"data_size",
"irradiance",
"wind_speed",
"volatile_organic_compounds",
"volatile_organic_compounds_parts",
"voltage",
"frequency",
"precipitation_intensity",
"volume",
"precipitation",
"battery",
"nitrogen_dioxide",
"speed",
"signal_strength",
"pm1",
"nitrous_oxide",
"atmospheric_pressure",
"data_rate",
"temperature",
"power_factor",
"aqi",
"current",
"volume_flow_rate",
"humidity",
"duration",
"ozone",
"distance",
"pressure",
"pm25",
"weight",
"energy",
"carbon_monoxide",
"apparent_power",
"illuminance",
"energy_storage",
"moisture",
"power",
"water",
"carbon_dioxide",
"ph",
"reactive_power",
"monetary",
"nitrogen_monoxide",
"pm10",
"sound_pressure",
"sulphur_dioxide",
],
},
]);
hass.mockWS("sensor/numeric_device_classes", () => ({
numeric_device_classes: [
"volume_storage",
"gas",
"data_size",
"irradiance",
"wind_speed",
"volatile_organic_compounds",
"volatile_organic_compounds_parts",
"voltage",
"frequency",
"precipitation_intensity",
"volume",
"precipitation",
"battery",
"nitrogen_dioxide",
"speed",
"signal_strength",
"pm1",
"nitrous_oxide",
"atmospheric_pressure",
"data_rate",
"temperature",
"power_factor",
"aqi",
"current",
"volume_flow_rate",
"humidity",
"duration",
"ozone",
"distance",
"pressure",
"pm25",
"weight",
"energy",
"carbon_monoxide",
"apparent_power",
"illuminance",
"energy_storage",
"moisture",
"power",
"water",
"carbon_dioxide",
"ph",
"reactive_power",
"monetary",
"nitrogen_monoxide",
"pm10",
"sound_pressure",
"sulphur_dioxide",
],
}));
};

View File

@@ -2,10 +2,7 @@
import unusedImports from "eslint-plugin-unused-imports";
import globals from "globals";
import path from "node:path";
import { fileURLToPath } from "node:url";
import js from "@eslint/js";
import { FlatCompat } from "@eslint/eslintrc";
import tseslint from "typescript-eslint";
import eslintConfigPrettier from "eslint-config-prettier";
import { configs as litConfigs } from "eslint-plugin-lit";
@@ -14,35 +11,8 @@ import { configs as a11yConfigs } from "eslint-plugin-lit-a11y";
import html from "@html-eslint/eslint-plugin";
import importX from "eslint-plugin-import-x";
const _filename = fileURLToPath(import.meta.url);
const _dirname = path.dirname(_filename);
const compat = new FlatCompat({
baseDirectory: _dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
// Load airbnb-base via FlatCompat for non-import rules only.
// eslint-plugin-import is incompatible with ESLint 10 (uses removed APIs),
// so we strip its plugin/rules/settings and use eslint-plugin-import-x instead.
const airbnbConfigs = compat.extends("airbnb-base").map((config) => {
const { plugins = {}, rules = {}, settings = {}, ...rest } = config;
return {
...rest,
plugins: Object.fromEntries(
Object.entries(plugins).filter(([key]) => key !== "import")
),
rules: Object.fromEntries(
Object.entries(rules).filter(([key]) => !key.startsWith("import/"))
),
settings: Object.fromEntries(
Object.entries(settings).filter(([key]) => !key.startsWith("import/"))
),
};
});
export default tseslint.config(
...airbnbConfigs,
js.configs.recommended,
eslintConfigPrettier,
litConfigs["flat/all"],
tseslint.configs.recommended,
@@ -86,35 +56,59 @@ export default tseslint.config(
},
rules: {
"class-methods-use-this": "off",
"new-cap": "off",
"prefer-template": "off",
"object-shorthand": "off",
"func-names": "off",
"no-underscore-dangle": "off",
strict: "off",
"no-plusplus": "off",
"no-bitwise": "error",
"comma-dangle": "off",
"vars-on-top": "off",
"no-continue": "off",
"no-param-reassign": "off",
"no-multi-assign": "off",
"no-console": "error",
radix: "off",
"no-alert": "off",
"no-nested-ternary": "off",
"prefer-destructuring": "off",
"no-restricted-globals": [2, "event"],
"prefer-promise-reject-errors": "off",
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
"object-curly-newline": "off",
"default-case": "off",
"wc/no-self-class": "off",
"no-shadow": "off",
"no-use-before-define": "off",
"array-callback-return": ["error", { allowImplicit: true }],
"block-scoped-var": "error",
"consistent-return": "error",
curly: ["error", "multi-line"],
"default-case-last": "error",
eqeqeq: ["error", "always", { null: "ignore" }],
"guard-for-in": "error",
"no-await-in-loop": "error",
"no-caller": "error",
"no-constructor-return": "error",
"no-eval": "error",
"no-extend-native": "error",
"no-implied-eval": "error",
"no-iterator": "error",
"no-new-func": "error",
"no-new-wrappers": "error",
"no-octal-escape": "error",
"no-promise-executor-return": "error",
"no-return-assign": ["error", "always"],
"no-script-url": "error",
"no-self-compare": "error",
"no-sequences": "error",
"no-template-curly-in-string": "error",
"no-unreachable-loop": "error",
// import-x rules (migrated from eslint-plugin-import / airbnb-base)
"no-else-return": ["error", { allowElseIf: false }],
"no-lonely-if": "error",
"no-unneeded-ternary": ["error", { defaultAssignment: false }],
"no-useless-computed-key": "error",
"no-useless-concat": "error",
"no-useless-rename": "error",
"no-useless-return": "error",
"one-var": ["error", "never"],
"operator-assignment": ["error", "always"],
"prefer-arrow-callback": "error",
"prefer-exponentiation-operator": "error",
"prefer-object-spread": "error",
"prefer-regex-literals": ["error", { disallowRedundantWrapping: true }],
"symbol-description": "error",
yoda: "error",
// TODO: Enable once violations are fixed (43 instances as of 2026-04)
// "no-useless-assignment": "error",
"no-useless-assignment": "error",
// Project rules
"no-bitwise": "error",
"no-console": "error",
"no-restricted-globals": [2, "event"],
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
"wc/no-self-class": "off",
// import-x rules
"import-x/named": "off",
"import-x/prefer-default-export": "off",
"import-x/no-default-export": "off",
@@ -146,13 +140,9 @@ export default tseslint.config(
"import-x/no-relative-packages": "error",
// TypeScript rules
"@typescript-eslint/camelcase": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-use-before-define": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-shadow": ["error"],
"@typescript-eslint/naming-convention": [
@@ -216,7 +206,6 @@ export default tseslint.config(
"lit-a11y/role-has-required-aria-attrs": "error",
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/no-import-type-side-effects": "error",
camelcase: "off",
"@typescript-eslint/no-dynamic-delete": "off",
"@typescript-eslint/no-empty-object-type": [
"error",

View File

@@ -1,17 +1,22 @@
import "@material/mwc-drawer";
import "@material/mwc-top-app-bar-fixed";
import { mdiMenu } from "@mdi/js";
import { mdiMenu, mdiSwapHorizontal } from "@mdi/js";
import type { PropertyValues } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, query, state } from "lit/decorators";
import { dynamicElement } from "../../src/common/dom/dynamic-element-directive";
import { setDirectionStyles } from "../../src/common/util/compute_rtl";
import "../../src/components/ha-button";
import { HaExpansionPanel } from "../../src/components/ha-expansion-panel";
import "../../src/components/ha-icon-button";
import "../../src/components/ha-svg-icon";
import "../../src/managers/notification-manager";
import { haStyle } from "../../src/resources/styles";
import { PAGES, SIDEBAR } from "../build/import-pages";
import "./components/page-description";
const RTL_STORAGE_KEY = "gallery-rtl";
const GITHUB_DEMO_URL =
"https://github.com/home-assistant/frontend/blob/dev/gallery/src/pages/";
@@ -29,6 +34,8 @@ class HaGallery extends LitElement {
document.location.hash.substring(1) ||
`${SIDEBAR[0].category}/${SIDEBAR[0].pages![0]}`;
@state() private _rtl = localStorage.getItem(RTL_STORAGE_KEY) === "true";
@query("notification-manager")
private _notifications!: HTMLElementTagNameMap["notification-manager"];
@@ -97,33 +104,43 @@ class HaGallery extends LitElement {
${dynamicElement(`demo-${this._page.replace("/", "-")}`)}
</div>
<div class="page-footer">
<div class="header">Help us to improve our documentation</div>
<div class="secondary">
Suggest an edit to this page, or provide/view feedback for this
page.
<div class="edit-docs">
<div class="header">Help us to improve our documentation</div>
<div class="secondary">
Suggest an edit to this page, or provide/view feedback for this
page.
</div>
<div>
${PAGES[this._page].description ||
Object.keys(PAGES[this._page].metadata).length > 0
? html`
<a
href=${`${GITHUB_DEMO_URL}${this._page}.markdown`}
target="_blank"
>
Edit text
</a>
`
: ""}
${PAGES[this._page].demo
? html`
<a
href=${`${GITHUB_DEMO_URL}${this._page}.ts`}
target="_blank"
>
Edit demo
</a>
`
: ""}
</div>
</div>
<div>
${PAGES[this._page].description ||
Object.keys(PAGES[this._page].metadata).length > 0
? html`
<a
href=${`${GITHUB_DEMO_URL}${this._page}.markdown`}
target="_blank"
>
Edit text
</a>
`
: ""}
${PAGES[this._page].demo
? html`
<a
href=${`${GITHUB_DEMO_URL}${this._page}.ts`}
target="_blank"
>
Edit demo
</a>
`
: ""}
<div class="rtl-toggle">
<ha-icon-button
@click=${this._toggleRtl}
.label=${this._rtl ? "Switch to LTR" : "Switch to RTL"}
>
<ha-svg-icon .path=${mdiSwapHorizontal}></ha-svg-icon>
</ha-icon-button>
</div>
</div>
</div>
@@ -138,6 +155,8 @@ class HaGallery extends LitElement {
firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this._applyDirection();
this.addEventListener("show-notification", (ev) =>
this._notifications.showDialog({ message: ev.detail.message })
);
@@ -164,6 +183,11 @@ class HaGallery extends LitElement {
updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("_rtl")) {
this._applyDirection();
}
if (!changedProps.has("_page")) {
return;
}
@@ -186,6 +210,15 @@ class HaGallery extends LitElement {
this._drawer.open = !this._drawer.open;
}
private _toggleRtl() {
this._rtl = !this._rtl;
localStorage.setItem(RTL_STORAGE_KEY, String(this._rtl));
}
private _applyDirection() {
setDirectionStyles(this._rtl ? "rtl" : "ltr", this);
}
static styles = [
haStyle,
css`
@@ -238,11 +271,16 @@ class HaGallery extends LitElement {
}
.page-footer {
display: flex;
border-radius: var(--ha-border-radius-lg);
background-color: var(--primary-background-color);
}
.edit-docs {
flex: 1;
text-align: center;
margin: 16px;
padding: 16px;
border-radius: var(--ha-border-radius-lg);
background-color: var(--primary-background-color);
}
.page-footer div {
@@ -266,6 +304,18 @@ class HaGallery extends LitElement {
margin: 0 8px;
text-decoration: none;
}
.rtl-toggle {
padding: var(--ha-space-4);
display: inline-flex;
align-items: flex-end;
margin-top: 12px !important;
}
.rtl-toggle ha-icon-button {
border: 1px solid var(--divider-color);
border-radius: var(--ha-border-radius-pill);
}
`,
];
}

View File

@@ -9,6 +9,7 @@ import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { repeat } from "lit/directives/repeat";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-control-switch";
@@ -50,59 +51,100 @@ export class DemoHaControlSwitch extends LitElement {
protected render(): TemplateResult {
return html`
${repeat(switches, (sw) => {
const { id, label, ...config } = sw;
return html`
<ha-card>
<div class="card-content">
<label id=${id}>${label}</label>
<pre>Config: ${JSON.stringify(config)}</pre>
<ha-control-switch
.checked=${this.checked}
class=${ifDefined(config.class)}
@change=${this.handleValueChanged}
.pathOn=${mdiLightbulb}
.pathOff=${mdiLightbulbOff}
.label=${label}
?disabled=${config.disabled}
?reversed=${config.reversed}
>
</ha-control-switch>
<div class="themes">
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-control-switch ${mode}">
${repeat(switches, (sw) => {
const { id, label, ...config } = sw;
return html`
<div class="card-content">
<label id="${mode}-${id}">${label}</label>
<pre>Config: ${JSON.stringify(config)}</pre>
<ha-control-switch
.checked=${this.checked}
class=${ifDefined(config.class)}
@change=${this.handleValueChanged}
.pathOn=${mdiLightbulb}
.pathOff=${mdiLightbulbOff}
.label=${label}
?disabled=${config.disabled}
?reversed=${config.reversed}
>
</ha-control-switch>
</div>
`;
})}
<div class="card-content">
<p class="title"><b>Vertical</b></p>
<div class="vertical-switches">
${repeat(switches, (sw) => {
const { label, ...config } = sw;
return html`
<ha-control-switch
.checked=${this.checked}
vertical
class=${ifDefined(config.class)}
@change=${this.handleValueChanged}
.label=${label}
.pathOn=${mdiGarageOpen}
.pathOff=${mdiGarage}
?disabled=${config.disabled}
?reversed=${config.reversed}
>
</ha-control-switch>
`;
})}
</div>
</div>
</ha-card>
</div>
</ha-card>
`;
})}
<ha-card>
<div class="card-content">
<p class="title"><b>Vertical</b></p>
<div class="vertical-switches">
${repeat(switches, (sw) => {
const { id, label, ...config } = sw;
return html`
<ha-control-switch
.checked=${this.checked}
vertical
class=${ifDefined(config.class)}
@change=${this.handleValueChanged}
.label=${label}
.pathOn=${mdiGarageOpen}
.pathOff=${mdiGarage}
?disabled=${config.disabled}
?reversed=${config.reversed}
>
</ha-control-switch>
`;
})}
</div>
</div>
</ha-card>
`
)}
</div>
`;
}
firstUpdated(changedProps) {
super.firstUpdated(changedProps);
applyThemesOnElement(
this.shadowRoot!.querySelector(".dark"),
{
default_theme: "default",
default_dark_theme: "default",
themes: {},
darkMode: true,
theme: "default",
},
undefined,
undefined,
true
);
}
static styles = css`
:host {
display: block;
}
.themes {
display: flex;
flex-direction: row;
justify-content: center;
flex-wrap: wrap;
gap: 16px;
padding: 16px;
}
.dark,
.light {
display: block;
background-color: var(--primary-background-color);
padding: 16px;
border-radius: var(--ha-border-radius-md);
}
ha-card {
max-width: 600px;
margin: 24px auto;
margin: 0 auto;
}
pre {
margin-top: 0;

View File

@@ -27,7 +27,6 @@ export class DemoHaInput extends LitElement {
constructor() {
super();
// Provides internationalizationContext for ha-input-copy, ha-input-multi and ha-input-search
// eslint-disable-next-line no-new
new ContextProvider(this, {
context: internationalizationContext,
initialValue: {

View File

@@ -1,188 +0,0 @@
---
title: List
---
# List
The list family provides accessible, keyboard-navigable list containers and
item variants. Pick the container based on semantics, then the item based on
interactivity.
## Containers
### `<ha-list-base>`
A styled container with roving-tabindex keyboard navigation. Host role is
`list`. Children should be `<ha-list-item-*>`. Arrow keys rove focus;
Home/End jump to the first/last enabled item; Enter/Space activates the
focused item.
**Attributes**
| Name | Type | Default | Description |
| ------------ | ------- | ------- | ------------------------------ |
| `wrap-focus` | Boolean | `false` | Arrow keys wrap past the ends. |
| `aria-label` | String | — | Accessible name. |
**Events**
- `ha-list-activated` — Enter/Space on a focused item. Detail
`{ index: number, item: HaListItemBase }`.
**Methods**
- `focus()` — focus the active item (or the first focusable one).
- `focusItemAtIndex(index)` — make the item at `index` active and focus it.
- `getActiveItemIndex()` — current active index, or `-1`.
- `setActiveItemIndex(index, focusItem?)` — move the active index without
necessarily focusing.
- `updateListItems()` — re-discover slotted items (called automatically on
slotchange).
**CSS parts**
- `base` — the outer `<div role="list">`.
**CSS custom properties**
- `--ha-list-gap` — spacing between items. Defaults to `0`.
- `--ha-list-padding` — padding around the list. Defaults to `0`.
### `<ha-list-selectable>`
Selectable list. Extends `ha-list-base`. Host role is `listbox`; items must be
`<ha-list-item-option>` (role `option`). Set `multi` for multi-select; the
host reflects `aria-multiselectable`.
**Attributes**
| Name | Type | Default | Description |
| ------- | ------- | ------- | -------------------------------------- |
| `multi` | Boolean | `false` | Allow multiple options to be selected. |
**Events**
- `ha-list-selected` — selection changed. Detail
`{ index: number | Set<number>, diff: { added: Set<number>, removed: Set<number> } }`.
`index` is a `number` in single mode (`-1` when nothing selected) and a
`Set<number>` in multi mode.
**Methods / getters**
- `selected` (getter) — current selection (`number` or `Set<number>`).
- `selectedItems` (getter) — selected `HaListItemOption` elements, in index
order.
- `setSelected(indices)` — replace the entire selection.
- `select(index)` — add `index` to the selection (replaces in single mode).
- `toggle(index, force?)` — toggle a single index, or force on/off.
- `clearSelection()` — clear all.
### `<ha-list-nav>`
Same as `ha-list-base`, but wrapped in a `<nav>` landmark
(`<nav><div role="list">…</div></nav>`). Use `aria-label` to name the
landmark — the value is forwarded to the inner `<nav>`. Items should be
`<ha-list-item-button>` with an `href`.
**CSS parts**
- `nav` — the `<nav>` wrapper.
- `base` — the inner `<div role="list">`.
## Items
All items inherit from `ha-row-item`, which provides the row layout and the
shared slots/attributes below.
### Shared row layout (`ha-row-item`)
**Slots**
- `start` — leading container (icon/avatar).
- `end` — trailing container (meta/chevron).
- `headline` — primary text (overrides the `headline` attribute).
- `supporting-text` — secondary text (overrides the `supporting-text` attribute).
- `content` — escape hatch: replaces the entire middle column.
**Attributes**
| Name | Type | Default | Description |
| ----------------- | ------- | ------- | --------------------------------------- |
| `headline` | String | — | Primary text. Overridden by the slot. |
| `supporting-text` | String | — | Secondary text. Overridden by the slot. |
| `disabled` | Boolean | `false` | Dims the row and blocks pointer events. |
**CSS parts**
`base`, `start`, `content`, `headline`, `supporting-text`, `end`.
**CSS custom properties**
- `--ha-row-item-padding-block` — vertical padding.
- `--ha-row-item-padding-inline` — horizontal padding.
- `--ha-row-item-gap` — gap between `start`, `content`, and `end`.
- `--ha-row-item-min-height` — minimum row height (default `48px`).
### `<ha-list-item-base>`
Non-interactive list row. Host role is `listitem`. Inherits everything from
`ha-row-item`.
**Attributes**
- `interactive` (Boolean, default `false`) — opt this row into the parent
list's roving tabindex. Useful for sortable rows that need keyboard focus
but no click action. Interactive subclasses set this automatically.
**CSS custom properties**
- `--ha-list-item-focus-radius` — focus outline border-radius.
- `--ha-list-item-focus-width` — focus outline width (steady state).
- `--ha-list-item-focus-width-start` — focus outline width at the start of
the focus-in animation.
- `--ha-list-item-focus-offset` — focus outline offset.
- `--ha-list-item-focus-background` — background color on keyboard focus.
### `<ha-list-item-button>`
Interactive row. Renders an inner `<a>` when `href` is set, otherwise a
`<button>`. The full row is the hit target. When placed inside a list using
roving tabindex, the host is the tab stop and the inner element carries
`tabindex="-1"`.
**Attributes**
- `href` (String) — when set, renders an `<a>` instead of a `<button>`.
- `target` (String) — anchor `target` (requires `href`).
- `rel` (String) — anchor `rel` (requires `href`).
- `download` (String) — anchor `download` (requires `href`).
**CSS parts**
- `ripple` — the ripple effect element.
### `<ha-list-item-option>`
Selectable row. Host role is `option`; reflects `aria-selected`. Designed to
sit inside `<ha-list-selectable>`, which owns selection state and toggles
`selected` on this item — the option itself does not fire selection events.
**Attributes**
- `selected` (Boolean, default `false`, reflected) — set by the parent
`ha-list-selectable`.
- `value` (String) — value identifying the option.
- `appearance` (`"line"` | `"checkbox"`, default `"line"`) — `"line"`
highlights the row; `"checkbox"` renders a decorative `<ha-checkbox>`.
- `selection-position` (`"start"` | `"end"`, default `"start"`) — side the
checkbox sits on when `appearance="checkbox"`.
**CSS parts**
- `checkbox` — wrapper around the `<ha-checkbox>` when `appearance="checkbox"`.
- `ripple` — the ripple effect element.
**CSS custom properties**
- `--ha-list-item-selected-background` — background color when selected
(`appearance="line"`).

View File

@@ -1,415 +0,0 @@
import {
mdiAccount,
mdiChevronRight,
mdiCog,
mdiHome,
mdiInformationOutline,
mdiMapMarker,
mdiOpenInNew,
mdiViewDashboard,
mdiWifi,
} from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-svg-icon";
import "../../../../src/components/item/ha-list-item-base";
import "../../../../src/components/item/ha-list-item-button";
import "../../../../src/components/item/ha-list-item-option";
import "../../../../src/components/list/ha-list-base";
import "../../../../src/components/list/ha-list-nav";
import "../../../../src/components/list/ha-list-selectable";
import type { HaListSelectedDetail } from "../../../../src/components/list/types";
type Appearance = "line" | "checkbox";
type Position = "start" | "end";
const appearances: Appearance[] = ["line", "checkbox"];
const positions: Position[] = ["start", "end"];
const selectedStates = [false, true];
const disabledStates = [false, true];
@customElement("demo-components-ha-list")
export class DemoHaList extends LitElement {
@state() private _buttonClicks = 0;
@state() private _single: number | Set<number> = -1;
@state() private _multiLine: number | Set<number> = new Set();
@state() private _multiCheckStart: number | Set<number> = new Set();
@state() private _multiCheckEnd: number | Set<number> = new Set();
private _options = ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"];
protected render(): TemplateResult {
return html`
<h2>ha-list-base</h2>
<p>
Styled container with keyboard focus navigation. Children should be
<code>ha-list-item-*</code>.
</p>
<ha-card header="Info list (non-interactive rows)">
<ha-list-base aria-label="Device info">
<ha-list-item-base
headline="IP address"
supporting-text="192.168.1.42"
>
<ha-svg-icon slot="start" .path=${mdiWifi}></ha-svg-icon>
</ha-list-item-base>
<ha-list-item-base headline="Location" supporting-text="Living room">
<ha-svg-icon slot="start" .path=${mdiMapMarker}></ha-svg-icon>
</ha-list-item-base>
<ha-list-item-base headline="Firmware" supporting-text="2026.4.1">
<ha-svg-icon
slot="start"
.path=${mdiInformationOutline}
></ha-svg-icon>
</ha-list-item-base>
</ha-list-base>
</ha-card>
<ha-card header="Vertical list (default)">
<ha-list-base aria-label="Example list">
<ha-list-item-button>
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
<span slot="headline">First row</span>
<span slot="supporting-text">Supporting text</span>
<ha-svg-icon slot="end" .path=${mdiChevronRight}></ha-svg-icon>
</ha-list-item-button>
<ha-list-item-button>
<ha-svg-icon slot="start" .path=${mdiAccount}></ha-svg-icon>
<span slot="headline">Second row</span>
</ha-list-item-button>
<ha-list-item-button disabled>
<span slot="headline">Disabled row</span>
</ha-list-item-button>
<ha-list-item-button>
<span slot="headline">Fourth row</span>
</ha-list-item-button>
</ha-list-base>
</ha-card>
<ha-card header="Vertical list with wrap-focus">
<ha-list-base wrap-focus aria-label="Wrap focus">
<ha-list-item-button>
<span slot="headline">A</span>
</ha-list-item-button>
<ha-list-item-button>
<span slot="headline">B</span>
</ha-list-item-button>
<ha-list-item-button>
<span slot="headline">C</span>
</ha-list-item-button>
</ha-list-base>
</ha-card>
<h2>ha-list-item-base</h2>
<p>Non-interactive base row with slot permutations.</p>
<ha-card header="Slot permutations">
<ha-list-base aria-label="Slot permutations">
<ha-list-item-base headline="Headline only"></ha-list-item-base>
<ha-list-item-base
headline="Headline"
supporting-text="Supporting text"
></ha-list-item-base>
<ha-list-item-base headline="Start + headline">
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
</ha-list-item-base>
<ha-list-item-base headline="Start + headline + end">
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
<ha-svg-icon slot="end" .path=${mdiChevronRight}></ha-svg-icon>
</ha-list-item-base>
<ha-list-item-base
headline="Full row"
supporting-text="All slots filled"
>
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
<ha-svg-icon slot="end" .path=${mdiChevronRight}></ha-svg-icon>
</ha-list-item-base>
<ha-list-item-base>
<div slot="content" class="custom-content">
<strong>Custom content escape hatch</strong>
<span>Replaces the whole middle column</span>
</div>
</ha-list-item-base>
<ha-list-item-base headline="Disabled row" disabled>
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
</ha-list-item-base>
</ha-list-base>
</ha-card>
<h2>ha-list-item-button</h2>
<p>
Interactive row. Renders an inner <code>&lt;a&gt;</code> when
<code>href</code> is set, otherwise a <code>&lt;button&gt;</code>.
</p>
<ha-card header="Button (default) / link (with href)">
<ha-list-base aria-label="Button items">
<ha-list-item-button @click=${this._onButtonClick}>
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
<span slot="headline">Button (clicks: ${this._buttonClicks})</span>
</ha-list-item-button>
<ha-list-item-button
href="https://www.home-assistant.io/"
target="_blank"
rel="noopener noreferrer"
>
<ha-svg-icon slot="start" .path=${mdiOpenInNew}></ha-svg-icon>
<span slot="headline">Link (opens in new tab)</span>
<span slot="supporting-text"
>Cmd/Ctrl-click still opens in new tab</span
>
</ha-list-item-button>
<ha-list-item-button disabled>
<span slot="headline">Disabled button</span>
</ha-list-item-button>
<ha-list-item-button href="#nope" disabled>
<span slot="headline">Disabled link</span>
</ha-list-item-button>
</ha-list-base>
</ha-card>
<h2>ha-list-selectable + ha-list-item-option</h2>
<p>
Selectable list (<code>role="listbox"</code>). Items must be
<code>ha-list-item-option</code>. Set <code>multi</code> for
multi-select.
</p>
<ha-card header="Single select, appearance=line">
<ha-list-selectable
aria-label="Single select"
@ha-list-selected=${this._onSingle}
>
${this._options.map(
(o, i) => html`
<ha-list-item-option
.value=${o}
?selected=${this._isSel(this._single, i)}
>
<span slot="headline">${o}</span>
</ha-list-item-option>
`
)}
</ha-list-selectable>
<pre>selected: ${JSON.stringify(this._toJson(this._single))}</pre>
</ha-card>
<ha-card header="Multi select, appearance=line">
<ha-list-selectable
multi
aria-label="Multi select line"
@ha-list-selected=${this._onMultiLine}
>
${this._options.map(
(o, i) => html`
<ha-list-item-option
.value=${o}
?selected=${this._isSel(this._multiLine, i)}
>
<span slot="headline">${o}</span>
</ha-list-item-option>
`
)}
</ha-list-selectable>
<pre>selected: ${JSON.stringify(this._toJson(this._multiLine))}</pre>
</ha-card>
<ha-card
header='Multi select, appearance=checkbox, selection-position="start"'
>
<ha-list-selectable
multi
aria-label="Multi checkbox start"
@ha-list-selected=${this._onMultiCheckStart}
>
${this._options.map(
(o, i) => html`
<ha-list-item-option
appearance="checkbox"
selection-position="start"
.value=${o}
?selected=${this._isSel(this._multiCheckStart, i)}
>
<span slot="headline">${o}</span>
</ha-list-item-option>
`
)}
</ha-list-selectable>
<pre>
selected: ${JSON.stringify(this._toJson(this._multiCheckStart))}</pre
>
</ha-card>
<ha-card
header='Multi select, appearance=checkbox, selection-position="end"'
>
<ha-list-selectable
multi
aria-label="Multi checkbox end"
@ha-list-selected=${this._onMultiCheckEnd}
>
${this._options.map(
(o, i) => html`
<ha-list-item-option
appearance="checkbox"
selection-position="end"
.value=${o}
?selected=${this._isSel(this._multiCheckEnd, i)}
>
<span slot="headline">${o}</span>
<span slot="supporting-text">${o.length} characters</span>
</ha-list-item-option>
`
)}
</ha-list-selectable>
<pre>
selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre
>
</ha-card>
<ha-card header="Option: all combinations">
<div class="grid">
${appearances.map((appearance) =>
positions.map((position) =>
selectedStates.map((selected) =>
disabledStates.map(
(disabled) => html`
<div role="listbox" class="wrap" aria-label="single option">
<ha-list-item-option
appearance=${appearance}
selection-position=${position}
?selected=${selected}
?disabled=${disabled}
>
<span slot="headline"
>${appearance} / pos=${position}</span
>
<span slot="supporting-text"
>selected=${String(selected)}
disabled=${String(disabled)}</span
>
</ha-list-item-option>
</div>
`
)
)
)
)}
</div>
</ha-card>
<h2>ha-list-nav</h2>
<p>
Same as <code>ha-list-base</code> but wrapped in a
<code>&lt;nav&gt;</code> landmark.
</p>
<ha-card header="Sidebar-style navigation">
<ha-list-nav aria-label="Primary navigation">
${[
{ name: "Overview", path: "#overview", icon: mdiHome },
{ name: "Dashboards", path: "#dashboards", icon: mdiViewDashboard },
{ name: "Map", path: "#map", icon: mdiMapMarker },
{ name: "Settings", path: "#settings", icon: mdiCog },
].map(
(p) => html`
<ha-list-item-button .href=${p.path}>
<ha-svg-icon slot="start" .path=${p.icon}></ha-svg-icon>
<span slot="headline">${p.name}</span>
<ha-svg-icon slot="end" .path=${mdiChevronRight}></ha-svg-icon>
</ha-list-item-button>
`
)}
</ha-list-nav>
</ha-card>
`;
}
private _isSel(value: number | Set<number>, index: number): boolean {
if (typeof value === "number") {
return value === index;
}
return value.has(index);
}
private _toJson(value: number | Set<number>): unknown {
return value instanceof Set ? [...value] : value;
}
private _onButtonClick = () => {
this._buttonClicks++;
};
private _onSingle = (ev: CustomEvent<HaListSelectedDetail>) => {
this._single = ev.detail.index;
};
private _onMultiLine = (ev: CustomEvent<HaListSelectedDetail>) => {
this._multiLine = ev.detail.index;
};
private _onMultiCheckStart = (ev: CustomEvent<HaListSelectedDetail>) => {
this._multiCheckStart = ev.detail.index;
};
private _onMultiCheckEnd = (ev: CustomEvent<HaListSelectedDetail>) => {
this._multiCheckEnd = ev.detail.index;
};
static styles = css`
:host {
display: flex;
flex-direction: column;
gap: var(--ha-space-4);
padding: var(--ha-space-6);
}
h2 {
margin: var(--ha-space-4) 0 0;
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-medium);
}
p {
margin: 0 0 var(--ha-space-2);
color: var(--secondary-text-color);
}
ha-card {
max-width: 560px;
}
pre {
padding: var(--ha-space-4);
background: var(--secondary-background-color);
margin: 0;
}
.custom-content {
display: flex;
flex-direction: column;
gap: var(--ha-space-1);
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--ha-space-3);
padding: var(--ha-space-3);
}
.wrap {
border: 1px solid var(--divider-color);
border-radius: var(--ha-border-radius-sm);
}
.drag-handle {
cursor: grab;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-list": DemoHaList;
}
}

View File

@@ -52,6 +52,7 @@ const SENSOR_DEVICE_CLASSES = [
"sulphur_dioxide",
"temperature",
"timestamp",
"uptime",
"volatile_organic_compounds",
"volatile_organic_compounds_parts",
"voltage",

View File

@@ -4,7 +4,7 @@ import { customElement, property, state } from "lit/decorators";
import { extractSearchParam } from "../../src/common/url/search-params";
import "../../src/components/ha-alert";
import "../../src/components/ha-button";
import "../../src/components/ha-fade-in";
import "../../src/components/animation/ha-fade-in";
import "../../src/components/ha-spinner";
import "../../src/components/ha-svg-icon";
import "../../src/components/progress/ha-progress-bar";

View File

@@ -33,27 +33,28 @@
"@codemirror/lang-jinja": "6.0.1",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.3",
"@codemirror/lint": "6.9.5",
"@codemirror/search": "6.7.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.41.1",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.3.2",
"@formatjs/intl-displaynames": "7.3.2",
"@formatjs/intl-durationformat": "0.10.4",
"@formatjs/intl-getcanonicallocales": "3.2.3",
"@formatjs/intl-listformat": "8.3.2",
"@formatjs/intl-locale": "5.3.2",
"@formatjs/intl-numberformat": "9.3.2",
"@formatjs/intl-pluralrules": "6.3.2",
"@formatjs/intl-relativetimeformat": "12.3.2",
"@formatjs/intl-datetimeformat": "7.4.0",
"@formatjs/intl-displaynames": "7.3.3",
"@formatjs/intl-durationformat": "0.10.5",
"@formatjs/intl-getcanonicallocales": "3.2.4",
"@formatjs/intl-listformat": "8.3.3",
"@formatjs/intl-locale": "5.3.3",
"@formatjs/intl-numberformat": "9.3.3",
"@formatjs/intl-pluralrules": "6.3.3",
"@formatjs/intl-relativetimeformat": "12.3.3",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
"@fullcalendar/list": "6.1.20",
"@fullcalendar/luxon3": "6.1.20",
"@fullcalendar/timegrid": "6.1.20",
"@home-assistant/webawesome": "3.3.1-ha.1",
"@home-assistant/webawesome": "3.3.1-ha.2",
"@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.1.0",
"@lit-labs/observers": "2.1.0",
@@ -99,7 +100,7 @@
"hls.js": "1.6.16",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "11.2.1",
"intl-messageformat": "11.2.2",
"js-yaml": "4.1.1",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
@@ -135,7 +136,6 @@
"@babel/plugin-transform-runtime": "7.29.0",
"@babel/preset-env": "7.29.2",
"@bundle-stats/plugin-webpack-filter": "4.22.1",
"@eslint/eslintrc": "3.3.5",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.59.0",
"@lokalise/node-api": "15.7.1",
@@ -144,7 +144,7 @@
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.9",
"@rspack/core": "2.0.0",
"@rspack/dev-server": "2.0.0",
"@rspack/dev-server": "2.0.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
@@ -168,10 +168,8 @@
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.2.1",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-import-x": "4.16.2",
"eslint-plugin-lit": "2.2.1",
"eslint-plugin-lit-a11y": "5.1.1",
@@ -187,7 +185,7 @@
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "29.0.2",
"jsdom": "29.1.0",
"jszip": "3.10.1",
"lint-staged": "16.4.0",
"lit-analyzer": "2.0.3",
@@ -200,10 +198,10 @@
"serve": "14.2.6",
"sinon": "21.1.2",
"tar": "7.5.13",
"terser-webpack-plugin": "5.4.0",
"terser-webpack-plugin": "5.5.0",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.59.0",
"typescript-eslint": "8.59.1",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.5",
"webpack-stats-plugin": "1.1.3",

View File

@@ -0,0 +1,17 @@
<svg width="268" height="28" viewBox="0 0 268 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="88" height="28" rx="8" fill="white"/>
<rect x="0.5" y="0.5" width="87" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
<rect x="8" y="8" width="12" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="28" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
<rect x="48" y="8" width="12" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="68" y="8" width="12" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<path d="M107.667 18.1167V14.7833H100.233L100.208 13.1083H107.667V9.78333L111.833 13.95L107.667 18.1167Z" fill="#B1B1B1"/>
<rect x="124" width="88" height="28" rx="8" fill="white"/>
<rect x="124.5" y="0.5" width="87" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
<rect x="132" y="8" width="12" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="152" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
<rect x="172" y="8" width="12" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="192" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
<path d="M237.5 13.1667H222.5V11.5H237.5V13.1667ZM237.5 14.8333H222.5V16.5H237.5V14.8333Z" fill="#B1B1B1"/>
<path d="M257.167 16.5H253L258.833 4.83337V11.5H263L257.167 23.1667V16.5Z" fill="#5E5E5E"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,17 @@
<svg width="268" height="28" viewBox="0 0 268 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 8C0 3.58172 3.58172 0 8 0H80C84.4183 0 88 3.58172 88 8V20C88 24.4183 84.4183 28 80 28H8C3.58172 28 0 24.4183 0 20V8Z" fill="#1C1C1C"/>
<path d="M8 0.5H80C84.1421 0.5 87.5 3.85786 87.5 8V20C87.5 24.1421 84.1421 27.5 80 27.5H8C3.85786 27.5 0.5 24.1421 0.5 20V8C0.5 3.85786 3.85786 0.5 8 0.5Z" stroke="white" stroke-opacity="0.24"/>
<rect x="8" y="8" width="12" height="12" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="28" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
<rect x="48" y="8" width="12" height="12" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="68" y="8" width="12" height="12" rx="3" fill="white" fill-opacity="0.24"/>
<path d="M109.333 16.45C108.718 17.065 107.667 16.6294 107.667 15.7596C107.667 15.2204 107.23 14.7833 106.69 14.7833H101.058C100.601 14.7833 100.228 14.4159 100.221 13.9583C100.214 13.4909 100.591 13.1083 101.058 13.1083H106.693C107.231 13.1083 107.667 12.6723 107.667 12.1345C107.667 11.2668 108.716 10.8323 109.329 11.4458L110.613 12.7296C111.287 13.4036 111.287 14.4964 110.613 15.1704L109.333 16.45Z" fill="white" fill-opacity="0.48"/>
<path d="M124 8C124 3.58172 127.582 0 132 0H204C208.418 0 212 3.58172 212 8V20C212 24.4183 208.418 28 204 28H132C127.582 28 124 24.4183 124 20V8Z" fill="#1C1C1C"/>
<path d="M132 0.5H204C208.142 0.5 211.5 3.85786 211.5 8V20C211.5 24.1421 208.142 27.5 204 27.5H132C127.858 27.5 124.5 24.1421 124.5 20V8C124.5 3.85786 127.858 0.5 132 0.5Z" stroke="white" stroke-opacity="0.24"/>
<rect x="132" y="8" width="12" height="12" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="152" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
<rect x="172" y="8" width="12" height="12" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="192" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
<path d="M237.5 12.3333C237.5 12.7936 237.127 13.1667 236.667 13.1667H223.333C222.873 13.1667 222.5 12.7936 222.5 12.3333C222.5 11.8731 222.873 11.5 223.333 11.5H236.667C237.127 11.5 237.5 11.8731 237.5 12.3333ZM237.5 15.6667C237.5 15.2064 237.127 14.8333 236.667 14.8333H223.333C222.873 14.8333 222.5 15.2064 222.5 15.6667C222.5 16.1269 222.873 16.5 223.333 16.5H236.667C237.127 16.5 237.5 16.1269 237.5 15.6667Z" fill="white" fill-opacity="0.48"/>
<path d="M257.167 16.5H253L258.833 4.83337V11.5H263L257.167 23.1667V16.5Z" fill="white" fill-opacity="0.24"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1,17 @@
<svg width="268" height="28" viewBox="0 0 268 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="88" height="28" rx="8" fill="white"/>
<rect x="0.5" y="0.5" width="87" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
<rect x="8" y="8" width="12" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="28" y="8" width="12" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="48" y="8" width="12" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="68" y="8" width="12" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<path d="M107.667 18.1167V14.7833H100.233L100.208 13.1083H107.667V9.78333L111.833 13.95L107.667 18.1167Z" fill="#B1B1B1"/>
<rect x="124" width="88" height="28" rx="8" fill="white"/>
<rect x="124.5" y="0.5" width="87" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
<rect x="132" y="8" width="12" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="152" y="8" width="12" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="172" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
<rect x="192" y="8" width="12" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<path d="M237.5 13.1667H222.5V11.5H237.5V13.1667ZM237.5 14.8333H222.5V16.5H237.5V14.8333Z" fill="#B1B1B1"/>
<path d="M257.167 16.5H253L258.833 4.83337V11.5H263L257.167 23.1667V16.5Z" fill="#5E5E5E"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,17 @@
<svg width="268" height="28" viewBox="0 0 268 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 8C0 3.58172 3.58172 0 8 0H80C84.4183 0 88 3.58172 88 8V20C88 24.4183 84.4183 28 80 28H8C3.58172 28 0 24.4183 0 20V8Z" fill="#1C1C1C"/>
<path d="M8 0.5H80C84.1421 0.5 87.5 3.85786 87.5 8V20C87.5 24.1421 84.1421 27.5 80 27.5H8C3.85786 27.5 0.5 24.1421 0.5 20V8C0.5 3.85786 3.85786 0.5 8 0.5Z" stroke="white" stroke-opacity="0.24"/>
<rect x="8" y="8" width="12" height="12" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="28" y="8" width="12" height="12" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="48" y="8" width="12" height="12" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="68" y="8" width="12" height="12" rx="3" fill="white" fill-opacity="0.24"/>
<path d="M109.333 16.45C108.718 17.065 107.667 16.6294 107.667 15.7596C107.667 15.2204 107.23 14.7833 106.69 14.7833H101.058C100.601 14.7833 100.228 14.4159 100.221 13.9583C100.214 13.4909 100.591 13.1083 101.058 13.1083H106.693C107.231 13.1083 107.667 12.6723 107.667 12.1345C107.667 11.2668 108.716 10.8323 109.329 11.4458L110.613 12.7296C111.287 13.4036 111.287 14.4964 110.613 15.1704L109.333 16.45Z" fill="white" fill-opacity="0.48"/>
<path d="M124 8C124 3.58172 127.582 0 132 0H204C208.418 0 212 3.58172 212 8V20C212 24.4183 208.418 28 204 28H132C127.582 28 124 24.4183 124 20V8Z" fill="#1C1C1C"/>
<path d="M132 0.5H204C208.142 0.5 211.5 3.85786 211.5 8V20C211.5 24.1421 208.142 27.5 204 27.5H132C127.858 27.5 124.5 24.1421 124.5 20V8C124.5 3.85786 127.858 0.5 132 0.5Z" stroke="white" stroke-opacity="0.24"/>
<rect x="132" y="8" width="12" height="12" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="152" y="8" width="12" height="12" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="172" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
<rect x="192" y="8" width="12" height="12" rx="3" fill="white" fill-opacity="0.24"/>
<path d="M237.5 12.3333C237.5 12.7936 237.127 13.1667 236.667 13.1667H223.333C222.873 13.1667 222.5 12.7936 222.5 12.3333C222.5 11.8731 222.873 11.5 223.333 11.5H236.667C237.127 11.5 237.5 11.8731 237.5 12.3333ZM237.5 15.6667C237.5 15.2064 237.127 14.8333 236.667 14.8333H223.333C222.873 14.8333 222.5 15.2064 222.5 15.6667C222.5 16.1269 222.873 16.5 223.333 16.5H236.667C237.127 16.5 237.5 16.1269 237.5 15.6667Z" fill="white" fill-opacity="0.48"/>
<path d="M257.167 16.5H253L258.833 4.83337V11.5H263L257.167 23.1667V16.5Z" fill="white" fill-opacity="0.24"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1,17 @@
<svg width="268" height="28" viewBox="0 0 268 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="88" height="28" rx="8" fill="white"/>
<rect x="0.5" y="0.5" width="87" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
<rect x="8" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
<rect x="28" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
<rect x="48" y="8" width="12" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="68" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
<path d="M107.667 18.1167V14.7833H100.233L100.208 13.1083H107.667V9.78333L111.833 13.95L107.667 18.1167Z" fill="#B1B1B1"/>
<rect x="124" width="88" height="28" rx="8" fill="white"/>
<rect x="124.5" y="0.5" width="87" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
<rect x="132" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
<rect x="152" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
<rect x="172" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
<rect x="192" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
<path d="M237.5 13.1667H222.5V11.5H237.5V13.1667ZM237.5 14.8333H222.5V16.5H237.5V14.8333Z" fill="#B1B1B1"/>
<path d="M257.167 16.5H253L258.833 4.83337V11.5H263L257.167 23.1667V16.5Z" fill="#5E5E5E"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,17 @@
<svg width="268" height="28" viewBox="0 0 268 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 8C0 3.58172 3.58172 0 8 0H80C84.4183 0 88 3.58172 88 8V20C88 24.4183 84.4183 28 80 28H8C3.58172 28 0 24.4183 0 20V8Z" fill="#1C1C1C"/>
<path d="M8 0.5H80C84.1421 0.5 87.5 3.85786 87.5 8V20C87.5 24.1421 84.1421 27.5 80 27.5H8C3.85786 27.5 0.5 24.1421 0.5 20V8C0.5 3.85786 3.85786 0.5 8 0.5Z" stroke="white" stroke-opacity="0.24"/>
<rect x="8" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
<rect x="28" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
<rect x="48" y="8" width="12" height="12" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="68" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
<path d="M109.333 16.45C108.718 17.065 107.667 16.6294 107.667 15.7596C107.667 15.2204 107.23 14.7833 106.69 14.7833H101.058C100.601 14.7833 100.228 14.4159 100.221 13.9583C100.214 13.4909 100.591 13.1083 101.058 13.1083H106.693C107.231 13.1083 107.667 12.6723 107.667 12.1345C107.667 11.2668 108.716 10.8323 109.329 11.4458L110.613 12.7296C111.287 13.4036 111.287 14.4964 110.613 15.1704L109.333 16.45Z" fill="white" fill-opacity="0.48"/>
<path d="M124 8C124 3.58172 127.582 0 132 0H204C208.418 0 212 3.58172 212 8V20C212 24.4183 208.418 28 204 28H132C127.582 28 124 24.4183 124 20V8Z" fill="#1C1C1C"/>
<path d="M132 0.5H204C208.142 0.5 211.5 3.85786 211.5 8V20C211.5 24.1421 208.142 27.5 204 27.5H132C127.858 27.5 124.5 24.1421 124.5 20V8C124.5 3.85786 127.858 0.5 132 0.5Z" stroke="white" stroke-opacity="0.24"/>
<rect x="132" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
<rect x="152" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
<rect x="172" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
<rect x="192" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
<path d="M237.5 12.3333C237.5 12.7936 237.127 13.1667 236.667 13.1667H223.333C222.873 13.1667 222.5 12.7936 222.5 12.3333C222.5 11.8731 222.873 11.5 223.333 11.5H236.667C237.127 11.5 237.5 11.8731 237.5 12.3333ZM237.5 15.6667C237.5 15.2064 237.127 14.8333 236.667 14.8333H223.333C222.873 14.8333 222.5 15.2064 222.5 15.6667C222.5 16.1269 222.873 16.5 223.333 16.5H236.667C237.127 16.5 237.5 16.1269 237.5 15.6667Z" fill="white" fill-opacity="0.48"/>
<path d="M257.167 16.5H253L258.833 4.83337V11.5H263L257.167 23.1667V16.5Z" fill="white" fill-opacity="0.24"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260325.0"
version = "20260429.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"

View File

@@ -44,9 +44,9 @@ export const normalizeLuminance = (color: string): string => {
return midL;
}
if (testLuminance < targetLuminance) {
return findLightness(midL, highL, iterations--);
return findLightness(midL, highL, iterations - 1);
}
return findLightness(lowL, midL, iterations--);
return findLightness(lowL, midL, iterations - 1);
}
baseOklch.l = findLightness();

View File

@@ -21,8 +21,9 @@ export const closestWithProperty = (
own
? Object.prototype.hasOwnProperty.call(element, property)
: element && property in element
)
) {
return element;
}
return closestWithProperty(element, property, own);
};

View File

@@ -1,6 +1,7 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../../types";
import { ensureArray } from "../array/ensure-array";
import { computeRTL } from "../util/compute_rtl";
import { computeAreaName } from "./compute_area_name";
import { computeDeviceName } from "./compute_device_name";
import { computeEntityName, entityUseDeviceName } from "./compute_entity_name";
@@ -117,3 +118,32 @@ export const computeEntityNameList = (
return names;
};
export interface EntityPickerDisplay {
primary: string;
secondary?: string;
}
export const computeEntityPickerDisplay = (
hass: HomeAssistant,
stateObj: HassEntity
): EntityPickerDisplay => {
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const isRTL = computeRTL(hass);
const primary = entityName || deviceName || stateObj.entity_id;
const secondary =
[areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ") || undefined;
return { primary, secondary };
};

View File

@@ -0,0 +1,52 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { isUnavailableState } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
interface EntityUnitStubConfig {
entity: string;
attribute?: string;
unit?: string;
}
/**
* Computes the display unit for an entity.
*
* @param hass - Home Assistant instance
* @param stateObj - Entity state object
* @param config - Element configuration
* @returns Computed entity unit
*/
export const computeEntityUnitDisplay = (
hass: HomeAssistant,
stateObj: HassEntity | undefined,
config: EntityUnitStubConfig
): string => {
let unit;
if (
stateObj &&
!isUnavailableState(stateObj.state) &&
(config.attribute || stateObj.attributes.device_class !== "duration")
) {
// check for an explicitly defined unit in config
unit = config.unit;
if (!unit) {
if (!config.attribute) {
// use entity's unit_of_measurement
const stateParts = hass.formatEntityStateToParts(stateObj);
unit = stateParts.find((part) => part.type === "unit")?.value;
} else {
// use attribute's unit if available
const attrParts = hass.formatEntityAttributeValueToParts(
stateObj,
config.attribute
);
unit = attrParts.find((part) => part.type === "unit")?.value;
}
}
return unit ?? "";
}
return "";
};

View File

@@ -258,6 +258,7 @@ const computeStateToPartsFromEntityAttributes = (
"infrared",
"input_button",
"notify",
"radio_frequency",
"scene",
"stt",
"tag",
@@ -265,7 +266,9 @@ const computeStateToPartsFromEntityAttributes = (
"wake_word",
"datetime",
].includes(domain) ||
(domain === "sensor" && attributes.device_class === "timestamp")
(domain === "sensor" &&
(attributes.device_class === "timestamp" ||
attributes.device_class === "uptime"))
) {
try {
return [

View File

@@ -54,6 +54,7 @@ export const FIXED_DOMAIN_STATES = {
],
person: ["home", "not_home"],
plant: ["ok", "problem"],
radio_frequency: [],
remote: ["on", "off"],
scene: [],
schedule: ["on", "off"],
@@ -224,6 +225,7 @@ const FIXED_DOMAIN_ATTRIBUTE_STATES = {
"sulphur_dioxide",
"temperature",
"timestamp",
"uptime",
"volatile_organic_compounds",
"volatile_organic_compounds_parts",
"voltage",

View File

@@ -7,7 +7,14 @@ export function stateActive(stateObj: HassEntity, state?: string): boolean {
const compareState = state !== undefined ? state : stateObj?.state;
if (
["button", "event", "infrared", "input_button", "scene"].includes(domain)
[
"button",
"event",
"infrared",
"input_button",
"radio_frequency",
"scene",
].includes(domain)
) {
return compareState !== UNAVAILABLE;
}

View File

@@ -19,7 +19,6 @@ const SECS_PER_HOUR = SECS_PER_MIN * 60;
// Adapted from https://github.com/formatjs/formatjs/blob/186cef62f980ec66252ee232f438a42d0b51b9f9/packages/intl-utils/src/diff.ts
export function selectUnit(
from: Date | number,
// eslint-disable-next-line default-param-last
to: Date | number = Date.now(),
locale: FrontendLocaleData,
thresholds: Partial<Thresholds> = {}

View File

@@ -3,13 +3,14 @@ import { customElement, property } from "lit/decorators";
@customElement("ha-fade-in")
export class HaFadeIn extends WaAnimation {
@property() public name = "fadeIn";
@property() public fill: FillMode = "both";
@property({ type: Boolean }) public play = true;
@property({ type: Number }) public iterations = 1;
constructor() {
super();
this.iterations = 1;
this.fill = "both";
this.name = "fadeIn";
}
}
declare global {

View File

@@ -0,0 +1,20 @@
import WaAnimation from "@home-assistant/webawesome/dist/components/animation/animation";
import { customElement, property } from "lit/decorators";
@customElement("ha-fade-out")
export class HaFadeOut extends WaAnimation {
@property({ type: Boolean }) public play = true;
constructor() {
super();
this.iterations = 1;
this.fill = "both";
this.name = "fadeOut";
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-fade-out": HaFadeOut;
}
}

View File

@@ -0,0 +1,147 @@
import "@home-assistant/webawesome/dist/components/animation/animation";
import { mdiInformationOutline } from "@mdi/js";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { keyed } from "lit/directives/keyed";
import "../animation/ha-fade-in";
import "../animation/ha-fade-out";
import "../ha-icon-button";
@customElement("ha-automation-row-event-chip")
export class HaAutomationRowEventChip extends LitElement {
@property({ reflect: true })
public variant: "info" | "warning" | "success" | "danger" = "info";
@property({ type: Boolean })
public interactive = false;
@property({ type: Boolean })
public show = false;
@state()
private _hide = false;
@state()
private _highlight = 0;
willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("show")) {
this._highlight = 0;
if (!this.show && this.hasUpdated) {
this._hide = true;
}
}
}
protected render(): TemplateResult | typeof nothing {
if (!this.show && !this._hide) {
return nothing;
}
let base = html`<div><slot></slot></div>`;
if (this.interactive) {
base = html`<button>
<slot></slot>
<ha-svg-icon .path=${mdiInformationOutline}></ha-svg-icon>
</button>`;
}
if (this.show && this._highlight) {
return keyed(
this._highlight,
html`
<wa-animation fill="both" .iterations=${1} name="tada" play
>${base}</wa-animation
>
`
);
}
if (!this.show && this._hide) {
return html`
<ha-fade-out @wa-finish=${this._handleHideFinish}>${base}</ha-fade-out>
`;
}
return html`<ha-fade-in .duration=${250}>${base}</ha-fade-in>`;
}
public highlight() {
this._highlight += 1;
}
private _handleHideFinish() {
this._hide = false;
}
static styles = css`
:host {
--background-color: var(--ha-color-fill-primary-normal-resting);
--background-color-hover: var(--ha-color-fill-primary-normal-hover);
--text-color: var(--ha-color-on-primary-normal);
border-radius: var(--ha-border-radius-pill);
}
:host([variant="warning"]) {
--background-color: var(--ha-color-fill-warning-normal-resting);
--background-color-hover: var(--ha-color-fill-warning-normal-hover);
--text-color: var(--ha-color-on-warning-normal);
}
:host([variant="success"]) {
--background-color: var(--ha-color-fill-success-normal-resting);
--background-color-hover: var(--ha-color-fill-success-normal-hover);
--text-color: var(--ha-color-on-success-normal);
}
:host([variant="danger"]) {
--background-color: var(--ha-color-fill-danger-normal-resting);
--background-color-hover: var(--ha-color-fill-danger-normal-hover);
--text-color: var(--ha-color-on-danger-normal);
}
button,
div {
background: var(--background-color);
border-radius: var(--ha-border-radius-pill);
color: var(--text-color);
display: inline-flex;
gap: var(--ha-space-2);
padding: var(--ha-space-1) var(--ha-space-2);
align-items: center;
--mdc-icon-size: 16px;
line-height: 1;
}
button {
border: none;
cursor: pointer;
}
button:hover {
background: var(--background-color-hover);
}
button:focus-visible {
outline: var(--wa-focus-ring);
outline-offset: var(--wa-focus-ring-offset);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-row-event-chip": HaAutomationRowEventChip;
}
interface HASSDomEvents {
"toggle-collapsed": undefined;
"stop-sort-selection": undefined;
"copy-row": undefined;
"cut-row": undefined;
"delete-row": undefined;
}
}

View File

@@ -2,8 +2,8 @@ import { mdiChevronUp } from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import "./ha-icon-button";
import { fireEvent } from "../../common/dom/fire_event";
import "../ha-icon-button";
@customElement("ha-automation-row")
export class HaAutomationRow extends LitElement {
@@ -27,6 +27,9 @@ export class HaAutomationRow extends LitElement {
@property({ type: Boolean, reflect: true }) public highlight?: boolean;
@property({ type: Boolean, reflect: true })
public dim = false;
@query(".row")
private _rowElement?: HTMLDivElement;
@@ -51,7 +54,11 @@ export class HaAutomationRow extends LitElement {
<div class="leading-icon-wrapper">
<slot name="leading-icon"></slot>
</div>
<slot class="header" name="header"></slot>
<div class="header">
<slot name="header"></slot>
<slot name="event"></slot>
</div>
<div class="icons">
<slot name="icons"></slot>
</div>
@@ -172,12 +179,24 @@ export class HaAutomationRow extends LitElement {
border-top-right-radius: var(--ha-border-radius-square);
border-top-left-radius: var(--ha-border-radius-square);
}
::slotted([slot="header"]) {
.header {
position: relative;
display: flex;
align-items: center;
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
margin: 0 var(--ha-space-3);
}
::slotted([slot="header"]) {
overflow-wrap: anywhere;
margin: 0 var(--ha-space-3);
}
::slotted([slot="event"]) {
position: absolute;
top: 13px;
inset-inline-end: 0;
}
.icons {
display: flex;
align-items: center;
@@ -199,6 +218,19 @@ export class HaAutomationRow extends LitElement {
:host([highlight]) .row:hover {
background-color: rgba(var(--rgb-primary-color), 0.16);
}
.icons,
.leading-icon-wrapper,
::slotted([slot="header"]) {
transition: opacity var(--ha-animation-duration-normal);
opacity: 1;
}
:host([dim]) .icons,
:host([dim]) .leading-icon-wrapper,
:host([dim]) ::slotted([slot="header"]) {
opacity: 0.5;
}
`;
}

View File

@@ -1,6 +1,12 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import { consume } from "@lit/context";
import { mdiChevronDown, mdiChevronUp, mdiRestart } from "@mdi/js";
import {
mdiCheckCircle,
mdiChevronDown,
mdiChevronUp,
mdiCircleOutline,
mdiRestart,
} from "@mdi/js";
import { differenceInMinutes } from "date-fns";
import type { DataZoomComponentOption } from "echarts/components";
import type { EChartsType } from "echarts/core";
@@ -47,6 +53,10 @@ export type CustomLegendOption = ECOption["legend"] & {
name: string;
value?: string; // Current value to display next to the name in the legend.
itemStyle?: Record<string, any>;
// If true, label click does not fire `legend-label-click` even when the
// chart has `clickLabelForMoreInfo`; falls back to toggle. Used for items
// without a corresponding entity (e.g. external statistics).
noLabelClick?: boolean;
}[];
};
@@ -81,6 +91,9 @@ export class HaChartBase extends LitElement {
})
private _themes!: Themes;
@property({ attribute: "click-label-for-more-info", type: Boolean })
public clickLabelForMoreInfo = false;
@state() private _isZoomed = false;
@state() private _zoomRatio = 1;
@@ -360,18 +373,19 @@ export class HaChartBase extends LitElement {
return nothing;
}
let itemStyle: Record<string, any> = {};
let name = "";
let id = "";
let value = "";
let noLabelClick = false;
const name = typeof item === "string" ? item : (item.name ?? "");
if (typeof item === "string") {
name = item;
id = item;
} else {
name = item.name ?? "";
id = item.id ?? name;
value = item.value ?? "";
itemStyle = item.itemStyle ?? {};
noLabelClick = item.noLabelClick ?? false;
}
const labelClickable = this.clickLabelForMoreInfo && !noLabelClick;
const dataset =
datasets.find((d) => d.id === id) ??
datasets.find((d) => d.name === id);
@@ -381,26 +395,43 @@ export class HaChartBase extends LitElement {
...itemStyle,
};
const color = itemStyle?.color as string;
const borderColor = itemStyle?.borderColor as string;
return html`<li
.id=${id}
@click=${this._legendClick}
@pointerdown=${this._legendPointerDown}
@pointerup=${this._legendPointerCancel}
@pointerleave=${this._legendPointerCancel}
@pointercancel=${this._legendPointerCancel}
@contextmenu=${this._legendContextMenu}
class=${classMap({ hidden: this._hiddenDatasets.has(id) })}
.title=${name}
>
<div
class="bullet"
style=${styleMap({
backgroundColor: color,
borderColor: borderColor || color,
})}
></div>
<div class="label">${name}</div>
<button
type="button"
class="legend-toggle"
data-id=${id}
aria-pressed=${!this._hiddenDatasets.has(id)}
.title=${this.hass.localize(
"ui.components.history_charts.toggle_visibility"
)}
@click=${this._toggleDataset}
>
<ha-svg-icon
.path=${this._hiddenDatasets.has(id)
? mdiCircleOutline
: mdiCheckCircle}
style=${styleMap({
color: this._hiddenDatasets.has(id) ? undefined : color,
})}
></ha-svg-icon>
</button>
<button
type="button"
class=${classMap({ label: true, clickable: labelClickable })}
data-id=${id}
.title=${name}
@click=${this._labelClick}
>
${name}
</button>
${value ? html`<div class="value">${value}</div>` : nothing}
</li>`;
})}
@@ -1163,7 +1194,8 @@ export class HaChartBase extends LitElement {
}
}
private _legendClick(ev: MouseEvent) {
private _toggleDataset(ev: MouseEvent) {
ev.stopPropagation();
if (!this.chart) {
return;
}
@@ -1171,13 +1203,46 @@ export class HaChartBase extends LitElement {
this._longPressTriggered = false;
return;
}
const id = (ev.currentTarget as HTMLElement)?.id;
const id = (ev.currentTarget as HTMLElement).dataset.id;
if (!id) {
return;
}
// Cmd+click on Mac (Ctrl+click is right-click there), Ctrl+click elsewhere
const soloModifier = isMac ? ev.metaKey : ev.ctrlKey;
if (soloModifier) {
this._soloLegend(id);
return;
}
this._handleDatasetToggle(id);
}
private _labelClick(ev: MouseEvent) {
ev.stopPropagation();
if (!this.chart) {
return;
}
if (this._longPressTriggered) {
this._longPressTriggered = false;
return;
}
const target = ev.currentTarget as HTMLElement;
const id = target.dataset.id;
if (!id) {
return;
}
const soloModifier = isMac ? ev.metaKey : ev.ctrlKey;
if (soloModifier) {
this._soloLegend(id);
return;
}
if (target.classList.contains("clickable")) {
fireEvent(this, "legend-label-click", { id });
} else {
this._handleDatasetToggle(id);
}
}
private _handleDatasetToggle(id: string) {
if (this._hiddenDatasets.has(id)) {
this._getAllIdsFromLegend(this.options, id).forEach((i) =>
this._hiddenDatasets.delete(i)
@@ -1392,7 +1457,6 @@ export class HaChartBase extends LitElement {
}
.chart-legend li {
height: 24px;
cursor: pointer;
display: inline-flex;
align-items: center;
padding: 0 2px;
@@ -1409,33 +1473,54 @@ export class HaChartBase extends LitElement {
color: var(--secondary-text-color);
}
.chart-legend .label {
background: none;
border: none;
padding: 0;
margin: 0;
font: inherit;
color: inherit;
cursor: pointer;
text-align: start;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
line-height: 1;
}
@media (hover: hover) {
.chart-legend .label.clickable:hover {
text-decoration: underline;
}
.chart-legend .legend-toggle:hover {
opacity: 0.5;
}
}
.chart-legend .value {
color: var(--secondary-text-color);
margin-inline-start: var(--ha-space-1);
flex-shrink: 0;
white-space: nowrap;
line-height: 1;
}
.chart-legend .bullet {
border-width: 1px;
border-style: solid;
border-radius: var(--ha-border-radius-circle);
display: block;
height: 16px;
width: 16px;
margin-right: 4px;
flex-shrink: 0;
box-sizing: border-box;
margin-inline-end: 4px;
margin-inline-start: initial;
direction: var(--direction);
.chart-legend .legend-toggle {
background: none;
border: none;
color: inherit;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 4px;
margin: -4px;
margin-inline-end: 0;
}
.chart-legend .hidden .bullet {
border-color: var(--secondary-text-color) !important;
background-color: transparent !important;
.chart-legend .legend-toggle:focus-visible,
.chart-legend .label:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
border-radius: var(--ha-border-radius-small, 4px);
}
.chart-legend .legend-toggle ha-svg-icon {
--mdc-icon-size: 18px;
}
ha-assist-chip {
height: 100%;
@@ -1455,6 +1540,7 @@ declare global {
"dataset-hidden": { id: string };
"dataset-unhidden": { id: string };
"chart-click": ECElementEvent;
"legend-label-click": { id: string };
"chart-zoom": {
start: number;
end: number;

View File

@@ -291,20 +291,26 @@ export class HaSankeyChart extends LitElement {
}
private _findParentIndex(id: string, links: Link[], sections: Node[][]) {
const parent = links.find((l) => l.target === id)?.source;
if (!parent) {
const parents = links.filter((l) => l.target === id).map((l) => l.source);
if (parents.length === 0) {
return -1;
}
let offset = 0;
for (let i = sections.length - 1; i >= 0; i--) {
const section = sections[i];
const index = section.findIndex((n) => n.id === parent);
if (index !== -1) {
return offset + index;
let sum = 0;
let count = 0;
for (const parent of parents) {
let offset = 0;
for (let i = sections.length - 1; i >= 0; i--) {
const section = sections[i];
const index = section.findIndex((n) => n.id === parent);
if (index !== -1) {
sum += offset + index;
count++;
break;
}
offset += section.length;
}
offset += section.length;
}
return -1;
return count > 0 ? sum / count : -1;
}
static styles = css`

View File

@@ -18,10 +18,12 @@ import {
formatNumber,
} from "../../common/number/format_number";
import { measureTextWidth } from "../../util/text";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import { filterXSS } from "../../common/util/xss";
import { computeAttributeValueDisplay } from "../../common/entity/compute_attribute_display";
const safeParseFloat = (value) => {
const parsed = parseFloat(value);
@@ -35,6 +37,21 @@ const CLIMATE_MODE_CONFIGS = [
{ mode: "fan_only", action: "fan", cssVar: "--state-climate-fan_only-color" },
] as const;
// Used to recover the underlying entity_id from a legend dataset id.
// Kept in sync with the suffixes appended at dataset construction below
// for climate / water_heater / humidifier multi-attribute charts.
const ENTITY_DATASET_SUFFIXES = [
"-current_temperature",
"-target_temperature",
"-target_temperature_mode",
"-target_temperature_mode_low",
...CLIMATE_MODE_CONFIGS.map((c) => `-${c.action}`),
"-current_humidity",
"-target_humidity",
"-humidifying",
"-on",
];
@customElement("state-history-chart-line")
export class StateHistoryChartLine extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -111,6 +128,8 @@ export class StateHistoryChartLine extends LitElement {
@chart-zoom=${this._handleDataZoom}
.expandLegend=${this.expandLegend}
.hideResetButton=${this.hideResetButton}
.clickLabelForMoreInfo=${this.clickForMoreInfo}
@legend-label-click=${this._handleLegendLabelClick}
></ha-chart-base>
`;
}
@@ -128,8 +147,9 @@ export class StateHistoryChartLine extends LitElement {
if (
dataset.tooltip?.show === false ||
this._hiddenStats.has(dataset.id as string)
)
) {
return;
}
const param = params.find(
(p: Record<string, any>) => p.seriesIndex === index
);
@@ -221,6 +241,24 @@ export class StateHistoryChartLine extends LitElement {
});
}
private _handleLegendLabelClick(
ev: HASSDomEvent<HASSDomEvents["legend-label-click"]>
) {
const id = ev.detail.id;
let entityId = id;
if (!this.hass.states[entityId]) {
for (const suffix of ENTITY_DATASET_SUFFIXES) {
if (id.endsWith(suffix)) {
entityId = id.slice(0, -suffix.length);
break;
}
}
}
if (this.hass.states[entityId]) {
fireEvent(this, "hass-more-info", { entityId });
}
}
public willUpdate(changedProps: PropertyValues) {
if (
changedProps.has("data") ||
@@ -310,12 +348,50 @@ export class StateHistoryChartLine extends LitElement {
.filter((item) => !(item.dataset as LineSeriesOption).areaStyle)
.map((item) => {
const stateObj = this.hass.states[item.entityId];
let value: string | undefined;
if (stateObj) {
// For climate temperature datasets, show temperature values
const datasetId = item.dataset.id as string;
if (
datasetId?.endsWith("-current_temperature") ||
datasetId?.endsWith("-target_temperature") ||
datasetId?.endsWith("-target_temperature_mode") ||
datasetId?.endsWith("-target_temperature_mode_low")
) {
let attribute: string | undefined;
if (datasetId.endsWith("-current_temperature")) {
attribute = "current_temperature";
} else if (
datasetId.endsWith("-target_temperature_mode_low")
) {
attribute = "target_temp_low";
} else if (datasetId.endsWith("-target_temperature_mode")) {
attribute = "target_temp_high";
} else {
attribute = "temperature";
}
// Use the helper to format temperature with proper unit
value = computeAttributeValueDisplay(
this.hass.localize,
stateObj,
this.hass.locale,
this.hass.config,
this.hass.entities,
attribute
);
}
// Default for non-temperature datasets / missing attribute
if (value === undefined) {
value = this.hass.formatEntityState(stateObj);
}
}
return {
id: item.dataset.id as string,
name: item.dataset.name as string,
value: stateObj
? this.hass.formatEntityState(stateObj)
: undefined,
value: value,
};
}),
},

View File

@@ -10,6 +10,8 @@ import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { getGraphColorByIndex } from "../../common/color/colors";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import {
@@ -46,6 +48,13 @@ export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
change: "sum",
};
// When the chart has a single entity, ha-chart-base falls back to raw series
// ids (`${statistic_id}-${type}`) for the legend (see _legendData branch at
// the bottom of _generateData). Strip the type suffix to recover statistic_id.
const STAT_TYPE_SUFFIXES = (
Object.keys(supportedStatTypeMap) as StatisticType[]
).map((t) => `-${t}`);
@customElement("statistics-chart")
export class StatisticsChart extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -186,6 +195,9 @@ export class StatisticsChart extends LitElement {
@dataset-hidden=${this._datasetHidden}
@dataset-unhidden=${this._datasetUnhidden}
.expandLegend=${this.expandLegend}
.clickLabelForMoreInfo=${this.clickForMoreInfo &&
!this._statisticIds.every(isExternalStatistic)}
@legend-label-click=${this._handleLegendLabelClick}
></ha-chart-base>
`;
}
@@ -200,6 +212,28 @@ export class StatisticsChart extends LitElement {
this.requestUpdate("_hiddenStats");
}
private _handleLegendLabelClick(
ev: HASSDomEvent<HASSDomEvents["legend-label-click"]>
) {
const id = ev.detail.id;
// External statistics aren't real entities; nothing to open.
if (isExternalStatistic(id)) {
return;
}
let entityId = id;
if (!this.hass.states[entityId]) {
for (const suffix of STAT_TYPE_SUFFIXES) {
if (id.endsWith(suffix)) {
entityId = id.slice(0, -suffix.length);
break;
}
}
}
if (this.hass.states[entityId]) {
fireEvent(this, "hass-more-info", { entityId });
}
}
private _renderTooltip = (params: any) => {
const rendered: Record<string, boolean> = {};
const unit = this.unit
@@ -400,6 +434,7 @@ export class StatisticsChart extends LitElement {
name: string;
color?: ZRColor;
borderColor?: ZRColor;
noLabelClick?: boolean;
}[] = [];
const statisticIds: string[] = [];
let endTime: Date;
@@ -603,6 +638,7 @@ export class StatisticsChart extends LitElement {
name,
color: series.color as ZRColor,
borderColor: series.itemStyle?.borderColor,
noLabelClick: isExternalStatistic(statistic_id),
});
}
displayedLegend = displayedLegend || showLegend;
@@ -738,7 +774,11 @@ export class StatisticsChart extends LitElement {
// only update the legend if it has changed or it will trigger options update
this._legendData =
legendData.length > 1
? legendData.map(({ id, name }) => ({ id, name }))
? legendData.map(({ id, name, noLabelClick }) => ({
id,
name,
noLabelClick,
}))
: // if there is only one entity, let the base chart handle the legend
undefined;
}

View File

@@ -1,12 +1,11 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiPlus, mdiShape } from "@mdi/js";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { computeEntityPickerDisplay } from "../../common/entity/compute_entity_name_display";
import { isValidEntityId } from "../../common/entity/valid_entity_id";
import { computeRTL } from "../../common/util/compute_rtl";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import {
entityComboBoxKeys,
@@ -14,6 +13,7 @@ import {
type EntityComboBoxItem,
} from "../../data/entity/entity_picker";
import { domainToName } from "../../data/integration";
import type { EntitySelectorExtraOption } from "../../data/selector";
import {
isHelperDomain,
type HelperDomain,
@@ -22,6 +22,7 @@ import { showHelperDetailDialog } from "../../panels/config/helpers/show-dialog-
import type { HomeAssistant } from "../../types";
import "../ha-combo-box-item";
import "../ha-generic-picker";
import "../ha-icon";
import type { HaGenericPicker } from "../ha-generic-picker";
import type { PickerComboBoxSearchFn } from "../ha-picker-combo-box";
import type { PickerValueRenderer } from "../ha-picker-field";
@@ -111,23 +112,91 @@ export class HaEntityPicker extends LitElement {
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
/**
* Extra options shown alongside entities. The `id` is used as the value
* when the option is selected (it does not need to be a valid entity id).
*/
@property({ attribute: false })
public extraOptions?: EntitySelectorExtraOption[];
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@property({ attribute: "add-button", type: Boolean })
public addButton = false;
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@state() private _pendingEntityId?: string;
protected willUpdate(changedProperties: PropertyValues<this>) {
if (
this._pendingEntityId &&
changedProperties.has("hass") &&
this.hass.states !== changedProperties.get("hass")?.states &&
this.hass.states[this._pendingEntityId]
) {
this._setValue(this._pendingEntityId);
this._pendingEntityId = undefined;
}
}
protected firstUpdated(changedProperties: PropertyValues<this>): void {
super.firstUpdated(changedProperties);
// Load title translations so it is available when the combo-box opens
this.hass.loadBackendTranslation("title");
}
private _findExtraOption(value: string | undefined) {
return value
? this.extraOptions?.find((opt) => opt.id === value)
: undefined;
}
private _renderExtraOptionStart(extraOption: EntitySelectorExtraOption) {
const stateObj = extraOption.entity_id
? this.hass.states[extraOption.entity_id]
: undefined;
if (stateObj) {
return html`
<state-badge
slot="start"
.stateObj=${stateObj}
.hass=${this.hass}
></state-badge>
`;
}
if (extraOption.icon_path) {
return html`
<ha-svg-icon
slot="start"
.path=${extraOption.icon_path}
style="margin: 0 4px"
></ha-svg-icon>
`;
}
if (extraOption.icon) {
return html`<ha-icon slot="start" .icon=${extraOption.icon}></ha-icon>`;
}
return nothing;
}
private _valueRenderer: PickerValueRenderer = (value) => {
const entityId = value || "";
const extraOption = this._findExtraOption(entityId);
if (extraOption) {
return html`
${this._renderExtraOptionStart(extraOption)}
<span slot="headline">${extraOption.primary}</span>
${extraOption.secondary
? html`<span slot="supporting-text">${extraOption.secondary}</span>`
: nothing}
`;
}
const stateObj = this.hass.states[entityId];
if (!stateObj) {
@@ -141,22 +210,11 @@ export class HaEntityPicker extends LitElement {
`;
}
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
const { primary, secondary } = computeEntityPickerDisplay(
this.hass,
stateObj
);
const isRTL = computeRTL(this.hass);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
return html`
<state-badge
.hass=${this.hass}
@@ -253,8 +311,8 @@ export class HaEntityPicker extends LitElement {
private _getEntitiesMemoized = memoizeOne(getEntities);
private _getItems = () =>
this._getEntitiesMemoized(
private _getItems = () => {
const items = this._getEntitiesMemoized(
this.hass,
this.includeDomains,
this.excludeDomains,
@@ -265,6 +323,19 @@ export class HaEntityPicker extends LitElement {
this.excludeEntities,
this.value
);
if (this.extraOptions?.length) {
const resolvedExtras = this.extraOptions.map((opt) => ({
...opt,
stateObj: opt.entity_id ? this.hass.states[opt.entity_id] : undefined,
}));
return [...resolvedExtras, ...items];
}
return items;
};
private _shouldHideClearIcon() {
return !!this._findExtraOption(this.value)?.hide_clear;
}
protected render() {
const placeholder =
@@ -287,13 +358,14 @@ export class HaEntityPicker extends LitElement {
.rowRenderer=${this._rowRenderer}
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.hideClearIcon=${this.hideClearIcon}
.hideClearIcon=${this.hideClearIcon || this._shouldHideClearIcon()}
.searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer}
.searchKeys=${entityComboBoxKeys}
use-top-label
.addButtonLabel=${this.addButton
? this.hass.localize("ui.components.entity.entity-picker.add")
? (this.addButtonLabel ??
this.hass.localize("ui.components.entity.entity-picker.add"))
: undefined}
.unknownItemText=${this.hass.localize(
"ui.components.entity.entity-picker.unknown"
@@ -341,13 +413,19 @@ export class HaEntityPicker extends LitElement {
showHelperDetailDialog(this, {
domain,
dialogClosedCallback: (item) => {
if (item.entityId) this._setValue(item.entityId);
if (item.entityId) {
if (this.hass.states[item.entityId]) {
this._setValue(item.entityId);
} else {
this._pendingEntityId = item.entityId;
}
}
},
});
return;
}
if (!isValidEntityId(value)) {
if (!isValidEntityId(value) && !this._findExtraOption(value)) {
return;
}

View File

@@ -38,8 +38,6 @@ export class HaEntityStatePicker extends LitElement {
@property() public helper?: string;
@property({ attribute: "no-entity", type: Boolean }) public noEntity = false;
private _getItems = memoizeOne(
(
hass: HomeAssistant,
@@ -122,12 +120,13 @@ export class HaEntityStatePicker extends LitElement {
return nothing;
}
const noEntity = !ensureArray(this.entityId)?.length;
return html`
<ha-generic-picker
.hass=${this.hass}
.allowCustomValue=${this.allowCustomValue}
.disabled=${this.disabled ||
(!this.entityId && this.noEntity === false)}
.disabled=${this.disabled || noEntity}
.autofocus=${this.autofocus}
.required=${this.required}
.label=${this.label ??

View File

@@ -13,9 +13,9 @@ import {
} from "../../data/entity/entity";
import { forwardHaptic } from "../../data/haptics";
import type { HomeAssistant } from "../../types";
import "../ha-control-switch";
import "../ha-formfield";
import "../ha-icon-button";
import "../ha-switch";
const isOn = (stateObj?: HassEntity) =>
stateObj !== undefined &&
@@ -35,7 +35,7 @@ export class HaEntityToggle extends LitElement {
protected render(): TemplateResult {
if (!this.stateObj) {
return html` <ha-switch disabled></ha-switch> `;
return html`<ha-control-switch disabled></ha-control-switch> `;
}
if (
@@ -62,14 +62,14 @@ export class HaEntityToggle extends LitElement {
`;
}
const switchTemplate = html`<ha-switch
const switchTemplate = html`<ha-control-switch
aria-label=${`Toggle ${computeStateName(this.stateObj)} ${
this._isOn ? "off" : "on"
}`}
.checked=${this._isOn}
.disabled=${this.stateObj.state === UNAVAILABLE}
@change=${this._toggleChanged}
></ha-switch>`;
></ha-control-switch>`;
if (!this.label) {
return switchTemplate;
@@ -160,9 +160,15 @@ export class HaEntityToggle extends LitElement {
static styles = css`
:host {
display: flex;
align-items: center;
white-space: nowrap;
min-width: 38px;
}
ha-control-switch {
--control-switch-thickness: 20px;
--control-switch-off-color: var(--state-inactive-color);
}
ha-icon-button {
--ha-icon-button-size: 40px;
color: var(--ha-icon-button-inactive-color, var(--primary-text-color));
@@ -171,9 +177,6 @@ export class HaEntityToggle extends LitElement {
ha-icon-button.state-active {
color: var(--ha-icon-button-active-color, var(--primary-color));
}
ha-switch {
padding: 13px 5px;
}
`;
}

View File

@@ -177,7 +177,6 @@ export class HaAnsiToHtml extends LitElement {
lineDiv.appendChild(span);
};
/* eslint-disable no-cond-assign */
let match;
while ((match = re.exec(line)) !== null) {

View File

@@ -1,8 +1,8 @@
import { mdiPlus, mdiTextureBox } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import type { TemplateResult, PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeAreaName } from "../common/entity/compute_area_name";
@@ -85,6 +85,20 @@ export class HaAreaPicker extends LitElement {
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@state() private _pendingAreaId?: string;
protected willUpdate(changedProperties: PropertyValues<this>) {
if (
this._pendingAreaId &&
changedProperties.has("hass") &&
this.hass.areas !== changedProperties.get("hass")?.areas &&
this.hass.areas[this._pendingAreaId]
) {
this._setValue(this._pendingAreaId);
this._pendingAreaId = undefined;
}
}
public async open() {
await this.updateComplete;
await this._picker?.open();
@@ -243,7 +257,11 @@ export class HaAreaPicker extends LitElement {
createEntry: async (values) => {
try {
const area = await createAreaRegistryEntry(this.hass, values);
this._setValue(area.area_id);
if (this.hass.areas[area.area_id]) {
this._setValue(area.area_id);
} else {
this._pendingAreaId = area.area_id;
}
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(

View File

@@ -26,7 +26,7 @@ class HaAttributeValue extends LitElement {
try {
// If invalid URL, exception will be raised
const url = new URL(attributeValue);
if (url.protocol === "http:" || url.protocol === "https:")
if (url.protocol === "http:" || url.protocol === "https:") {
return html`
<a
target="_blank"
@@ -36,6 +36,7 @@ class HaAttributeValue extends LitElement {
${attributeValue}
</a>
`;
}
} catch {
// Nothing to do here
}

View File

@@ -0,0 +1,42 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { CompletionItem } from "./ha-code-editor-completion-items";
import "./ha-code-editor-completion-items";
@customElement("ha-code-editor-jinja-arg-hover")
export class HaCodeEditorJinjaArgHover extends LitElement {
/** Bold heading shown above the items grid (e.g. entity/device/area name). */
@property({ attribute: false }) public heading?: string;
@property({ attribute: false }) public items: CompletionItem[] = [];
render() {
return html`
${this.heading
? html`<div class="heading">${this.heading}</div>`
: nothing}
<ha-code-editor-completion-items
.items=${this.items}
></ha-code-editor-completion-items>
`;
}
static styles = css`
:host {
display: block;
padding: 6px 10px;
max-width: 360px;
}
.heading {
font-weight: var(--ha-font-weight-bold);
margin-bottom: 4px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-code-editor-jinja-arg-hover": HaCodeEditorJinjaArgHover;
}
}

View File

@@ -0,0 +1,101 @@
import type { Completion } from "@codemirror/autocomplete";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { mdiHelpCircleOutline } from "@mdi/js";
import "./ha-svg-icon";
@customElement("ha-code-editor-jinja-hover")
export class HaCodeEditorJinjaHover extends LitElement {
@property({ attribute: false }) public completion!: Completion;
@property({ attribute: false }) public docUrl?: string;
@property({ attribute: false }) public openDocumentation =
"Open documentation";
render() {
const info =
typeof this.completion.info === "string"
? this.completion.info
: undefined;
return html`
<div class="header">
<div class="sig">
<strong>${this.completion.label}</strong>
${this.completion.detail
? html`<span class="detail">(${this.completion.detail})</span>`
: nothing}
</div>
${this.docUrl
? html`<a
class="doc-link"
href=${this.docUrl}
target="_blank"
rel="noreferrer"
title=${this.openDocumentation}
><ha-svg-icon .path=${mdiHelpCircleOutline}></ha-svg-icon
></a>`
: nothing}
</div>
${info ? html`<div class="desc">${info}</div>` : nothing}
`;
}
static styles = css`
:host {
display: block;
padding: 6px 10px;
max-width: 360px;
line-height: 1.5;
}
.header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.sig {
font-family: var(--ha-font-family-code);
font-size: 0.9em;
flex: 1;
min-width: 0;
}
.detail {
color: var(--secondary-text-color);
}
.doc-link {
flex-shrink: 0;
display: inline-flex;
align-items: center;
color: var(--secondary-text-color);
opacity: 0.7;
line-height: 1;
}
.doc-link:hover {
opacity: 1;
color: var(--primary-color);
}
.doc-link ha-svg-icon {
width: 16px;
height: 16px;
}
.desc {
font-size: 0.9em;
color: var(--secondary-text-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-code-editor-jinja-hover": HaCodeEditorJinjaHover;
}
}

View File

@@ -36,9 +36,13 @@ 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 type { JinjaArgType } from "../resources/jinja_ha_completions";
import type {
JinjaArgType,
HassArgHoverContext,
} from "../resources/jinja_ha_completions";
import type { HomeAssistant } from "../types";
import { showToast } from "../util/toast";
import { documentationUrl } from "../util/documentation-url";
import { labelsContext } from "../data/context";
import type { LabelRegistryEntry } from "../data/label/label_registry";
import "./ha-code-editor-completion-items";
@@ -91,6 +95,8 @@ export class HaCodeEditor extends ReactiveElement {
@property({ type: Boolean }) public error = false;
@property({ type: Boolean }) public lint = false;
@property({ type: Boolean, attribute: "disable-fullscreen" })
public disableFullscreen = false;
@@ -159,6 +165,40 @@ export class HaCodeEditor extends ReactiveElement {
return !!this.renderRoot.querySelector(`span.${className}`);
}
/**
* 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.
*/
public setYamlError(
err: {
mark?: { position: number; line: number; column: number };
reason?: string;
} | null
): void {
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.hass?.localize("ui.components.yaml-editor.error") ||
"YAML syntax error"
}${err.mark ? ` (${this.hass?.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() {
super.connectedCallback();
this.classList.toggle("in-dialog", this.inDialog);
@@ -216,17 +256,38 @@ export class HaCodeEditor extends ReactiveElement {
transactions.push({
effects: [
this._loadedCodeMirror!.langCompartment!.reconfigure(this._mode),
this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
this.lint && !this.readOnly
? [this._loadedCodeMirror!.lintGutter()]
: []
),
],
});
}
if (changedProps.has("readOnly")) {
transactions.push({
effects: this._loadedCodeMirror!.readonlyCompartment!.reconfigure(
this._loadedCodeMirror!.EditorView!.editable.of(!this.readOnly)
),
effects: [
this._loadedCodeMirror!.readonlyCompartment!.reconfigure(
this._loadedCodeMirror!.EditorView!.editable.of(!this.readOnly)
),
this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
this.lint && !this.readOnly
? [this._loadedCodeMirror!.lintGutter()]
: []
),
],
});
this._updateToolbarButtons();
}
if (changedProps.has("lint")) {
transactions.push({
effects: this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
this.lint && !this.readOnly
? [this._loadedCodeMirror!.lintGutter()]
: []
),
});
}
if (changedProps.has("linewrap")) {
transactions.push({
effects: this._loadedCodeMirror!.linewrapCompartment!.reconfigure(
@@ -308,6 +369,7 @@ export class HaCodeEditor extends ReactiveElement {
...this._loadedCodeMirror.searchKeymap,
...this._loadedCodeMirror.historyKeymap,
...this._loadedCodeMirror.tabKeyBindings,
...this._loadedCodeMirror.lintKeymap,
saveKeyBinding,
]),
this._loadedCodeMirror.search({ top: true }),
@@ -322,10 +384,23 @@ export class HaCodeEditor extends ReactiveElement {
this._loadedCodeMirror.linewrapCompartment.of(
this.linewrap ? this._loadedCodeMirror.EditorView.lineWrapping : []
),
this._loadedCodeMirror.yamlLintCompartment.of(
this.lint && !this.readOnly ? [this._loadedCodeMirror.lintGutter()] : []
),
this._loadedCodeMirror.EditorView.updateListener.of(this._onUpdate),
this._loadedCodeMirror.tooltips({
position: "absolute",
}),
this._loadedCodeMirror.hoverTooltip(
(view, pos) =>
this._loadedCodeMirror!.haJinjaHoverSource(
view,
pos,
this.hass ? documentationUrl(this.hass, "") : undefined,
this.hass ? this._hassArgHoverContext() : undefined
),
{ hoverTime: 300 }
),
...(this.placeholder ? [placeholder(this.placeholder)] : []),
];
@@ -370,11 +445,12 @@ export class HaCodeEditor extends ReactiveElement {
}
private _fullscreenLabel(): string {
if (this._isFullscreen)
if (this._isFullscreen) {
return (
this.hass?.localize("ui.components.yaml-editor.exit_fullscreen") ||
"Exit fullscreen"
);
}
return (
this.hass?.localize("ui.components.yaml-editor.enter_fullscreen") ||
"Enter fullscreen"
@@ -574,6 +650,48 @@ export class HaCodeEditor extends ReactiveElement {
}
};
/**
* Builds a HassArgHoverContext from the current hass object so that
* haJinjaHoverSource can resolve entity / device / area friendly names
* without importing the full HomeAssistant type into the resource file.
*/
private _hassArgHoverContext(): HassArgHoverContext {
const hass = this.hass!;
const labelMap: Record<
string,
{ name: string; description?: string | null }
> = {};
for (const label of this._labels ?? []) {
labelMap[label.label_id] = {
name: label.name,
description: label.description,
};
}
return {
states: hass.states as HassArgHoverContext["states"],
devices: hass.devices as HassArgHoverContext["devices"],
areas: hass.areas as HassArgHoverContext["areas"],
floors: hass.floors as HassArgHoverContext["floors"],
entities: hass.entities as HassArgHoverContext["entities"],
labels: labelMap,
formatEntityState: (entityId) =>
hass.formatEntityState(hass.states[entityId]),
formatEntityName: (entityId) => {
const stateObj = hass.states[entityId];
return (
(stateObj?.attributes.friendly_name as string | undefined) ??
hass.entities[entityId]?.name ??
undefined
);
},
formatAttributeName: (entityId, attribute) =>
hass.formatEntityAttributeName(hass.states[entityId], attribute),
formatAttributeValue: (entityId, attribute) =>
hass.formatEntityAttributeValue(hass.states[entityId], attribute),
localize: (key) => hass.localize(key as never),
};
}
private _renderInfo = (completion: Completion): CompletionInfo => {
const key =
typeof completion.apply === "string"
@@ -914,7 +1032,7 @@ export class HaCodeEditor extends ReactiveElement {
// In both cases the parent is a MemberExpression.
const memberNode = node.parent;
// "from" for the completion result (start of what the user is currently typing)
let completionFrom = pos;
let completionFrom: number;
if (
node.name === "PropertyName" &&

View File

@@ -11,6 +11,7 @@ import { css, html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event";
import { mainWindow } from "../common/dom/get_main_window";
import "./ha-svg-icon";
@customElement("ha-control-switch")
@@ -39,7 +40,7 @@ export class HaControlSwitch extends LitElement {
protected firstUpdated(changedProperties: PropertyValues<this>): void {
super.firstUpdated(changedProperties);
this.setupListeners();
this.setupSwipeListeners();
}
private _toggle() {
@@ -50,7 +51,19 @@ export class HaControlSwitch extends LitElement {
connectedCallback(): void {
super.connectedCallback();
this.setupListeners();
this.setupSwipeListeners();
}
updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
if (
changedProperties.has("disabled") ||
changedProperties.has("vertical") ||
changedProperties.has("reversed")
) {
this.destroyListeners();
this.setupSwipeListeners();
}
}
disconnectedCallback(): void {
@@ -61,7 +74,11 @@ export class HaControlSwitch extends LitElement {
@query("#switch")
private switch!: HTMLDivElement;
setupListeners() {
setupSwipeListeners() {
if (this.disabled) {
return;
}
if (this.switch && !this._mc) {
this._mc = new Manager(this.switch, {
touchAction: this.touchAction ?? (this.vertical ? "pan-x" : "pan-y"),
@@ -90,13 +107,15 @@ export class HaControlSwitch extends LitElement {
} else {
this._mc.on("swiperight", () => {
if (this.disabled) return;
this.checked = !this.reversed;
const isRTL = mainWindow.document.dir === "rtl";
this.checked = (!this.reversed && !isRTL) || (this.reversed && isRTL);
fireEvent(this, "change");
});
this._mc.on("swipeleft", () => {
if (this.disabled) return;
this.checked = !!this.reversed;
const isRTL = mainWindow.document.dir === "rtl";
this.checked = (this.reversed && !isRTL) || (!this.reversed && isRTL);
fireEvent(this, "change");
});
}
@@ -116,11 +135,30 @@ export class HaControlSwitch extends LitElement {
}
private _keydown(ev: any) {
if (ev.key !== "Enter" && ev.key !== " ") {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
this._toggle();
return;
}
const rtl = !this.vertical && mainWindow.document.dir === "rtl";
const flip = this.reversed !== rtl;
const [forward, backward] = this.vertical
? ["ArrowDown", "ArrowUp"]
: ["ArrowRight", "ArrowLeft"];
const onKey = flip ? backward : forward;
const offKey = flip ? forward : backward;
if (ev.key !== onKey && ev.key !== offKey) {
return;
}
ev.preventDefault();
this._toggle();
const wantOn = ev.key === onKey;
if (wantOn !== this.checked) {
this._toggle();
}
}
protected render(): TemplateResult {
@@ -132,7 +170,7 @@ export class HaControlSwitch extends LitElement {
aria-checked=${this.checked ? "true" : "false"}
aria-label=${ifDefined(this.label)}
role="switch"
tabindex="0"
tabindex=${ifDefined(this.disabled ? undefined : "0")}
?checked=${this.checked}
?disabled=${this.disabled}
>
@@ -156,6 +194,7 @@ export class HaControlSwitch extends LitElement {
--control-switch-on-color: var(--primary-color);
--control-switch-off-color: var(--disabled-color);
--control-switch-background-opacity: 0.2;
--control-switch-hover-background-opacity: 0.4;
--control-switch-thickness: 40px;
--control-switch-border-radius: var(--ha-border-radius-lg);
--control-switch-padding: 4px;
@@ -167,10 +206,10 @@ export class HaControlSwitch extends LitElement {
transition: box-shadow 180ms ease-in-out;
-webkit-tap-highlight-color: transparent;
}
.switch:focus-visible {
.switch:not([disabled]):focus-visible {
box-shadow: 0 0 0 2px var(--control-switch-off-color);
}
.switch[checked]:focus-visible {
.switch[checked]:not([disabled]):focus-visible {
box-shadow: 0 0 0 2px var(--control-switch-on-color);
}
.switch {
@@ -199,6 +238,10 @@ export class HaControlSwitch extends LitElement {
transition: background-color 180ms ease-in-out;
opacity: var(--control-switch-background-opacity);
}
.switch:not([disabled]):focus-visible .background,
.switch:not([disabled]):hover .background {
opacity: var(--control-switch-hover-background-opacity);
}
.switch .button {
width: 50%;
height: 100%;
@@ -222,12 +265,19 @@ export class HaControlSwitch extends LitElement {
transform: translateX(100%);
background-color: var(--control-switch-on-color);
}
.switch[checked] .button:dir(rtl) {
transform: translateX(-100%);
background-color: var(--control-switch-on-color);
}
:host([reversed]) .switch {
flex-direction: row-reverse;
}
:host([reversed]) .switch[checked] .button {
transform: translateX(-100%);
}
:host([reversed]) .switch[checked] .button:dir(rtl) {
transform: translateX(100%);
}
:host([vertical]) {
width: var(--control-switch-thickness);
height: 100%;

View File

@@ -13,17 +13,14 @@ import type { RelatedResult } from "../data/search";
import { findRelated } from "../data/search";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-floor-icon";
import "./ha-icon";
import "./ha-icon-button";
import "./ha-list";
import "./ha-svg-icon";
import "./ha-tree-indicator";
import "./item/ha-list-item-option";
import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-selectable";
import type { HaListSelectable } from "./list/ha-list-selectable";
import type { HaListSelectedDetail } from "./list/types";
@customElement("ha-filter-floor-areas")
export class HaFilterFloorAreas extends LitElement {
@@ -78,33 +75,27 @@ export class HaFilterFloorAreas extends LitElement {
</div>
${this._shouldRender
? html`
<ha-list-selectable
class="ha-scrollbar"
multi
@ha-list-selected=${this._handleListChanged}
aria-label=${this.hass.localize(
"ui.panel.config.areas.caption"
)}
>
<ha-list class="ha-scrollbar">
${repeat(
areas?.floors || [],
(floor) => floor.floor_id,
(floor) => html`
<ha-list-item-option
appearance="checkbox"
selection-position="end"
<ha-check-list-item
.value=${floor.floor_id}
.type=${"floors"}
.selected=${this.value?.floors?.includes(
floor.floor_id
) || false}
graphic="icon"
@request-selected=${this._handleItemClick}
@keydown=${this._handleItemKeydown}
>
<ha-floor-icon
slot="start"
slot="graphic"
.floor=${floor}
></ha-floor-icon>
<span slot="headline">${floor.name} </span>
</ha-list-item-option>
${floor.name}
</ha-check-list-item>
${repeat(
floor.areas,
(area, index) =>
@@ -119,7 +110,7 @@ export class HaFilterFloorAreas extends LitElement {
(area) => area.area_id,
(area) => this._renderArea(area)
)}
</ha-list-selectable>
</ha-list>
`
: nothing}
</ha-expansion-panel>
@@ -128,83 +119,79 @@ export class HaFilterFloorAreas extends LitElement {
private _renderArea(area, last = false) {
const hasFloor = !!area.floor_id;
return html`
<ha-list-item-option
appearance="checkbox"
selection-position="end"
<ha-check-list-item
.value=${area.area_id}
.selected=${this.value?.areas?.includes(area.area_id) || false}
.type=${"areas"}
graphic="icon"
@request-selected=${this._handleItemClick}
@keydown=${this._handleItemKeydown}
class=${classMap({
rtl: computeRTL(this.hass),
floor: hasFloor,
})}
>
${hasFloor
? html`<ha-tree-indicator
slot="start"
.end=${last}
></ha-tree-indicator>`
? html`
<ha-tree-indicator
.end=${last}
slot="graphic"
></ha-tree-indicator>
`
: nothing}
${area.icon
? html`<ha-icon slot="start" .icon=${area.icon}></ha-icon>`
? html`<ha-icon slot="graphic" .icon=${area.icon}></ha-icon>`
: html`<ha-svg-icon
slot="start"
slot="graphic"
.path=${mdiTextureBox}
></ha-svg-icon>`}
<span slot="headline">${area.name}</span>
</ha-list-item-option>
${area.name}
</ha-check-list-item>
`;
}
private _handleListChanged(ev: CustomEvent<HaListSelectedDetail>) {
if (!ev.detail.diff?.added.size && !ev.detail.diff?.removed.size) {
private _handleItemKeydown(ev) {
if (ev.key === " " || ev.key === "Enter") {
ev.preventDefault();
this._handleItemClick(ev);
}
}
private _handleItemClick(ev) {
ev.stopPropagation();
const listItem = ev.currentTarget;
const type = listItem?.type;
const value = listItem?.value;
if (ev.detail.selected === listItem.selected || !value) {
return;
}
if (ev.detail.diff?.added.size) {
const addedIndex = ev.detail.diff.added.values().next().value;
if (addedIndex === undefined) {
return;
}
const addedItem = (ev.currentTarget as HaListSelectable).items[
addedIndex
] as HaListItemOption & { type: string; value: string };
if (this.value?.[type]?.includes(value)) {
this.value = {
...this.value,
[type]: this.value[type].filter((val) => val !== value),
};
} else {
if (!this.value) {
this.value = {};
}
this.value = {
...this.value,
[addedItem.type]: [
...(this.value[addedItem.type] || []),
addedItem.value,
],
};
} else {
const removedIndex = ev.detail.diff?.removed.values().next().value;
if (removedIndex === undefined) {
return;
}
const removedItem = (ev.currentTarget as HaListSelectable).items[
removedIndex
] as HaListItemOption & { type: string; value: string };
this.value = {
...this.value,
[removedItem.type]: this.value![removedItem.type].filter(
(val) => val !== removedItem.value
),
[type]: [...(this.value[type] || []), value],
};
}
listItem.selected = this.value[type]?.includes(value);
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("ha-list-selectable")!.style.height =
this.renderRoot.querySelector("ha-list")!.style.height =
`${this.clientHeight - 49}px`;
}, 300);
}
@@ -330,7 +317,11 @@ export class HaFilterFloorAreas extends LitElement {
padding: 0px 2px;
color: var(--text-primary-color);
}
.floor::part(base) {
ha-check-list-item {
--mdc-list-item-graphic-margin: 16px;
}
.floor {
padding-left: 48px;
padding-inline-start: 48px;
padding-inline-end: 16px;
}

View File

@@ -1,9 +1,9 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiPlus, mdiTextureBox } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { LitElement, html } from "lit";
import { customElement, property, query } from "lit/decorators";
import type { TemplateResult, PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
@@ -104,6 +104,20 @@ export class HaFloorPicker extends LitElement {
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@state() private _pendingFloorId?: string;
protected willUpdate(changedProperties: PropertyValues<this>) {
if (
this._pendingFloorId &&
changedProperties.has("hass") &&
this.hass.floors !== changedProperties.get("hass")?.floors &&
this.hass.floors[this._pendingFloorId]
) {
this._setValue(this._pendingFloorId);
this._pendingFloorId = undefined;
}
}
public async open() {
await this.updateComplete;
await this._picker?.open();
@@ -436,7 +450,11 @@ export class HaFloorPicker extends LitElement {
floor_id: floor.floor_id,
});
});
this._setValue(floor.floor_id);
if (this.hass.floors[floor.floor_id]) {
this._setValue(floor.floor_id);
} else {
this._pendingFloorId = floor.floor_id;
}
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(

View File

@@ -72,6 +72,8 @@ export class HaForm extends LitElement implements HaFormElement {
key: string
) => string;
@property({ attribute: false }) public context?: Record<string, any>;
protected getFormProperties(): Record<string, any> {
return {};
}
@@ -218,13 +220,15 @@ export class HaForm extends LitElement implements HaFormElement {
private _generateContext(
schema: HaFormSchema
): Record<string, any> | undefined {
if (!schema.context) {
if (!schema.context && !this.context) {
return undefined;
}
const context = {};
for (const [context_key, data_key] of Object.entries(schema.context)) {
context[context_key] = this.data[data_key];
const context = { ...this.context };
if (schema.context) {
for (const [context_key, data_key] of Object.entries(schema.context)) {
context[context_key] = this.data[data_key];
}
}
return context;
}

View File

@@ -445,10 +445,10 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
}
wa-popover::part(body) {
width: max(var(--body-width), 250px);
width: var(--ha-generic-picker-width, max(var(--body-width), 250px));
max-width: var(
--ha-generic-picker-max-width,
max(var(--body-width), 250px)
var(--ha-generic-picker-width, max(var(--body-width), 250px))
);
max-height: 500px;
height: 70vh;

View File

@@ -53,7 +53,10 @@ export class HaIconButton extends LitElement {
.download=${this.download}
>
${this.path
? html`<ha-svg-icon .path=${this.path}></ha-svg-icon>`
? html`<ha-svg-icon
aria-hidden="true"
.path=${this.path}
></ha-svg-icon>`
: html`<span><slot></slot></span>`}
</ha-button>
`;

View File

@@ -2,8 +2,8 @@ import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { consume } from "@lit/context";
import { mdiPlus } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { LitElement, html, nothing } from "lit";
import type { TemplateResult, PropertyValues } from "lit";
import {
customElement,
property,
@@ -117,6 +117,19 @@ export class HaLabelPicker extends LitElement {
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@state() private _pendingLabelId?: string;
protected willUpdate(changedProperties: PropertyValues) {
if (
this._pendingLabelId &&
changedProperties.has("_labels") &&
this._labels?.some((l) => l.label_id === this._pendingLabelId)
) {
this._setValue(this._pendingLabelId);
this._pendingLabelId = undefined;
}
}
public async open() {
await this.updateComplete;
await this._picker?.open();
@@ -248,7 +261,11 @@ export class HaLabelPicker extends LitElement {
createEntry: async (values) => {
try {
const label = await createLabelRegistryEntry(this.hass, values);
this._setValue(label.label_id);
if (this._labels?.some((l) => l.label_id === label.label_id)) {
this._setValue(label.label_id);
} else {
this._pendingLabelId = label.label_id;
}
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(

View File

@@ -1,15 +1,16 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-icon-next";
import "../../../components/ha-svg-icon";
import "../../../components/item/ha-list-item-button";
import "../../../components/list/ha-list-nav";
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../../../types";
import { ifDefined } from "lit/directives/if-defined";
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../types";
import "./ha-icon-next";
import "./ha-md-list";
import "./ha-md-list-item";
import "./ha-svg-icon";
@customElement("ha-config-navigation-list")
class HaConfigNavigationList extends LitElement {
@customElement("ha-navigation-list")
class HaNavigationList extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@@ -23,11 +24,16 @@ class HaConfigNavigationList extends LitElement {
public render(): TemplateResult {
return html`
<ha-list-nav .ariaLabel=${this.label}>
<ha-md-list
innerRole="menu"
itemRoles="menuitem"
innerAriaLabel=${ifDefined(this.label)}
>
${this.pages.map((page) => {
const externalApp = page.path.endsWith("#external-app-configuration");
return html`
<ha-list-item-button
<ha-md-list-item
.type=${externalApp ? "button" : "link"}
.href=${externalApp ? undefined : page.path}
@click=${externalApp ? this._handleExternalApp : undefined}
>
@@ -49,10 +55,10 @@ class HaConfigNavigationList extends LitElement {
${!this.narrow
? html`<ha-icon-next slot="end"></ha-icon-next>`
: ""}
</ha-list-item-button>
</ha-md-list-item>
`;
})}
</ha-list-nav>
</ha-md-list>
`;
}
@@ -77,11 +83,14 @@ class HaConfigNavigationList extends LitElement {
.icon-background ha-svg-icon {
color: #fff;
}
ha-md-list-item {
font-size: var(--navigation-list-item-title-font-size);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-navigation-list": HaConfigNavigationList;
"ha-navigation-list": HaNavigationList;
}
}

View File

@@ -13,7 +13,10 @@ import {
} from "lit/decorators";
import memoizeOne from "memoize-one";
import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event";
import {
fireEvent,
type HASSDomCurrentTargetEvent,
} from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import { internationalizationContext } from "../data/context";
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
@@ -52,6 +55,7 @@ export interface PickerComboBoxItem {
id: string;
primary: string;
secondary?: string;
disabled?: boolean;
search_labels?: Record<string, string | null>;
sorting_label?: string;
icon_path?: string;
@@ -64,6 +68,12 @@ export interface PickerComboBoxIndexSelectedDetail {
newTab?: boolean;
}
type PickerComboBoxRowElement = HTMLDivElement & {
disabled?: boolean;
index: number;
value: string;
};
export const NO_ITEMS_AVAILABLE_ID = "___no_items_available___";
const PADDING_ID = "___padding___";
@@ -425,6 +435,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
class="combo-box-row ${this.value === item.id ? "current-value" : ""}"
.value=${item.id}
.index=${index}
.disabled=${item.disabled}
@click=${this._valueSelected}
>
${renderer(item, index)}
@@ -437,10 +448,14 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
this._listScrolled = top > 0;
}
private _valueSelected = (ev: MouseEvent) => {
private _valueSelected = (
ev: MouseEvent & HASSDomCurrentTargetEvent<PickerComboBoxRowElement>
) => {
ev.stopPropagation();
const value = (ev.currentTarget as any).value as string;
const index = Number((ev.currentTarget as any).index);
const { disabled, index, value } = ev.currentTarget;
if (disabled) {
return;
}
const newValue = value?.trim();
const newTab = ev.ctrlKey || ev.metaKey;
@@ -728,7 +743,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
(
this.virtualizerElement?.items as (PickerComboBoxItem | string)[]
).forEach((item, index) => {
if (typeof item !== "string") {
if (typeof item !== "string" && !item.disabled) {
this._fireSelectedEvents(item.id, index, newTab);
}
});
@@ -748,7 +763,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
const item = this.virtualizerElement?.items[
this._selectedItemIndex
] as PickerComboBoxItem;
if (item) {
if (item && !item.disabled) {
this._fireSelectedEvents(item.id, this._selectedItemIndex, newTab);
}
};

View File

@@ -36,6 +36,9 @@ export class HaSelectBox extends LitElement {
@property({ type: Number, attribute: "max_columns" })
public maxColumns?: number;
@property({ type: Boolean, attribute: "stacked_image" })
public stackedImage = false;
render() {
const maxColumns = this.maxColumns ?? 3;
const columns = Math.min(maxColumns, this.options.length);
@@ -48,7 +51,8 @@ export class HaSelectBox extends LitElement {
}
private _renderOption(option: SelectBoxOption) {
const horizontal = this.maxColumns === 1;
const horizontal = this.maxColumns === 1 && !this.stackedImage;
const stacked = this.maxColumns === 1 && this.stackedImage;
const disabled = option.disabled || this.disabled || false;
const selected = option.value === this.value;
@@ -66,6 +70,7 @@ export class HaSelectBox extends LitElement {
<label
class="option ${classMap({
horizontal: horizontal,
stacked: stacked,
selected: selected,
})}"
?disabled=${disabled}
@@ -187,6 +192,16 @@ export class HaSelectBox extends LitElement {
margin: 0;
}
.option.stacked {
align-items: stretch;
}
.option.stacked img {
max-width: 100%;
max-height: var(--ha-select-box-image-size, 96px);
margin: 0;
}
.option:before {
content: "";
display: block;

View File

@@ -59,7 +59,7 @@ export class HaSelect extends LitElement {
value: string | number | undefined
) => {
// just in case value is a number, convert it to string to avoid falsy value
const valueStr = String(value);
const valueStr = value !== undefined ? String(value) : undefined;
if (!options || !valueStr) {
return valueStr;
}

View File

@@ -68,7 +68,7 @@ export class HaSelectorAttribute extends LitElement {
}
// Validate that that the attribute is still valid for this entity, else unselect.
let invalid = false;
let invalid: boolean;
if (this.context.filter_entity) {
const entityIds = ensureArray(this.context.filter_entity);

View File

@@ -0,0 +1,123 @@
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { LocalizeKeys } from "../../common/translations/localize";
import type {
AutomationBehavior,
AutomationBehaviorConditionMode,
AutomationBehaviorSelector,
AutomationBehaviorTriggerMode,
} from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-input-helper-text";
import type { SelectBoxOption } from "../ha-select-box";
import "../ha-select-box";
const TRIGGER_BEHAVIORS: AutomationBehaviorTriggerMode[] = [
"any",
"first",
"last",
];
const CONDITION_BEHAVIORS: AutomationBehaviorConditionMode[] = ["any", "all"];
@customElement("ha-selector-automation_behavior")
export class HaSelectorAutomationBehavior extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false })
public selector!: AutomationBehaviorSelector;
@property() public value?: AutomationBehavior;
@property() public helper?: string;
@property({ attribute: false })
public localizeValue?: (key: string) => string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
protected render() {
const { mode } = this.selector.automation_behavior ?? {};
const modeKey = mode ?? "trigger";
const isTrigger = modeKey === "trigger";
const options = this._behaviors().map<SelectBoxOption>((behavior) => ({
value: behavior,
label: this._localizeOption(behavior, "label"),
description: this._localizeOption(behavior, "description"),
disabled: this.disabled,
...(isTrigger && {
image: {
src: `/static/images/form/automation_behavior_trigger_${behavior}.svg`,
src_dark: `/static/images/form/automation_behavior_trigger_${behavior}_dark.svg`,
},
}),
}));
return html`
<ha-select-box
.hass=${this.hass}
.options=${options}
.value=${this.value ?? ""}
max_columns="1"
?stacked_image=${isTrigger}
@value-changed=${this._valueChanged}
></ha-select-box>
${this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
: nothing}
`;
}
private _behaviors(): AutomationBehavior[] {
const mode = this.selector.automation_behavior?.mode;
return mode === "condition" ? CONDITION_BEHAVIORS : TRIGGER_BEHAVIORS;
}
private _localizeOption(
behavior: AutomationBehavior,
field: "label" | "description"
): string {
const { translation_key: translationKey, mode } =
this.selector.automation_behavior ?? {};
if (this.localizeValue && translationKey) {
const translated = this.localizeValue(
`${translationKey}.options.${behavior}.${field}`
);
if (translated) {
return translated;
}
}
return this.hass.localize(
`ui.components.selectors.automation_behavior.${mode ?? "trigger"}.options.${behavior}.${field}` as LocalizeKeys
);
}
private _valueChanged(ev: CustomEvent) {
ev.stopPropagation();
const value = ev.detail.value as AutomationBehavior;
if (this.disabled || value === this.value) {
return;
}
fireEvent(this, "value-changed", { value });
}
static styles = css`
ha-select-box {
--ha-select-box-image-size: 28px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-automation_behavior": HaSelectorAutomationBehavior;
}
}

View File

@@ -70,6 +70,7 @@ export class HaEntitySelector extends LitElement {
.helper=${this.helper}
.includeEntities=${this.selector.entity?.include_entities}
.excludeEntities=${this.selector.entity?.exclude_entities}
.extraOptions=${this.selector.entity?.extra_options}
.entityFilter=${this._filterEntities}
.createDomains=${this._createDomains}
.disabled=${this.disabled}

View File

@@ -1,6 +1,8 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiClose, mdiConnection, mdiMemory, mdiPencil, mdiUsb } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event";
@@ -27,21 +29,32 @@ const MANUAL_ENTRY_ID = "__manual_entry__";
const SERIAL_PORTS_REFRESH_INTERVAL = 5000;
type SerialPortType =
| "recommended"
| "serial_proxy"
| "integration"
| "usb"
| "embedded"
| "unnamed"
| "not_recommended";
const SECTION_ORDER: SerialPortType[] = [
"recommended",
"serial_proxy",
"integration",
"usb",
"embedded",
"unnamed",
"not_recommended",
];
type BaseSerialPortType =
| "serial_proxy"
| "integration"
| "usb"
| "embedded"
| "unnamed";
const SECTION_ORDER: SerialPortType[] = [
"serial_proxy",
"integration",
"usb",
"embedded",
"unnamed",
];
const TYPE_ICONS: Record<SerialPortType, string> = {
const TYPE_ICONS: Record<BaseSerialPortType, string> = {
serial_proxy: mdiEsphomeLogo,
integration: mdiConnection,
usb: mdiUsb,
@@ -51,7 +64,7 @@ const TYPE_ICONS: Record<SerialPortType, string> = {
const ESPHOME_HASS_SCHEME = "esphome-hass://";
const getPortType = (port: SerialPort): SerialPortType => {
const getBasePortType = (port: SerialPort): BaseSerialPortType => {
if (port.device.startsWith(ESPHOME_HASS_SCHEME)) {
return "serial_proxy";
}
@@ -67,6 +80,37 @@ const getPortType = (port: SerialPort): SerialPortType => {
return "unnamed";
};
interface SerialPickerItem extends PickerComboBoxItem {
port_type: SerialPortType;
used_by?: string;
description?: string;
}
const integrationName = (
localize: HomeAssistant["localize"],
domain: string
): string => localize(`component.${domain}.title`) || domain;
const getPortType = (
port: SerialPort,
recommendedDomains: Set<string>
): SerialPortType => {
const matchingDomains = port.matching_integrations ?? [];
// If the current integration matches this port, it is recommended
if (matchingDomains.some((d) => recommendedDomains.has(d))) {
return "recommended";
}
// If any other integrations match it, the port is not recommended
if (recommendedDomains.size > 0 && matchingDomains.length > 0) {
return "not_recommended";
}
// Otherwise, classify the port
return getBasePortType(port);
};
@customElement("ha-selector-serial_port")
export class HaSerialPortSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -85,6 +129,8 @@ export class HaSerialPortSelector extends LitElement {
@property({ type: Boolean }) public required = true;
@property({ attribute: false }) public context?: Record<string, any>;
@state() private _serialPorts?: SerialPort[];
@state() private _manualEntry = false;
@@ -172,24 +218,29 @@ export class HaSerialPortSelector extends LitElement {
language: string,
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
localize: HomeAssistant["localize"]
): Record<SerialPortType, PickerComboBoxItem[]> => {
const grouped: Record<SerialPortType, PickerComboBoxItem[]> = {
localize: HomeAssistant["localize"],
recommendedDomains: Set<string>
): Record<SerialPortType, SerialPickerItem[]> => {
const grouped: Record<SerialPortType, SerialPickerItem[]> = {
recommended: [],
serial_proxy: [],
integration: [],
usb: [],
embedded: [],
unnamed: [],
not_recommended: [],
};
for (const port of ports) {
const type = getPortType(port);
const type = getPortType(port, recommendedDomains);
let primary: string;
let description: string | undefined;
let secondary: string | undefined;
const searchLabels: Record<string, string | null> = {
device: port.device,
manufacturer: port.manufacturer,
description: port.description,
interface_description: port.interface_description ?? null,
serial_number: port.serial_number,
};
@@ -223,13 +274,25 @@ export class HaSerialPortSelector extends LitElement {
searchLabels.port_name = port.device;
}
} else {
primary =
const productManufacturer =
port.description && port.manufacturer
? `${port.description}${port.manufacturer}`
: port.description || port.manufacturer || port.device;
: port.description || port.manufacturer;
// Prefer the interface description if one exists
if (
port.interface_description &&
port.interface_description !== port.description
) {
primary = port.interface_description;
description = productManufacturer || undefined;
} else {
primary = productManufacturer || port.device;
description = undefined;
}
const parts: string[] = [];
if (port.description || port.manufacturer) {
if (primary !== port.device) {
parts.push(port.device);
}
if (port.vid && port.pid) {
@@ -238,16 +301,31 @@ export class HaSerialPortSelector extends LitElement {
if (port.serial_number) {
parts.push(`S/N: ${port.serial_number}`);
}
secondary = parts.length ? parts.join(" · ") : undefined;
secondary = parts.join(" · ");
}
let used_by: string | undefined;
if (type === "not_recommended" && port.matching_integrations.length) {
const integrations = port.matching_integrations
.map((d) => integrationName(localize, d))
.join(", ");
used_by = localize("ui.components.selectors.serial_port.used_by", {
integrations,
});
searchLabels.used_by = used_by;
}
grouped[type].push({
id: port.device,
primary,
secondary,
icon_path: TYPE_ICONS[type],
icon_path: TYPE_ICONS[getBasePortType(port)],
search_labels: searchLabels,
sorting_label: primary,
port_type: type,
used_by,
description: description,
});
}
@@ -265,6 +343,42 @@ export class HaSerialPortSelector extends LitElement {
}
);
private _sectionLabel(type: SerialPortType): string {
const key = `ui.components.selectors.serial_port.type.${type}` as const;
if (type === "recommended" && this._selectorDomain) {
return this.hass.localize(key, {
integration: integrationName(this.hass.localize, this._selectorDomain),
});
}
return this.hass.localize(key);
}
private get _selectorDomain(): string | undefined {
return this.context?.handler;
}
private _memoRecommendedDomains = memoizeOne(
(domain: string | undefined, extra: string[] | undefined): Set<string> => {
const domains = new Set<string>();
if (domain) {
domains.add(domain);
}
if (extra) {
for (const d of extra) {
domains.add(d);
}
}
return domains;
}
);
private get _recommendedDomains(): Set<string> {
return this._memoRecommendedDomains(
this._selectorDomain,
this.selector?.serial_port?.extra_recommended_domains
);
}
private _getPickerItems = (
searchString?: string,
section?: string
@@ -278,7 +392,8 @@ export class HaSerialPortSelector extends LitElement {
this.hass.locale.language,
this.hass.devices,
this.hass.areas,
this.hass.localize
this.hass.localize,
this._recommendedDomains
);
const items: (PickerComboBoxItem | string)[] = [];
@@ -286,7 +401,7 @@ export class HaSerialPortSelector extends LitElement {
if (section && section !== type) {
continue;
}
let groupItems = grouped[type];
let groupItems: SerialPickerItem[] = grouped[type];
if (searchString) {
groupItems = multiTermSortedSearch(
groupItems,
@@ -299,11 +414,7 @@ export class HaSerialPortSelector extends LitElement {
continue;
}
if (!section) {
items.push(
this.hass.localize(
`ui.components.selectors.serial_port.type.${type}` as const
)
);
items.push(this._sectionLabel(type));
}
items.push(...groupItems);
}
@@ -321,17 +432,48 @@ export class HaSerialPortSelector extends LitElement {
},
];
private _rowRenderer = (item: PickerComboBoxItem) => html`
<ha-combo-box-item type="button" compact>
${item.icon_path
? html`<ha-svg-icon slot="start" .path=${item.icon_path}></ha-svg-icon>`
: nothing}
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
</ha-combo-box-item>
`;
private _rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => {
const manual = item.id === MANUAL_ENTRY_ID;
const { port_type, used_by, description } = item as SerialPickerItem;
return html`
<ha-combo-box-item
type="button"
compact
.borderTop=${manual}
style=${styleMap({
marginTop: manual ? "var(--ha-space-3)" : "",
opacity: port_type === "not_recommended" ? "0.6" : "",
backgroundColor:
port_type === "recommended"
? "var(--ha-assist-chip-active-container-color)"
: "",
})}
>
${item.icon_path
? html`<ha-svg-icon
slot="start"
.path=${item.icon_path}
></ha-svg-icon>`
: nothing}
<span slot="headline" style="white-space: normal">${item.primary}</span>
${used_by
? html`<span slot="supporting-text" style="white-space: normal"
>${used_by}</span
>`
: nothing}
${description
? html`<span slot="supporting-text" style="white-space: normal"
>${description}</span
>`
: nothing}
${item.secondary
? html`<span slot="supporting-text" style="white-space: normal"
>${item.secondary}</span
>`
: nothing}
</ha-combo-box-item>
`;
};
protected render() {
const usbLoaded = this.hass && isComponentLoaded(this.hass.config, "usb");
@@ -393,7 +535,8 @@ export class HaSerialPortSelector extends LitElement {
this.hass.locale.language,
this.hass.devices,
this.hass.areas,
this.hass.localize
this.hass.localize,
this._recommendedDomains
)
)
.flat()
@@ -415,13 +558,12 @@ export class HaSerialPortSelector extends LitElement {
this.hass.locale.language,
this.hass.devices,
this.hass.areas,
this.hass.localize
this.hass.localize,
this._recommendedDomains
);
return SECTION_ORDER.filter((type) => grouped[type].length).map((type) => ({
id: type,
label: this.hass.localize(
`ui.components.selectors.serial_port.type.${type}` as const
),
label: this._sectionLabel(type),
}));
}

View File

@@ -99,7 +99,6 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
.noEntity=${this.selector.state?.no_entity ?? false}
.disabled=${this.disabled}
.required=${this.required}
allow-custom-value

View File

@@ -13,6 +13,7 @@ import type { HomeAssistant } from "../../types";
const LOAD_ELEMENTS = {
action: () => import("./ha-selector-action"),
addon: () => import("./ha-selector-addon"),
automation_behavior: () => import("./ha-selector-automation-behavior"),
app: () => import("./ha-selector-app"),
area: () => import("./ha-selector-area"),
areas_display: () => import("./ha-selector-areas-display"),

View File

@@ -36,14 +36,15 @@ import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { actionHandler } from "../panels/lovelace/common/directives/action-handler-directive";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant, PanelInfo, Route } from "../types";
import "./ha-fade-in";
import { isMobileClient } from "../util/is_mobile";
import "./animation/ha-fade-in";
import "./ha-icon";
import "./ha-icon-button";
import "./ha-md-list";
import "./ha-md-list-item";
import "./ha-spinner";
import "./ha-svg-icon";
import "./ha-tooltip";
import "./item/ha-list-item-button";
import "./list/ha-list-nav";
import "./user/ha-user-badge";
const SORT_VALUE_URL_PATHS = {
@@ -352,12 +353,12 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
private _renderAllPanels(selectedPanel: string) {
const renderList = (content, cls: string, scrollable: boolean) =>
html`<ha-list-nav
html`<ha-md-list
class=${classMap({
"ha-scrollbar": scrollable,
[cls]: true,
})}
>${content}</ha-list-nav
>${content}</ha-md-list
>`;
if (!this._panelOrder || !this._hiddenPanels) {
@@ -429,8 +430,9 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
const iconPath = getPanelIconPath(panel);
return html`
<ha-list-item-button
<ha-md-list-item
.href=${`/${urlPath}`}
type="link"
id="sidebar-panel-${urlPath}"
class=${classMap({ selected: isSelected })}
>
@@ -438,7 +440,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
? html`<ha-svg-icon slot="start" .path=${iconPath}></ha-svg-icon>`
: html`<ha-icon slot="start" .icon=${icon}></ha-icon>`}
<span class="item-text" slot="headline">${title}</span>
</ha-list-item-button>
</ha-md-list-item>
${!this.alwaysExpand && title
? this._renderToolTip(`sidebar-panel-${urlPath}`, title)
: nothing}
@@ -455,8 +457,9 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
}
const isSelected = selectedPanel === "config";
return html`
<ha-list-item-button
<ha-md-list-item
class="configuration ${classMap({ selected: isSelected })}"
type="button"
href="/config"
id="sidebar-config"
>
@@ -478,7 +481,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
>
`
: nothing}
</ha-list-item-button>
</ha-md-list-item>
${!this.alwaysExpand
? this._renderToolTip(
"sidebar-config",
@@ -494,9 +497,10 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
: 0;
return html`
<ha-list-item-button
<ha-md-list-item
class="notifications"
@click=${this._handleShowNotificationDrawer}
type="button"
id="sidebar-notifications"
>
<ha-svg-icon slot="start" .path=${mdiBell}></ha-svg-icon>
@@ -511,7 +515,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
${notificationCount > 0
? html`<span class="badge" slot="end">${notificationCount}</span>`
: nothing}
</ha-list-item-button>
</ha-md-list-item>
${!this.alwaysExpand
? this._renderToolTip(
"sidebar-notifications",
@@ -526,8 +530,9 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
const isSelected = selectedPanel === "profile";
return html`
<ha-list-item-button
<ha-md-list-item
href="/profile"
type="link"
id="sidebar-profile"
class=${classMap({
user: true,
@@ -543,7 +548,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
<span class="item-text" slot="headline"
>${this.hass.user ? this.hass.user.name : ""}</span
>
</ha-list-item-button>
</ha-md-list-item>
${!this.alwaysExpand && this.hass.user
? this._renderToolTip("sidebar-profile", this.hass.user.name)
: nothing}
@@ -555,15 +560,16 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
return nothing;
}
return html`
<ha-list-item-button
<ha-md-list-item
@click=${this._handleExternalAppConfiguration}
type="button"
id="sidebar-external-config"
>
<ha-svg-icon slot="start" .path=${mdiCellphoneCog}></ha-svg-icon>
<span class="item-text" slot="headline"
>${this.hass.localize("ui.sidebar.external_app_configuration")}</span
>
</ha-list-item-button>
</ha-md-list-item>
${!this.alwaysExpand
? this._renderToolTip(
"sidebar-external-config",
@@ -574,6 +580,10 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
}
private _renderToolTip(id: string, text: string) {
if (isMobileClient) {
return nothing;
}
return html`<ha-tooltip
for=${id}
show-delay="0"
@@ -708,10 +718,10 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
flex: 1;
}
ha-list-nav {
ha-md-list {
overflow-x: hidden;
background: none;
margin-left: var(--safe-area-inset-left, 0px);
margin-block: var(--ha-space-2);
}
.wrapper {
@@ -721,38 +731,42 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
min-height: 0;
flex: 1;
}
ha-list-nav.before-spacer {
ha-md-list.before-spacer {
padding-bottom: 0;
}
ha-list-nav.after-spacer {
ha-md-list.after-spacer {
padding-top: 0;
min-height: fit-content;
}
ha-list-item-button {
ha-md-list-item {
flex-shrink: 0;
margin: 0 var(--ha-space-1) var(--ha-space-1);
box-sizing: border-box;
margin: var(--ha-space-1);
border-radius: var(--ha-border-radius-sm);
--ha-row-item-min-height: var(--ha-space-10);
--ha-row-item-padding-block: 0;
--md-list-item-one-line-container-height: var(--ha-space-10);
--md-list-item-top-space: 0;
--md-list-item-bottom-space: 0;
width: var(--ha-space-12);
position: relative;
--md-list-item-label-text-color: var(--sidebar-text-color);
--md-list-item-leading-space: var(--ha-space-3);
--md-list-item-trailing-space: var(--ha-space-3);
--md-list-item-leading-icon-size: var(--ha-space-6);
transition: width var(--ha-animation-duration-normal) ease;
}
ha-list-item-button::part(headline) {
color: var(--sidebar-text-color);
}
:host([expanded]) ha-list-item-button {
:host([expanded]) ha-md-list-item {
width: 248px;
}
:host([narrow][expanded]) ha-list-item-button {
:host([narrow][expanded]) ha-md-list-item {
width: calc(240px - var(--safe-area-inset-left, 0px));
}
ha-list-item-button.selected::part(headline) {
color: var(--sidebar-selected-icon-color);
ha-md-list-item.selected {
--md-list-item-label-text-color: var(--sidebar-selected-icon-color);
--md-ripple-hover-color: var(--sidebar-selected-icon-color);
}
ha-list-item-button.selected::before {
ha-md-list-item.selected::before {
border-radius: var(--ha-border-radius-sm);
position: absolute;
top: 0;
@@ -774,12 +788,12 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
color: var(--sidebar-icon-color);
}
ha-list-item-button.selected ha-svg-icon[slot="start"],
ha-list-item-button.selected ha-icon[slot="start"] {
ha-md-list-item.selected ha-svg-icon[slot="start"],
ha-md-list-item.selected ha-icon[slot="start"] {
color: var(--sidebar-selected-icon-color);
}
ha-list-item-button .item-text {
ha-md-list-item .item-text {
display: block;
max-width: 0;
opacity: 0;
@@ -792,7 +806,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
max-width var(--ha-animation-duration-normal) ease,
opacity var(--ha-animation-duration-normal) ease;
}
:host([expanded]) ha-list-item-button .item-text {
:host([expanded]) ha-md-list-item .item-text {
max-width: 100%;
opacity: 1;
transition-delay: 0ms, 80ms;
@@ -834,17 +848,13 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
pointer-events: none;
}
ha-user-badge {
width: var(--ha-space-10);
height: var(--ha-space-10);
ha-md-list-item.user {
--md-list-item-leading-icon-size: var(--ha-space-10);
--md-list-item-leading-space: var(--ha-space-1);
}
ha-list-item-button.user {
--ha-row-item-padding-inline: var(--ha-space-2) var(--ha-space-3);
}
ha-list-item-button.user.rtl {
--ha-row-item-padding-inline: var(--ha-space-4) var(--ha-space-3);
ha-md-list-item.user.rtl {
--md-list-item-leading-space: var(--ha-space-3);
}
ha-user-badge {
@@ -864,8 +874,8 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
@media (prefers-reduced-motion: reduce) {
.menu,
ha-list-item-button,
ha-list-item-button .item-text,
ha-md-list-item,
ha-md-list-item .item-text,
.title {
transition: 1ms;
}

View File

@@ -157,12 +157,24 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
),
};
@state() private _pendingEntityId?: string;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._loadConfigEntries();
}
if (
this._pendingEntityId &&
changedProps.has("hass") &&
this.hass.states !== changedProps.get("hass")?.states &&
this.hass.states[this._pendingEntityId]
) {
this._addTarget(this._pendingEntityId, "entity");
this._pendingEntityId = undefined;
}
}
private _createFuseIndex = (states, keys: FuseWeightedKey[]) =>
@@ -532,10 +544,11 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
domain,
dialogClosedCallback: (item) => {
if (item.entityId) {
// prevent error that new entity_id isn't in hass object
requestAnimationFrame(() => {
this._addTarget(item.entityId!, "entity");
});
if (this.hass.states[item.entityId]) {
this._addTarget(item.entityId, "entity");
} else {
this._pendingEntityId = item.entityId;
}
}
},
});

View File

@@ -234,7 +234,10 @@ export class HaToast extends LitElement {
border-radius: var(--ha-border-radius-sm);
box-shadow: var(--wa-shadow-l);
opacity: 0;
transform: translate(-50%, var(--ha-space-2));
transform: translate(
calc(-50% * var(--scale-direction)),
var(--ha-space-2)
);
transition:
opacity var(--ha-animation-duration-fast, 150ms) ease,
transform var(--ha-animation-duration-fast, 150ms) ease;
@@ -242,7 +245,7 @@ export class HaToast extends LitElement {
.toast.visible {
opacity: 1;
transform: translate(-50%, 0);
transform: translate(calc(-50% * var(--scale-direction)), 0);
}
.toast:not(.active) {

View File

@@ -0,0 +1,185 @@
import {
mdiAlertCircleOutline,
mdiCheckCircleOutline,
mdiChevronDown,
mdiHelpCircleOutline,
mdiProgressClock,
mdiProgressWrench,
mdiStopCircleOutline,
} from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import type { HomeAssistant } from "../types";
import type { Trace } from "../data/trace";
import "./ha-button";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import { formatDateTimeWithSeconds } from "../common/datetime/format_date_time";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
@customElement("ha-trace-picker")
class HaTracePicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public traces!: Trace[];
@property({ attribute: false }) public value?: string;
@query("ha-generic-picker") private tracePicker?: HaGenericPicker;
protected render() {
return html` <ha-generic-picker
name="trace"
.hass=${this.hass}
.label=${this.hass.localize(
"ui.panel.config.automation.trace.select_trace"
)}
.value=${this.value}
.getItems=${this._getTraces}
required
>
<ha-button
slot="field"
appearance="filled"
variant="neutral"
size="small"
@click=${this._openPicker}
>
${this._renderTracePickerValue(this.value!)}
<ha-svg-icon slot="end" .path=${mdiChevronDown}></ha-svg-icon>
</ha-button>
</ha-generic-picker>`;
}
private _openPicker(ev: Event) {
ev.stopPropagation();
this.tracePicker?.open();
}
private _getTraces = (): PickerComboBoxItem[] =>
this.traces?.map((trace) => {
const renderRuntime = () =>
(
(new Date(trace.timestamp.finish!).getTime() -
new Date(trace.timestamp.start).getTime()) /
1000
).toFixed(2);
const item: PickerComboBoxItem = {
id: trace.run_id,
primary: formatDateTimeWithSeconds(
new Date(trace.timestamp.start),
this.hass.locale,
this.hass.config
),
};
if (trace.state === "running") {
item.secondary = this.hass.localize(
"ui.panel.config.automation.trace.picker.still_running"
);
item.icon_path = mdiProgressClock;
} else if (trace.state === "debugged") {
item.secondary = this.hass.localize(
"ui.panel.config.automation.trace.picker.debugged"
);
item.icon_path = mdiProgressWrench;
} else if (trace.script_execution === "finished") {
item.secondary = this.hass.localize(
"ui.panel.config.automation.trace.picker.finished",
{
executiontime: renderRuntime(),
}
);
item.icon_path = mdiCheckCircleOutline;
} else if (trace.script_execution === "aborted") {
item.secondary = this.hass.localize(
"ui.panel.config.automation.trace.picker.aborted",
{
executiontime: renderRuntime(),
}
);
item.icon_path = mdiAlertCircleOutline;
} else if (trace.script_execution === "cancelled") {
item.secondary = this.hass.localize(
"ui.panel.config.automation.trace.picker.cancelled",
{
executiontime: renderRuntime(),
}
);
item.icon_path = mdiAlertCircleOutline;
} else {
let message:
| "stopped_failed_conditions"
| "stopped_failed_single"
| "stopped_failed_max_runs"
| "stopped_error"
| "stopped_unknown_reason";
let error: string | undefined;
let icon: string;
switch (trace.script_execution) {
case "failed_conditions":
message = "stopped_failed_conditions";
icon = mdiStopCircleOutline;
break;
case "failed_single":
message = "stopped_failed_single";
icon = mdiStopCircleOutline;
break;
case "failed_max_runs":
message = "stopped_failed_max_runs";
icon = mdiStopCircleOutline;
break;
case "error":
message = "stopped_error";
error = trace.error!;
icon = mdiAlertCircleOutline;
break;
default:
message = "stopped_unknown_reason";
icon = mdiHelpCircleOutline;
}
item.secondary = this.hass.localize(
`ui.panel.config.automation.trace.picker.${message}`,
{
error,
executiontime: renderRuntime(),
}
);
item.icon_path = icon;
}
return item;
}) ?? [];
private _renderTracePickerValue = (runId: string) => {
const trace = this.traces?.find((t) => t.run_id === runId);
return html`${trace
? formatDateTimeWithSeconds(
new Date(trace.timestamp.start),
this.hass.locale,
this.hass.config
)
: runId}`;
};
static get styles(): CSSResultGroup {
return [
css`
ha-generic-picker {
width: 100%;
}
ha-button {
width: 100%;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-trace-picker": HaTracePicker;
}
}

View File

@@ -8,7 +8,6 @@ import { copyToClipboard } from "../common/util/copy-clipboard";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
import { showToast } from "../util/toast";
import "./ha-alert";
import "./ha-button";
import "./ha-code-editor";
import type { HaCodeEditor } from "./ha-code-editor";
@@ -58,15 +57,8 @@ export class HaYamlEditor extends LitElement {
@property({ attribute: "has-extra-actions", type: Boolean })
public hasExtraActions = false;
@property({ attribute: "show-errors", type: Boolean })
public showErrors = true;
@state() private _yaml = "";
@state() private _error = "";
@state() private _showingError = false;
@query("ha-code-editor") _codeEditor?: HaCodeEditor;
public setValue(value): void {
@@ -126,16 +118,14 @@ export class HaYamlEditor extends LitElement {
.disableFullscreen=${this.disableFullscreen}
.inDialog=${this.inDialog}
mode="yaml"
lint
autocomplete-entities
autocomplete-icons
.error=${this.isValid === false}
@value-changed=${this._onChange}
@blur=${this._onBlur}
@editor-save=${this._onEditorSave}
dir="ltr"
></ha-code-editor>
${this._showingError
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
${this.copyClipboard || this.hasExtraActions
? html`
<div class="card-actions">
@@ -158,9 +148,13 @@ export class HaYamlEditor extends LitElement {
private _onChange(ev: CustomEvent): void {
ev.stopPropagation();
this._yaml = ev.detail.value;
let parsed;
let parsed: unknown;
let isValid = true;
let errorMsg;
let errorMsg: string | undefined;
let yamlError: {
mark?: { position: number; line: number; column: number };
message?: string;
} | null = null;
if (this._yaml) {
try {
@@ -168,15 +162,13 @@ export class HaYamlEditor extends LitElement {
} catch (err: any) {
// Invalid YAML
isValid = false;
yamlError = err;
errorMsg = `${this.hass.localize("ui.components.yaml-editor.error", { reason: err.reason })}${err.mark ? ` (${this.hass.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
}
} else {
parsed = {};
}
this._error = errorMsg ?? "";
if (isValid) {
this._showingError = false;
}
this._codeEditor?.setYamlError(yamlError);
this.value = parsed;
this.isValid = isValid;
@@ -188,16 +180,23 @@ export class HaYamlEditor extends LitElement {
} as any);
}
private _onBlur(): void {
if (this.showErrors && this._error) {
this._showingError = true;
}
}
get yaml() {
return this._yaml;
}
get codemirror() {
return this._codeEditor?.codemirror;
}
get hasComments(): boolean {
return this._codeEditor?.hasComments ?? false;
}
private _onEditorSave(ev: CustomEvent): void {
fireEvent(this, "editor-save");
ev.stopPropagation();
}
private async _copyYaml(): Promise<void> {
if (this.yaml) {
await copyToClipboard(this.yaml);

View File

@@ -1,8 +1,6 @@
import { consume, type ContextType } from "@lit/context";
import { mdiMagnify } from "@mdi/js";
import { css, html, type PropertyValues } from "lit";
import { customElement, state } from "lit/decorators";
import { internationalizationContext } from "../../data/context";
import { customElement } from "lit/decorators";
import { HaInput } from "./ha-input";
/**
@@ -17,10 +15,6 @@ import { HaInput } from "./ha-input";
*/
@customElement("ha-input-search")
export class HaInputSearch extends HaInput {
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
constructor() {
super();
this.withClear = true;
@@ -35,7 +29,7 @@ export class HaInputSearch extends HaInput {
!this.placeholder &&
(!this.hasUpdated || changedProps.has("_i18n"))
) {
this.placeholder = this._i18n.localize("ui.common.search");
this.placeholder = this.i18n?.localize?.("ui.common.search") || "Search";
}
}

View File

@@ -2,19 +2,21 @@ import "@home-assistant/webawesome/dist/components/animation/animation";
import "@home-assistant/webawesome/dist/components/input/input";
import type WaInput from "@home-assistant/webawesome/dist/components/input/input";
import { HasSlotController } from "@home-assistant/webawesome/dist/internal/slot";
import { consume, type ContextType } from "@lit/context";
import { mdiClose, mdiEye, mdiEyeOff } from "@mdi/js";
import {
LitElement,
type PropertyValues,
type TemplateResult,
css,
html,
LitElement,
nothing,
type PropertyValues,
type TemplateResult,
} from "lit";
import { customElement, property, query } from "lit/decorators";
import { customElement, property, query, 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";
import { internationalizationContext } from "../../data/context";
import "../ha-icon-button";
import "../ha-svg-icon";
import "../ha-tooltip";
@@ -125,6 +127,10 @@ export class HaInput extends WaInputMixin(LitElement) {
@query("wa-input")
private _input?: WaInput;
@state()
@consume({ context: internationalizationContext, subscribe: true })
protected i18n?: ContextType<typeof internationalizationContext>;
private readonly _hasSlotController = new HasSlotController(
this,
"label",
@@ -233,19 +239,22 @@ export class HaInput extends WaInputMixin(LitElement) {
${this.renderStartDefault()}
</slot>
<slot name="end" slot="end"> ${this.renderEndDefault()} </slot>
<slot name="clear-icon" slot="clear-icon">
<ha-icon-button .path=${mdiClose}></ha-icon-button>
</slot>
<slot name="show-password-icon" slot="show-password-icon">
<slot name="clear-button" slot="clear-button">
<ha-icon-button
@keydown=${stopPropagation}
.path=${mdiEye}
@click=${this._handleClearClick}
.label=${this.i18n?.localize?.("ui.components.input.clear") ||
"Clear"}
.path=${mdiClose}
></ha-icon-button>
</slot>
<slot name="hide-password-icon" slot="hide-password-icon">
<slot name="password-toggle-button" slot="password-toggle-button">
<ha-icon-button
@keydown=${stopPropagation}
.path=${mdiEyeOff}
@click=${this._handlePasswordToggle}
.label=${this.i18n?.localize?.(
`ui.components.input.${this.passwordVisible ? "hide_password" : "show_password"}`
) || (this.passwordVisible ? "Hide password" : "Show password")}
.path=${this.passwordVisible ? mdiEyeOff : mdiEye}
></ha-icon-button>
</slot>
<div
@@ -293,6 +302,14 @@ export class HaInput extends WaInputMixin(LitElement) {
}
};
private _handleClearClick() {
this._input?.clear();
}
private _handlePasswordToggle() {
this.passwordVisible = !this.passwordVisible;
}
static styles = [
waInputStyles,
css`
@@ -414,6 +431,12 @@ export class HaInput extends WaInputMixin(LitElement) {
color: var(--ha-color-text-secondary);
}
ha-icon-button {
display: flex;
align-items: center;
color: var(--ha-color-text-secondary);
}
:host([appearance="outlined"]) wa-input.no-label {
--ha-icon-button-size: 24px;
--mdc-icon-size: 18px;

View File

@@ -1,101 +0,0 @@
import type { CSSResultGroup } from "lit";
import { css } from "lit";
import { customElement, property } from "lit/decorators";
import { HaRowItem } from "./ha-row-item";
/**
* @element ha-list-item-base
* @extends {HaRowItem}
*
* @summary
* Non-interactive list row (role `listitem`). Base class for
* `ha-list-item-button`, `ha-list-item-option`.
*
* @cssprop --ha-list-item-focus-radius - Focus outline border-radius.
* @cssprop --ha-list-item-focus-width - Focus outline width (steady state).
* @cssprop --ha-list-item-focus-width-start - Focus outline width at the start of the focus-in animation.
* @cssprop --ha-list-item-focus-offset - Focus outline offset.
* @cssprop --ha-list-item-focus-background - Background color applied on keyboard focus.
*
* @attr {boolean} interactive - Opts the row into the parent list's roving tabindex. Interactive subclasses set this automatically.
*/
@customElement("ha-list-item-base")
export class HaListItemBase extends HaRowItem {
/**
* Whether the item takes keyboard focus. Read by the parent list to decide
* if it should be part of the roving-tabindex ring. Interactive subclasses
* (`ha-list-item-button`, `-option`, `-todo`) override the default to `true`.
* For the plain base row, set the `interactive` attribute to opt into focus
* (useful for sortable rows where you need keyboard reorder but no click
* action).
*/
@property({ type: Boolean, reflect: true }) public interactive = false;
/** Host `role` attribute. Subclasses override. */
protected readonly defaultRole: string = "listitem";
public connectedCallback(): void {
super.connectedCallback();
if (!this.hasAttribute("role")) {
this.setAttribute("role", this.defaultRole);
}
}
/**
* Activate the item (Enter/Space from the parent list). Default dispatches
* a click on the host. Subclasses that wrap a native element (e.g. `<a>`)
* override this to click the inner element so browser default actions
* (like anchor navigation) fire.
*/
public activate(): void {
this.click();
}
static styles: CSSResultGroup = [
HaRowItem.styles,
css`
:host {
--ha-list-item-focus-radius: var(--ha-border-radius-sm);
--ha-list-item-focus-width: 2px;
--ha-list-item-focus-width-start: var(--ha-space-2);
--ha-list-item-focus-offset: -2px;
--ha-list-item-focus-background: var(
--ha-color-fill-neutral-quiet-hover
);
}
:host(:focus) {
outline: none;
}
.base {
border-radius: var(--ha-list-item-focus-radius);
outline: var(--ha-list-item-focus-width) solid transparent;
outline-offset: var(--ha-list-item-focus-offset);
transition:
outline-color var(--ha-animation-duration-fast) ease-out,
background-color var(--ha-animation-duration-fast) ease-out;
}
@keyframes ha-list-item-focus-in {
from {
outline-width: var(--ha-list-item-focus-width-start);
outline-offset: calc(-1 * var(--ha-list-item-focus-width-start));
}
to {
outline-width: var(--ha-list-item-focus-width);
outline-offset: var(--ha-list-item-focus-offset);
}
}
:host(:focus-visible) .base {
outline-color: var(--ha-color-focus);
background-color: var(--ha-list-item-focus-background);
animation: ha-list-item-focus-in var(--ha-animation-duration-normal)
ease-in;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-list-item-base": HaListItemBase;
}
}

View File

@@ -1,109 +0,0 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import "../ha-ripple";
import { HaListItemBase } from "./ha-list-item-base";
/**
* @element ha-list-item-button
* @extends {HaListItemBase}
*
* @summary
* Interactive list row. Renders an inner `<a>` when `href` is set, otherwise
* a `<button>`. The full row is the hit target. When placed in a list using
* roving tabindex, the host is the tab stop and the inner element carries
* `tabindex="-1"`. For a non-interactive row, use `ha-list-item-base`.
*
* @csspart ripple - The ripple effect element.
*
* @attr {string} href - URL. When set, renders an `<a>` instead of a `<button>`.
* @attr {string} target - Anchor `target` attribute (requires `href`).
* @attr {string} rel - Anchor `rel` attribute (requires `href`).
* @attr {string} download - Anchor `download` attribute (requires `href`).
*/
@customElement("ha-list-item-button")
export class HaListItemButton extends HaListItemBase {
public override interactive = true;
@property({ type: String }) public href?: string;
@property({ type: String }) public target?: string;
@property({ type: String }) public rel?: string;
@property({ type: String }) public download?: string;
public override activate(): void {
this.renderRoot.querySelector<HTMLElement>("#item")?.click();
}
protected _renderBase(inner: TemplateResult): TemplateResult {
if (this.href !== undefined) {
return html`<a
part="base"
class="base interactive"
id="item"
href=${ifDefined(this.disabled ? undefined : this.href)}
target=${ifDefined(this.target)}
rel=${ifDefined(this.rel)}
download=${ifDefined(this.download)}
tabindex="-1"
aria-disabled=${this.disabled ? "true" : "false"}
>
${this._renderRipple()}${inner}
</a>`;
}
return html`<button
part="base"
class="base interactive"
id="item"
type="button"
?disabled=${this.disabled}
tabindex="-1"
>
${this._renderRipple()}${inner}
</button>`;
}
private _renderRipple() {
return html`<ha-ripple
part="ripple"
for="item"
?disabled=${this.disabled}
></ha-ripple>`;
}
static styles: CSSResultGroup = [
HaListItemBase.styles,
css`
:host {
cursor: pointer;
--ha-ripple-color: var(--primary-text-color);
}
:host([disabled]) {
cursor: default;
}
.base.interactive {
width: 100%;
border: none;
background: transparent;
color: inherit;
font: inherit;
text-align: inherit;
text-decoration: none;
appearance: none;
cursor: inherit;
}
:host([disabled]) .base.interactive {
color: var(--disabled-text-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-list-item-button": HaListItemButton;
}
}

View File

@@ -1,132 +0,0 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../ha-checkbox";
import "../ha-ripple";
import { HaListItemBase } from "./ha-list-item-base";
export type HaListItemOptionAppearance = "line" | "checkbox";
export type HaListItemOptionSelectionPosition = "start" | "end";
/**
* @element ha-list-item-option
* @extends {HaListItemBase}
*
* @summary
* Selectable list row (role `option`). Selection state is driven by the parent
* `ha-list-selectable`; reflects `aria-selected`. When `appearance="checkbox"`, renders
* a decorative `<ha-checkbox>` (clicks on the row are handled by the listbox).
*
* @csspart checkbox - Wrapper around the `<ha-checkbox>` when `appearance="checkbox"`.
* @csspart ripple - The ripple effect element.
*
* @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 {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"`.
*/
@customElement("ha-list-item-option")
export class HaListItemOption extends HaListItemBase {
@property({ type: Boolean, reflect: true }) public selected = false;
@property({ type: String }) public value?: string;
@property({ type: String, reflect: true })
public appearance: HaListItemOptionAppearance = "line";
@property({ type: String, reflect: true, attribute: "selection-position" })
public selectionPosition: HaListItemOptionSelectionPosition = "start";
protected override readonly defaultRole = "option";
public override interactive = true;
public update(changed: Map<string, unknown>) {
super.update(changed);
if (changed.has("selected")) {
this.setAttribute("aria-selected", this.selected ? "true" : "false");
}
if (changed.has("disabled")) {
this.setAttribute("aria-disabled", this.disabled ? "true" : "false");
}
}
protected _renderBase(inner: TemplateResult): TemplateResult {
return html`<div part="base" class="base" id="item">
${this._renderRipple()}${this.selectionPosition === "start"
? this._renderCheckbox()
: nothing}${inner}${this.selectionPosition === "end"
? this._renderCheckbox()
: nothing}
</div>`;
}
private _renderRipple() {
return html`<ha-ripple
part="ripple"
for="item"
?disabled=${this.disabled}
></ha-ripple>`;
}
private _renderCheckbox() {
if (this.appearance !== "checkbox") {
return nothing;
}
return html`<div part="checkbox" class="checkbox" inert>
<ha-checkbox
.checked=${this.selected}
.disabled=${this.disabled}
></ha-checkbox>
</div>`;
}
static styles: CSSResultGroup = [
HaListItemBase.styles,
css`
:host {
cursor: pointer;
--ha-ripple-color: var(--primary-text-color);
--ha-list-item-selected-background: var(
--ha-color-fill-primary-quiet-resting,
rgba(var(--rgb-primary-color), 0.08)
);
}
:host([disabled]) {
cursor: default;
}
.base {
cursor: inherit;
}
:host([appearance="line"][selected]:not([disabled])) .base,
:host([appearance="line"][active]:not([disabled])) .base {
background-color: var(--ha-list-item-selected-background);
}
:host([appearance="line"][selected]:not([disabled])) {
color: var(--primary-color);
}
.checkbox {
display: flex;
align-items: center;
flex: 0 0 auto;
pointer-events: none;
}
.checkbox ha-checkbox {
pointer-events: none;
}
ha-checkbox::part(base) {
gap: 0;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-list-item-option": HaListItemOption;
}
}

View File

@@ -1,170 +0,0 @@
import { HasSlotController } from "@home-assistant/webawesome/dist/internal/slot";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
/**
* @element ha-row-item
* @extends {LitElement}
*
* @summary
* Generic row layout primitive. Renders a horizontal row with optional
* leading/trailing slots and a stacked middle column (headline +
* supporting text). Role-agnostic; use `ha-list-item-base` and its
* subclasses for list semantics.
*
* @slot start - Leading container (usually icon/avatar).
* @slot end - Trailing container (usually meta/chevron).
* @slot headline - Primary text (overrides the `headline` attribute).
* @slot supporting-text - Secondary text (overrides the `supporting-text` attribute).
* @slot content - Escape hatch: replaces the entire middle column (headline + supporting-text).
*
* @csspart base - The outer container.
* @csspart start - The leading slot wrapper.
* @csspart content - The middle column wrapper.
* @csspart headline - The headline wrapper.
* @csspart supporting-text - The supporting-text wrapper.
* @csspart end - The trailing slot wrapper.
*
* @cssprop --ha-row-item-padding-block - Vertical padding inside the row.
* @cssprop --ha-row-item-padding-inline - Horizontal padding inside the row.
* @cssprop --ha-row-item-gap - Gap between start, content, and end.
* @cssprop --ha-row-item-min-height - Minimum row height.
*
* @attr {string} headline - Primary text. Overridden by the `headline` slot.
* @attr {string} supporting-text - Secondary text. Overridden by the `supporting-text` slot.
* @attr {boolean} disabled - Dims the row and blocks pointer events.
*/
@customElement("ha-row-item")
export class HaRowItem extends LitElement {
@property({ type: String }) public headline?: string;
@property({ type: String, attribute: "supporting-text" })
public supportingText?: string;
@property({ type: Boolean, reflect: true }) public disabled = false;
protected readonly _slotController = new HasSlotController(
this,
"start",
"end",
"headline",
"supporting-text",
"content"
);
protected render(): TemplateResult {
return this._renderBase(this._renderInner());
}
protected _renderBase(inner: TemplateResult): TemplateResult {
return html`<div part="base" class="base" id="item">${inner}</div>`;
}
protected _renderInner(): TemplateResult {
const hasStart = this._slotController.test("start");
const hasEnd = this._slotController.test("end");
const hasContent = this._slotController.test("content");
return html`
${hasStart
? html`<div part="start" class="start">
<slot name="start"></slot>
</div>`
: nothing}
<div part="content" class="content">
${hasContent
? html`<slot name="content"></slot>`
: this._renderDefaultContent()}
</div>
${hasEnd
? html`<div part="end" class="end">
<slot name="end"></slot>
</div>`
: nothing}
`;
}
protected _renderDefaultContent(): TemplateResult {
const hasHeadlineSlot = this._slotController.test("headline");
const hasSupportingSlot = this._slotController.test("supporting-text");
const showHeadline = hasHeadlineSlot || this.headline !== undefined;
const showSupporting =
hasSupportingSlot || this.supportingText !== undefined;
return html`
${showHeadline
? html`<div part="headline" class="headline">
<slot name="headline">${this.headline ?? nothing}</slot>
</div>`
: nothing}
${showSupporting
? html`<div part="supporting-text" class="supporting">
<slot name="supporting-text"
>${this.supportingText ?? nothing}</slot
>
</div>`
: nothing}
`;
}
static styles: CSSResultGroup = css`
:host {
display: block;
color: var(--primary-text-color);
font-size: var(--ha-font-size-m);
line-height: var(--ha-line-height-normal);
--ha-row-item-padding-block: var(--ha-space-3);
--ha-row-item-padding-inline: var(--ha-space-4);
--ha-row-item-gap: var(--ha-space-4);
--ha-row-item-min-height: 48px;
}
:host([disabled]) {
color: var(--disabled-text-color);
pointer-events: none;
}
.base {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
gap: var(--ha-row-item-gap);
padding-block: var(--ha-row-item-padding-block);
padding-inline: var(--ha-row-item-padding-inline);
min-height: var(--ha-row-item-min-height);
box-sizing: border-box;
}
.content {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
}
.start,
.end {
display: flex;
align-items: center;
flex: 0 0 auto;
}
.headline {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.supporting {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-row-item": HaRowItem;
}
}

View File

@@ -1,291 +0,0 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { tinykeys } from "tinykeys";
import { fireEvent } from "../../common/dom/fire_event";
import { HaListItemBase } from "../item/ha-list-item-base";
import "./types";
/**
* @element ha-list-base
* @extends {LitElement}
*
* @summary
* Base list container with roving-tabindex keyboard navigation (ArrowUp/Down,
* Home/End, optional Enter/Space activation, optional wrap-focus). Discovers
* slotted `HaListItemBase` descendants. Subclasses override `hostRole` and/or
* `render()` to specialize.
*
* @slot - List items (`<ha-list-item-*>`).
*
* @csspart base - The outer container (`<div role="list">`).
*
* @cssprop --ha-list-gap - Spacing between items. Defaults to `0`.
* @cssprop --ha-list-padding - Padding around the list content. Defaults to `0`.
*
* @attr {boolean} wrap-focus - Whether ArrowUp/Down navigation wraps at the ends.
* @attr {string} aria-label - Accessible label for the list.
*
* @fires ha-list-activated - Fired when an item is activated via Enter/Space. `detail: { index, item }`.
*/
@customElement("ha-list-base")
export class HaListBase extends LitElement {
@property({ type: Boolean, attribute: "wrap-focus" })
public wrapFocus = false;
@property({ type: String, attribute: "aria-label", reflect: true })
public ariaLabel: string | null = null;
public items: readonly HaListItemBase[] = [];
/** Host `role` attribute. Empty string means no role is set. */
protected readonly hostRole: string = "list";
private _activeItemIndex = -1;
private _firstFocusableIndex = -1;
private _lastFocusableIndex = -1;
private _hasFocusableItem = false;
private _unbindKeys?: () => void;
public connectedCallback() {
super.connectedCallback();
if (!this.hasAttribute("ha-list")) {
this.setAttribute("ha-list", "");
}
if (!this.hasAttribute("role") && this.hostRole) {
this.setAttribute("role", this.hostRole);
}
this._unbindKeys = tinykeys(this, {
ArrowDown: this._onForward,
ArrowUp: this._onBack,
Home: this._onHome,
End: this._onEnd,
Enter: this._onActivate,
Space: this._onActivate,
});
this.addEventListener("focusin", this._onFocusIn);
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unbindKeys?.();
this._unbindKeys = undefined;
this.removeEventListener("focusin", this._onFocusIn);
}
public firstUpdated(changed: PropertyValues) {
super.firstUpdated(changed);
this.updateListItems();
}
public focus(options?: FocusOptions) {
if (!this.items.length) {
super.focus(options);
return;
}
this.focusItemAtIndex(
this._activeItemIndex >= 0 ? this._activeItemIndex : 0
);
}
public focusItemAtIndex(index: number) {
if (index < 0) {
return;
}
this.setActiveItemIndex(index, true);
}
public getActiveItemIndex(): number {
return this._activeItemIndex;
}
public setActiveItemIndex(index: number, focusItem = false) {
if (!this._hasFocusableItem) {
this._activeItemIndex = -1;
return;
}
this._activeItemIndex = Math.max(0, Math.min(this.items.length - 1, index));
if (!this._isFocusable(this._activeItemIndex)) {
this._activeItemIndex = this._firstFocusableIndex;
}
this._applyActive(focusItem);
}
public updateListItems() {
const next = this._discoverListItems();
const changed =
next.length !== this.items.length ||
next.some((it, i) => it !== this.items[i]);
if (!changed) {
return;
}
this.items = next;
this._recomputeFocusableIndexes();
if (
this._activeItemIndex >= next.length ||
!this._hasFocusableItem ||
this._activeItemIndex < 0
) {
this._activeItemIndex = this._firstFocusableIndex;
}
this._applyActive(false);
}
private _recomputeFocusableIndexes() {
let first = -1;
let last = -1;
for (let i = 0; i < this.items.length; i++) {
if (this._isFocusable(i)) {
if (first === -1) {
first = i;
}
last = i;
}
}
this._firstFocusableIndex = first;
this._lastFocusableIndex = last;
this._hasFocusableItem = first !== -1;
}
public handleSlotChange = () => {
this.updateListItems();
};
protected render(): TemplateResult {
return html`<div part="base" class="base">
<slot @slotchange=${this.handleSlotChange}></slot>
</div>`;
}
private _discoverListItems(): HaListItemBase[] {
const slot =
this.renderRoot?.querySelector<HTMLSlotElement>("slot:not([name])");
if (!slot) {
return [];
}
return slot
.assignedElements({ flatten: true })
.filter((el): el is HaListItemBase => el instanceof HaListItemBase);
}
private _isFocusable(index: number): boolean {
const item = this.items[index];
return !!item && item.interactive && !item.disabled;
}
private _applyActive(focusItem: boolean) {
this.items.forEach((item, i) => {
if (!item.interactive || item.disabled) {
item.removeAttribute("tabindex");
return;
}
item.tabIndex = i === this._activeItemIndex ? 0 : -1;
});
if (focusItem && this._activeItemIndex >= 0) {
this.items[this._activeItemIndex]?.focus();
}
}
private _onFocusIn = (ev: FocusEvent) => {
const path = ev.composedPath();
for (let i = 0; i < this.items.length; i++) {
if (path.includes(this.items[i])) {
if (i !== this._activeItemIndex) {
this._activeItemIndex = i;
this._applyActive(false);
}
return;
}
}
};
private _onForward = (ev: KeyboardEvent) => {
this._moveFocus(ev, this._stepIndex(this._activeItemIndex, 1));
};
private _onBack = (ev: KeyboardEvent) => {
this._moveFocus(ev, this._stepIndex(this._activeItemIndex, -1));
};
private _onHome = (ev: KeyboardEvent) => {
this._moveFocus(ev, this._firstFocusableIndex);
};
private _onEnd = (ev: KeyboardEvent) => {
this._moveFocus(ev, this._lastFocusableIndex);
};
private _onActivate = (ev: KeyboardEvent) => {
if (!this._isFocusable(this._activeItemIndex)) {
return;
}
ev.preventDefault();
const active = this.items[this._activeItemIndex];
active.activate();
fireEvent(this, "ha-list-activated", {
index: this._activeItemIndex,
item: active,
});
};
private _moveFocus(ev: KeyboardEvent, next: number) {
if (!this._hasFocusableItem || next < 0 || next === this._activeItemIndex) {
return;
}
ev.preventDefault();
this._activeItemIndex = next;
this._applyActive(true);
}
/**
* Step from `from` by `delta`, skipping non-interactive and disabled items.
* Returns `from` when no other focusable item can be reached (honouring
* `wrapFocus`).
*/
private _stepIndex(from: number, delta: 1 | -1): number {
const n = this.items.length;
if (!n || !this._hasFocusableItem) {
return from;
}
let i = from;
for (let step = 0; step < n; step++) {
i += delta;
if (i < 0 || i >= n) {
if (!this.wrapFocus) {
return from;
}
i = (i + n) % n;
}
if (this._isFocusable(i)) {
return i;
}
}
return from;
}
static styles: CSSResultGroup = css`
:host {
display: block;
--ha-list-gap: 0;
--ha-list-padding: 0;
}
.base {
display: flex;
flex-direction: column;
gap: var(--ha-list-gap);
padding: var(--ha-list-padding);
margin: 0;
list-style: none;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-list-base": HaListBase;
}
}

View File

@@ -1,44 +0,0 @@
import type { TemplateResult } from "lit";
import { html } from "lit";
import { customElement, property } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { HaListBase } from "./ha-list-base";
/**
* @element ha-list-nav
* @extends {HaListBase}
*
* @summary
* Navigation list. Wraps the list in a `<nav>` landmark. Items should be
* `<ha-list-item-button>` with an `href`. Use `aria-label` to name the landmark.
*
* @csspart nav - The `<nav>` wrapper.
* @csspart base - The inner `<div role="list">`.
*/
@customElement("ha-list-nav")
export class HaListNav extends HaListBase {
// No host role — the inner <nav> carries the landmark semantics, and the
// inner <div role="list"> preserves the list semantics for screen readers.
protected override readonly hostRole = "";
// The label is forwarded to the inner <nav>
@property({ type: String, attribute: "aria-label", reflect: false })
public override ariaLabel: string | null = null;
protected render(): TemplateResult {
return html`<nav
part="nav"
aria-label=${ifDefined(this.ariaLabel ?? undefined)}
>
<div part="base" class="base" role="list">
<slot @slotchange=${this.handleSlotChange}></slot>
</div>
</nav>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-list-nav": HaListNav;
}
}

View File

@@ -1,212 +0,0 @@
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import { HaListItemOption } from "../item/ha-list-item-option";
import { HaListBase } from "./ha-list-base";
import type { HaListSelectedDetail } from "./types";
/**
* @element ha-list-selectable
* @extends {HaListBase}
*
* @summary
* Selection list (role `listbox`). Items must be `<ha-list-item-option>`.
* Toggle single vs multi selection via the `multi` attribute.
*
* @attr {boolean} multi - Whether multiple options can be selected at once.
*
* @fires ha-list-selected - Fired when the selection changes. `detail: HaListSelectedDetail`.
*/
@customElement("ha-list-selectable")
export class HaListSelectable extends HaListBase {
@property({ type: Boolean, reflect: true }) public multi = false;
protected override readonly hostRole = "listbox";
private _selectedIndices?: Set<number>;
public connectedCallback(): void {
super.connectedCallback();
this.addEventListener("click", this._onOptionClick);
this.setAttribute("aria-multiselectable", this.multi ? "true" : "false");
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener("click", this._onOptionClick);
}
public updated(changed: Map<string, unknown>) {
super.updated(changed);
if (changed.has("multi")) {
this.setAttribute("aria-multiselectable", this.multi ? "true" : "false");
if (!this.multi && (this._selectedIndices?.size ?? 0) > 1) {
const first = Math.min(...this._selectedIndices!);
this._setSelection(new Set([first]));
}
}
}
/**
* Returns the current selection. `number` (or `-1` if nothing) when single,
* `Set<number>` when multi.
*/
public get selected(): number | Set<number> {
if (this.multi) {
return new Set(this._selectedIndices);
}
return (this._selectedIndices?.size ?? 0) === 0
? -1
: this._selectedIndices!.values().next().value!;
}
public get selectedItems(): HaListItemOption[] {
return this._sortedSelectedIndices()
.map((i) => this.items[i] as HaListItemOption | undefined)
.filter((it): it is HaListItemOption => !!it);
}
/** Replace the entire selection. */
public setSelected(indices: number | number[] | Set<number>): void {
const next =
typeof indices === "number"
? indices < 0
? new Set<number>()
: new Set([indices])
: new Set(indices);
if (!this.multi && next.size > 1) {
const first = Math.min(...next);
this._setSelection(new Set([first]));
return;
}
this._setSelection(next);
}
public select(index: number): void {
if (index < 0) {
return;
}
if (this.multi) {
const next = new Set(this._selectedIndices);
next.add(index);
this._setSelection(next);
} else {
this._setSelection(new Set([index]));
}
}
public toggle(index: number, force?: boolean): void {
if (index < 0) {
return;
}
if (this.multi) {
const next = new Set(this._selectedIndices);
const isSelected = next.has(index);
const shouldSelect = force !== undefined ? force : !isSelected;
if (shouldSelect) {
next.add(index);
} else {
next.delete(index);
}
this._setSelection(next);
} else {
const isSelected = this._selectedIndices!.has(index);
const shouldSelect = force !== undefined ? force : !isSelected;
this._setSelection(shouldSelect ? new Set([index]) : new Set());
}
}
public clearSelection(): void {
this._setSelection(new Set());
}
public updateListItems() {
super.updateListItems();
this._syncItemSelectedState();
}
private _sortedSelectedIndices(): number[] {
return [...this._selectedIndices!].sort((a, b) => a - b);
}
private _syncItemSelectedState() {
if (!this._selectedIndices) {
this._selectedIndices = new Set<number>();
this.items.forEach((item, i) => {
const opt = item as HaListItemOption;
if (opt.selected) {
this._selectedIndices!.add(i);
}
});
return;
}
this.items.forEach((item, i) => {
const opt = item as HaListItemOption;
const shouldBe = this._selectedIndices!.has(i);
if (opt.selected !== shouldBe) {
opt.selected = shouldBe;
}
});
}
private _setSelection(next: Set<number>): void {
const prev = this._selectedIndices!;
const added = new Set<number>();
const removed = new Set<number>();
next.forEach((i) => {
if (!prev.has(i)) {
added.add(i);
}
});
prev.forEach((i) => {
if (!next.has(i)) {
removed.add(i);
}
});
if (!added.size && !removed.size) {
return;
}
this._selectedIndices = next;
this._syncItemSelectedState();
const detail: HaListSelectedDetail = this.multi
? { index: new Set(next), diff: { added, removed } }
: {
index: next.size === 0 ? -1 : next.values().next().value!,
diff: { added, removed },
};
fireEvent(this, "ha-list-selected", detail);
}
private _onOptionClick = (ev: Event) => {
const path = ev.composedPath();
for (const el of path) {
if (el === this) {
return;
}
if (el instanceof HaListItemOption) {
const index = this.items.indexOf(el);
if (index < 0) {
return;
}
const item = this.items[index];
if (item.disabled) {
return;
}
if (this.multi) {
this.toggle(index);
} else {
this.select(index);
}
return;
}
}
};
}
declare global {
interface HTMLElementTagNameMap {
"ha-list-selectable": HaListSelectable;
}
}

View File

@@ -1,19 +0,0 @@
import type { HaListItemBase } from "../item/ha-list-item-base";
export interface HaListSelectedDetail {
index: number | Set<number>;
diff?: { added: Set<number>; removed: Set<number> };
value?: string | string[];
}
export interface HaListActivatedDetail {
index: number;
item: HaListItemBase;
}
declare global {
interface HASSDomEvents {
"ha-list-selected": HaListSelectedDetail;
"ha-list-activated": HaListActivatedDetail;
}
}

View File

@@ -602,7 +602,7 @@ export class HaMap extends ReactiveElement {
}
// create icon
let iconHTML = "";
let iconHTML: string;
if (icon) {
const el = document.createElement("ha-icon");
el.setAttribute("icon", icon);

View File

@@ -0,0 +1,264 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { consume, type ContextType } from "@lit/context";
import { mdiMonitor } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { supportsFeature } from "../../common/entity/supports-feature";
import {
areasContext,
configContext,
devicesContext,
entitiesContext,
floorsContext,
internationalizationContext,
statesContext,
} from "../../data/context";
import { UNAVAILABLE } from "../../data/entity/entity";
import {
entityComboBoxKeys,
type EntityComboBoxItem,
} from "../../data/entity/entity_picker";
import { domainToName } from "../../data/integration";
import {
BROWSER_PLAYER,
type MediaPlayerEntity,
MediaPlayerEntityFeature,
} from "../../data/media-player";
import "../entity/state-badge";
import "../ha-combo-box-item";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import type {
PickerComboBoxItem,
PickerComboBoxSearchFn,
} from "../ha-picker-combo-box";
import "../ha-svg-icon";
interface BrowserPlayerComboBoxItem extends EntityComboBoxItem {
id: typeof BROWSER_PLAYER;
icon_path: string;
stateObj?: never;
}
interface MediaPlayerComboBoxItem extends EntityComboBoxItem {
icon_path?: never;
stateObj: MediaPlayerEntity;
}
type PlayerComboBoxItem = BrowserPlayerComboBoxItem | MediaPlayerComboBoxItem;
@customElement("ha-media-player-picker")
export class HaMediaPlayerPicker extends LitElement {
@property() public value?: string;
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@consume({ context: entitiesContext, subscribe: true })
private _entities!: ContextType<typeof entitiesContext>;
@consume({ context: devicesContext, subscribe: true })
private _devices!: ContextType<typeof devicesContext>;
@consume({ context: areasContext, subscribe: true })
private _areas!: ContextType<typeof areasContext>;
@consume({ context: floorsContext, subscribe: true })
private _floors!: ContextType<typeof floorsContext>;
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: configContext, subscribe: true })
private _hassConfig!: ContextType<typeof configContext>;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
protected render() {
return html`
<ha-generic-picker
.value=${this.value}
.getItems=${this._getPlayerItems}
.rowRenderer=${this._playerRowRenderer}
.searchFn=${this._playerSearchFn}
.searchKeys=${entityComboBoxKeys}
.notFoundLabel=${this._notFoundPlayerLabel}
.popoverPlacement=${"top-end"}
.hideClearIcon=${true}
@value-changed=${this._valueChanged}
>
<slot name="field" slot="field" @click=${this.open}></slot>
</ha-generic-picker>
`;
}
public open(ev?: Event): void {
this._picker?.open(ev, { selectedValue: this.value });
}
private _getPlayerItems = (): PlayerComboBoxItem[] => {
const webBrowserLabel = this._i18n.localize(
"ui.components.media-browser.web-browser"
);
const lang = this._i18n.language || "en";
const isRTL =
this._i18n.translationMetadata.translations[lang]?.isRTL || false;
return [
{
id: BROWSER_PLAYER,
primary: webBrowserLabel,
icon_path: mdiMonitor,
search_labels: {
entityName: webBrowserLabel,
friendlyName: webBrowserLabel,
deviceName: null,
areaName: null,
domainName: null,
entityId: BROWSER_PLAYER,
},
},
...Object.values(this._states)
.filter(this._filterPlayerEntities)
.map<MediaPlayerComboBoxItem>((stateObj) => {
const friendlyName = computeStateName(stateObj);
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this._entities,
this._devices,
this._areas,
this._floors
);
const entityId = stateObj.entity_id;
const domainName = domainToName(
this._i18n.localize,
computeDomain(entityId)
);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
return {
id: entityId,
primary,
secondary,
disabled: stateObj.state === UNAVAILABLE,
domain_name: domainName,
sorting_label: [primary, secondary].filter(Boolean).join("_"),
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
areaName: areaName || null,
domainName: domainName || null,
friendlyName: friendlyName || null,
entityId,
},
stateObj,
};
}),
];
};
private _filterPlayerEntities = (
entity: HassEntity
): entity is MediaPlayerEntity =>
computeStateDomain(entity) === "media_player" &&
supportsFeature(entity, MediaPlayerEntityFeature.BROWSE_MEDIA) &&
!this._entities[entity.entity_id]?.hidden;
private _playerRowRenderer: RenderItemFunction<PickerComboBoxItem> = (
item: PickerComboBoxItem,
index: number
) => {
const stateObj = this._isMediaPlayerItem(item) ? item.stateObj : undefined;
return html`
<div
style=${styleMap({
width: "100%",
borderTop: index === 0 ? undefined : "1px solid var(--divider-color)",
})}
>
<ha-combo-box-item type="button" compact .disabled=${!!item.disabled}>
${item.icon_path
? html`
<ha-svg-icon
slot="start"
style="margin: 0 4px"
.path=${item.icon_path}
></ha-svg-icon>
`
: html`
<state-badge slot="start" .stateObj=${stateObj}></state-badge>
`}
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
${stateObj && this._hassConfig.userData?.showEntityIdPicker
? html`
<span slot="supporting-text" class="code">
${stateObj.entity_id}
</span>
`
: nothing}
</ha-combo-box-item>
</div>
`;
};
private _playerSearchFn: PickerComboBoxSearchFn<PickerComboBoxItem> = (
search: string,
filteredItems: PickerComboBoxItem[]
) => {
const index = filteredItems.findIndex((item) => item.id === search);
if (index === -1) {
return filteredItems;
}
const [exactMatch] = filteredItems.splice(index, 1);
filteredItems.unshift(exactMatch);
return filteredItems;
};
private _isMediaPlayerItem(
item: PickerComboBoxItem
): item is MediaPlayerComboBoxItem {
return "stateObj" in item && item.stateObj !== undefined;
}
private _notFoundPlayerLabel = (search: string) =>
this._i18n.localize("ui.components.entity.entity-picker.no_match", {
term: html`<b>${search}</b>`,
});
private _valueChanged(ev: HASSDomEvent<{ value: string }>): void {
ev.stopPropagation();
if (!ev.detail.value) {
return;
}
fireEvent(this, "value-changed", { value: ev.detail.value });
}
static styles = css`
ha-generic-picker {
--ha-generic-picker-width: min(360px, calc(100vw - 32px));
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-media-player-picker": HaMediaPlayerPicker;
}
}

View File

@@ -428,7 +428,8 @@ export class HaTargetPickerItemRow extends LitElement {
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
this.entityFilter,
!this.primaryEntitiesOnly
)
) {
return true;
@@ -458,7 +459,8 @@ export class HaTargetPickerItemRow extends LitElement {
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
this.entityFilter,
!this.primaryEntitiesOnly
)
) {
return true;

View File

@@ -322,6 +322,7 @@ export interface ShorthandNotCondition extends ShorthandBaseCondition {
export interface AutomationElementGroupCollection {
titleKey?: LocalizeKeys;
generic?: boolean;
groups: AutomationElementGroup;
}

View File

@@ -328,13 +328,12 @@ const describeLegacyTrigger = (
let fromChoice = "other";
let fromString = "";
if (trigger.from !== undefined) {
let fromArray: string[] = [];
if (trigger.from === null) {
if (!trigger.attribute) {
fromChoice = "null";
}
} else {
fromArray = ensureArray(trigger.from);
const fromArray = ensureArray(trigger.from);
const from: string[] = [];
for (const state of fromArray) {
@@ -362,13 +361,12 @@ const describeLegacyTrigger = (
let toChoice = "other";
let toString = "";
if (trigger.to !== undefined) {
let toArray: string[] = [];
if (trigger.to === null) {
if (!trigger.attribute) {
toChoice = "null";
}
} else {
toArray = ensureArray(trigger.to);
const toArray = ensureArray(trigger.to);
const to: string[] = [];
for (const state of toArray) {
@@ -521,7 +519,7 @@ const describeLegacyTrigger = (
| "every_interval"
| "on_the_xth"
| "other"
| "has_seconds_or_minutes" = "other";
| "has_seconds_or_minutes";
let seconds = 0;
let minutes = 0;

View File

@@ -189,6 +189,20 @@ export const updateBackupConfig = (
config: BackupMutableConfig
) => hass.callWS({ type: "backup/config/update", ...config });
export const saveBackupConfig = (hass: HomeAssistant, config: BackupConfig) =>
updateBackupConfig(hass, {
create_backup: {
agent_ids: config.create_backup.agent_ids,
include_folders: config.create_backup.include_folders ?? [],
include_database: config.create_backup.include_database,
include_addons: config.create_backup.include_addons ?? [],
include_all_addons: config.create_backup.include_all_addons,
password: config.create_backup.password,
},
retention: config.retention,
schedule: config.schedule,
});
export const getBackupDownloadUrl = (
id: string,
agentId: string,

View File

@@ -8,28 +8,31 @@ import type { Selector, TargetSelector } from "./selector";
export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [
{
groups: {
device: {},
dynamicGroups: {},
entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
time_location: {
icon: mdiMapClock,
members: { sun: {}, time: {}, zone: {} },
},
helpers: {},
template: {},
trigger: {},
other: {},
},
},
{
titleKey:
"ui.panel.config.automation.editor.conditions.groups.helpers.label",
"ui.panel.config.automation.editor.conditions.groups.generic.label",
generic: true,
groups: {
helpers: {},
device: {},
entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
},
},
{
titleKey: "ui.panel.config.automation.editor.conditions.groups.other.label",
titleKey:
"ui.panel.config.automation.editor.conditions.groups.custom_integrations.label",
groups: {
template: {},
trigger: {},
other: {},
customDynamicGroups: {},
},
},
] as const;

View File

@@ -11,6 +11,7 @@ import type {
} from "../../types";
import type { ConfigEntry } from "../config_entries";
import type { EntityRegistryEntry } from "../entity/entity_registry";
import type { DomainManifestLookup } from "../integration";
import type { LabelRegistryEntry } from "../label/label_registry";
/**
@@ -107,6 +108,12 @@ export const fullEntitiesContext =
export const configEntriesContext =
createContext<ConfigEntry[]>("configEntries");
/**
* Lazy loaded integration manifests, keyed by domain.
*/
export const manifestsContext =
createContext<DomainManifestLookup>("manifests");
// #endregion lazy-contexts
// #region deprecated-contexts

View File

@@ -1235,13 +1235,7 @@ export const computeConsumptionSingle = (data: {
(to_grid || 0) -
(to_battery || 0);
let used_solar = 0;
let grid_to_battery = 0;
let battery_to_grid = 0;
let solar_to_battery = 0;
let solar_to_grid = 0;
let used_battery = 0;
let used_grid = 0;
let used_total_remaining = Math.max(used_total, 0);
// Consumption Priority
@@ -1266,40 +1260,34 @@ export const computeConsumptionSingle = (data: {
// Fill the remainder of the battery input from solar
// Solar -> Battery_In
solar_to_battery = Math.min(solar, to_battery);
const solar_to_battery = Math.min(solar, to_battery);
to_battery -= solar_to_battery;
solar -= solar_to_battery;
// Solar -> Grid_Out
solar_to_grid = Math.min(solar, to_grid);
const solar_to_grid = Math.min(solar, to_grid);
to_grid -= solar_to_grid;
solar -= solar_to_grid;
// Battery_Out -> Grid_Out
battery_to_grid = Math.min(from_battery, to_grid);
const battery_to_grid = Math.min(from_battery, to_grid);
from_battery -= battery_to_grid;
to_grid -= battery_to_grid;
// Grid_In -> Battery_In (second pass)
const grid_to_battery_2 = Math.min(from_grid, to_battery);
grid_to_battery += grid_to_battery_2;
from_grid -= grid_to_battery_2;
to_battery -= grid_to_battery_2;
// Solar -> Consumption
used_solar = Math.min(used_total_remaining, solar);
const used_solar = Math.min(used_total_remaining, solar);
used_total_remaining -= used_solar;
solar -= used_solar;
// Battery_Out -> Consumption
used_battery = Math.min(from_battery, used_total_remaining);
from_battery -= used_battery;
const used_battery = Math.min(from_battery, used_total_remaining);
used_total_remaining -= used_battery;
// Grid_In -> Consumption
used_grid = Math.min(used_total_remaining, from_grid);
from_grid -= used_grid;
used_total_remaining -= from_grid;
const used_grid = Math.min(used_total_remaining, from_grid);
return {
used_solar,

View File

@@ -187,6 +187,30 @@ export const NON_NUMERIC_ATTRIBUTES = [
"xy_color",
];
export const STATE_CONDITION_HIDDEN_ATTRIBUTES = [
"access_token",
"available_modes",
"color_modes",
"editable",
"effect_list",
"entity_picture",
"event_types",
"fan_modes",
"fan_speed_list",
"forecast",
"friendly_name",
"hvac_modes",
"icon",
"operation_list",
"options",
"preset_modes",
"sound_mode_list",
"source_list",
"state_class",
"swing_modes",
"token",
];
export const computeShownAttributes = (stateObj: HassEntity) => {
const domain = computeStateDomain(stateObj);
const filtersArray = STATE_ATTRIBUTES.concat(

View File

@@ -53,7 +53,7 @@ export const getEntities = (
value?: string,
idPrefix = ""
): EntityComboBoxItem[] => {
let items: EntityComboBoxItem[] = [];
let items: EntityComboBoxItem[];
let entityIds = Object.keys(hass.states);

View File

@@ -1,4 +1,5 @@
import type { Connection } from "home-assistant-js-websocket";
import type { ShortcutItem } from "./home_shortcuts";
export interface CoreFrontendUserData {
showAdvanced?: boolean;
@@ -18,20 +19,12 @@ export interface CoreFrontendSystemData {
onboarded_date?: string;
}
export interface CustomShortcutItem {
path: string;
label?: string;
icon?: string;
color?: string;
}
export interface HomeFrontendSystemData {
favorite_entities?: string[];
welcome_banner_dismissed?: boolean;
hidden_summaries?: string[];
hide_welcome_message?: boolean;
hide_suggested_entities?: boolean;
custom_shortcuts?: CustomShortcutItem[];
shortcuts?: ShortcutItem[];
}
declare global {

View File

@@ -0,0 +1,55 @@
export interface CustomShortcutItem {
type: "custom";
path: string;
label?: string;
icon?: string;
color?: string;
}
export interface SummaryShortcutItem {
type: "summary";
key: string;
hidden?: boolean;
}
export type ShortcutItem = CustomShortcutItem | SummaryShortcutItem;
export const DEFAULT_SUMMARY_KEYS = [
"light",
"climate",
"security",
"media_players",
"maintenance",
"weather",
"energy",
] as const;
export type DefaultSummaryKey = (typeof DEFAULT_SUMMARY_KEYS)[number];
const DEFAULT_SUMMARY_KEYS_SET: ReadonlySet<string> = new Set(
DEFAULT_SUMMARY_KEYS
);
// Built-in summary keys missing from the saved list are appended in
// DEFAULT_SUMMARY_KEYS order; summary keys no longer in the defaults are
// dropped.
export function resolveShortcutItems(
saved: ShortcutItem[] | undefined
): ShortcutItem[] {
const result: ShortcutItem[] = [];
const seenSummaryKeys = new Set<string>();
for (const item of saved || []) {
if (item.type === "summary") {
if (!DEFAULT_SUMMARY_KEYS_SET.has(item.key)) continue;
if (seenSummaryKeys.has(item.key)) continue;
seenSummaryKeys.add(item.key);
}
result.push(item);
}
for (const key of DEFAULT_SUMMARY_KEYS) {
if (!seenSummaryKeys.has(key)) {
result.push({ type: "summary", key });
}
}
return result;
}

View File

@@ -52,6 +52,7 @@ import {
mdiThermostat,
mdiTimerOutline,
mdiToggleSwitch,
mdiVideoInputAntenna,
mdiWater,
mdiWaterPercent,
mdiWeatherPartlyCloudy,
@@ -128,6 +129,7 @@ export const FALLBACK_DOMAIN_ICONS = {
plant: mdiFlower,
power: mdiFlash,
proximity: mdiAppleSafari,
radio_frequency: mdiVideoInputAntenna,
remote: mdiRemote,
scene: mdiPalette,
schedule: mdiCalendarClock,

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