Compare commits

..

69 Commits

Author SHA1 Message Date
Petar Petrov
337f0e9f34 Position chart tooltip beside cursor instead of over data point 2026-05-07 13:13:53 +03:00
Aidan Timson
0e1aa400d7 Skeleton for graphs (loading animation) (#51882) 2026-05-07 10:20:35 +01:00
Petar Petrov
00e57454ed Add volume up/down to media player playback tile feature (#51898) 2026-05-07 09:52:15 +01:00
Paul Bottein
0e6b342b3f Fix race condition loading home dashboard favorites (#51901) 2026-05-07 09:47:07 +01:00
ildar170975
7ad8c27aa3 Statistics graph card: allow color customization (#51824)
* add a possibility to customize color

* add a possibility to customize color

* add GraphEntityConfig

* add basic color support

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-05-07 11:19:50 +03:00
ildar170975
f01c202bbd History graph card: allow color customization for "line" graphs (#51802)
* add "color" option

* add GraphEntityConfig type

* add "color" option

* add "color" option

* add "color" option

* typescript-eslint/no-shadow

* linter

* add graphEntitiesConfigStruct

* import graphEntitiesConfigStruct

* typo in import

* leftout

* Create order-properties-graph.ts

* use common orderPropertiesGraphCard()

* Apply suggestions from code review

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

* Add missing Struct type import

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-05-07 07:17:06 +00:00
Timothy
ac6439bb5b Give less importance to the custom tag and tag id in the UI (#51884)
* Give less importance to the custom tag and tag id in the UI

* Make an expandable version prefill with a tagID

* Improve Edit tag dialog to be more usable

* Apply manually prettier

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-05-07 06:17:54 +00:00
Wendelin
33d29e3abd New list components (#51705)
* add new ha-list options

* Refactor ha-list components to use ha-list-selectable and ha-list-item-option

* fix types in gallery

* fix filter-floor-areas

* Review

* Fix list aria-label
2026-05-07 08:47:10 +03:00
karwosts
ca4ff25073 Fix entity filter card (#51895) 2026-05-07 08:44:42 +03:00
George Caliment
a4b4e285d8 Fixed detail tooltip overflow on charts (card or card detail) (#51891) 2026-05-07 08:43:34 +03:00
dependabot[bot]
850b597e47 Bump ip-address from 10.1.0 to 10.2.0 (#51892)
Bumps [ip-address](https://github.com/beaugunderson/ip-address) from 10.1.0 to 10.2.0.
- [Commits](https://github.com/beaugunderson/ip-address/commits)

---
updated-dependencies:
- dependency-name: ip-address
  dependency-version: 10.2.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-06 18:00:40 +00:00
Wendelin
b2e07c3ba5 Add ha-radio-group and ha-radio-option (#51864)
* add ha-radio-group and ha-radio-option

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

* Migrate ha-radio

* add docs, remove ha-radio

* update webawesome

---------

Co-authored-by: Copilot <copilot@github.com>
2026-05-06 19:51:02 +02:00
Aidan Timson
76c871b249 Add scenes and scripts to labels nav actions (#51888)
* Add scenes and scripts to labels nav actions

* Tighten type and flatten key
2026-05-06 19:40:41 +02:00
Wendelin
c15d514918 Improve automation event chips action, condition (#51886) 2026-05-06 18:11:12 +02:00
Wendelin
8a52fa5f7a Fix content padding picker (#51889) 2026-05-06 18:09:01 +02:00
Paul Bottein
22c89ceff9 Move logs page search bar out of the toolbar (#51887) 2026-05-06 14:05:55 +00:00
Clément Notin
764f99beb3 Fix quick bar search not focused on first open (#51822)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-05-06 12:19:28 +00:00
Aidan Timson
64b242e89c Add error handling for AbortError in view transitions (#51883) 2026-05-06 14:07:26 +02:00
Aidan Timson
103861bf71 Fix Safari 14 legacy bundle require errors (#51868)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 10:55:53 +02:00
Marcin Bauer
b0a885f504 Fix automation row right padding and soften chip highlight animation (#51865)
Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 09:26:21 +01:00
Paul Bottein
d620919643 Fix switch clipping in view visibility editor (#51876) 2026-05-06 09:19:10 +01:00
Paul Bottein
f190a4f75c Fix name for battery entities without device (#51879) 2026-05-06 08:15:54 +00:00
Wendelin
9c0f4ef8eb Remove duplicate definition in semantic colors (#51875)
* Remove duplicate definition in semantic colors

* rearrange surface tokens
2026-05-06 10:12:15 +02:00
GeorgeC
f25692a6f3 Handle nested dialogs inside dialog-form (#51715) 2026-05-06 09:52:56 +02:00
Wendelin
8b0d193742 Reduce progress bar default height (#51878)
reduce progress bar default height to 12px
2026-05-06 09:39:23 +02:00
Paul Bottein
da8dedbdea Fix media controls in media player more info dialog (#51877) 2026-05-06 09:24:10 +02:00
Wendelin
405ea0d09d Fix integration search shrink on mobile (#51867) 2026-05-05 13:12:52 +01:00
karwosts
afce0703e3 Change display for uptime sensors (#51830) 2026-05-05 09:52:03 +02:00
Paul Bottein
be0abafdff Use ha-switch instead of ha-control-switch in entity toggle (#51852) 2026-05-05 09:47:38 +02:00
renovate[bot]
4aa9b188a0 Update dependency globals to v17.6.0 (#51859)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-04 17:56:08 +00:00
renovate[bot]
1312cdceda Update dependency eslint to v10.3.0 (#51858)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-04 17:55:23 +00:00
renovate[bot]
7dddcc0feb Update dependency marked to v18.0.3 (#51855)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-04 17:33:32 +02:00
Paul Bottein
38a18e327c Remove daily and hourly forecast card features (#51854) 2026-05-04 14:23:57 +00:00
Paul Bottein
a288ad4ab6 Resolve service name and icon for shortcut card and badge (#51850) 2026-05-04 14:21:42 +02:00
Paul Bottein
89a85d6f04 Group areas floor vacuum clean (#51847) 2026-05-04 13:23:06 +02:00
Wendelin
6f1d644676 Fix automation row target width (#51848) 2026-05-04 12:51:08 +02:00
Isaac (Kwangjin Ko)
3edf8beb5a ha-humidifier-state: fix incorrect translation key for 'Currently' (#51843) 2026-05-04 10:49:51 +00:00
Aidan Timson
7b95baf36b Update actions devtool layout (#51786)
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-05-04 11:45:07 +02:00
Wendelin
b9c9008135 Use ha-switch in ha-automation-picker (#51846)
use ha-switch in ha-automation-picker
2026-05-04 09:33:29 +00:00
Paul Bottein
a8fb2e251e Fix entity toggle switch size (#51845) 2026-05-04 09:23:29 +00:00
Paul Bottein
5c93e7adbc Add min touch size for control switch (#51826) 2026-05-04 11:17:08 +02:00
renovate[bot]
4745cb4103 Update dependency @babel/preset-env to v7.29.3 (#51841)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-04 07:03:01 +02:00
renovate[bot]
0a27727b9f Update dependency jsdom to v29.1.1 (#51838)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-03 13:45:58 +03:00
ildar170975
2644706d5a Dev tools -> Template: make a "description" collapsible (#51777)
* make a "description" expandable

* add "about" label for devtools->templates

* Update src/translations/en.json

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

* expandedWillChange -> expandedChanged

* Add type annotation to _expandedChanged method

* Add import for HASSDomEvent type

* prettier

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-05-03 08:01:43 +03:00
Simon Lamon
dd25b448cf Missing toggle in switch group (#51825)
Missing toggle
2026-05-03 07:49:41 +03:00
renovate[bot]
884c110bcc Update dependency @formatjs/intl-durationformat to v0.10.7 (#51834)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-03 07:47:42 +03:00
Brooke Hatton
c61ed9c56a Remove battery chargers from maintenance dashboard (#51835) 2026-05-02 20:10:34 -04:00
renovate[bot]
b454a45ca3 Update formatjs monorepo (#51831)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-02 19:15:47 +02:00
renovate[bot]
3bc404bc01 Update dependency @html-eslint/eslint-plugin to v0.60.0 (#51832)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-02 19:15:24 +02:00
renovate[bot]
f22fc0b68a Update dependency @rspack/core to v2.0.1 (#51827)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-01 17:26:18 +02: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
123 changed files with 5432 additions and 2254 deletions

View File

@@ -0,0 +1,188 @@
---
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

@@ -0,0 +1,415 @@
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

@@ -43,12 +43,22 @@ const fullOptions: SelectBoxOption[] = [
},
];
const manyOptions: SelectBoxOption[] = [
{ value: "opt1", label: "Option 1" },
{ value: "opt2", label: "Option 2" },
{ value: "opt3", label: "Option 3" },
{ value: "opt4", label: "Option 4" },
{ value: "opt5", label: "Option 5" },
{ value: "opt6", label: "Option 6" },
];
const selects: {
id: string;
label: string;
class?: string;
options: SelectBoxOption[];
disabled?: boolean;
maxColumns?: number;
}[] = [
{
id: "basic",
@@ -60,6 +70,12 @@ const selects: {
label: "With description and image",
options: fullOptions,
},
{
id: "two-columns",
label: "2 columns (maxColumns=2)",
options: manyOptions,
maxColumns: 2,
},
];
@customElement("demo-components-ha-select-box")
@@ -67,13 +83,14 @@ export class DemoHaSelectBox extends LitElement {
@state() private value?: string = "off";
handleValueChanged(e: CustomEvent) {
console.log(e.detail.value);
this.value = e.detail.value as string;
}
protected render(): TemplateResult {
return html`
${repeat(selects, (select) => {
const { id, label, options } = select;
const { id, label, options, maxColumns } = select;
return html`
<ha-card>
<div class="card-content">
@@ -81,6 +98,7 @@ export class DemoHaSelectBox extends LitElement {
<ha-select-box
.value=${this.value}
.options=${options}
.maxColumns=${maxColumns}
@value-changed=${this.handleValueChanged}
>
</ha-select-box>

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.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",
"@formatjs/intl-datetimeformat": "7.4.1",
"@formatjs/intl-displaynames": "7.3.4",
"@formatjs/intl-durationformat": "0.10.7",
"@formatjs/intl-getcanonicallocales": "3.2.5",
"@formatjs/intl-listformat": "8.3.4",
"@formatjs/intl-locale": "5.3.4",
"@formatjs/intl-numberformat": "9.3.4",
"@formatjs/intl-pluralrules": "6.3.4",
"@formatjs/intl-relativetimeformat": "12.3.4",
"@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.3",
"@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.1.0",
"@lit-labs/observers": "2.1.0",
@@ -65,7 +66,6 @@
"@material/mwc-drawer": "0.27.0",
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"@material/mwc-radio": "0.27.0",
"@material/mwc-top-app-bar": "0.27.0",
"@material/mwc-top-app-bar-fixed": "0.27.0",
"@material/top-app-bar": "=14.0.0-canary.53b3cad2f.0",
@@ -99,7 +99,7 @@
"hls.js": "1.6.16",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "11.2.2",
"intl-messageformat": "11.2.3",
"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",
@@ -107,7 +107,7 @@
"lit": "3.3.2",
"lit-html": "3.3.2",
"luxon": "3.7.2",
"marked": "18.0.2",
"marked": "18.0.3",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -133,16 +133,16 @@
"@babel/core": "7.29.0",
"@babel/helper-define-polyfill-provider": "0.6.8",
"@babel/plugin-transform-runtime": "7.29.0",
"@babel/preset-env": "7.29.2",
"@babel/preset-env": "7.29.3",
"@bundle-stats/plugin-webpack-filter": "4.22.1",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.59.0",
"@html-eslint/eslint-plugin": "0.60.0",
"@lokalise/node-api": "15.7.1",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.9",
"@rspack/core": "2.0.0",
"@rspack/core": "2.0.1",
"@rspack/dev-server": "2.0.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -166,7 +166,7 @@
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.2.1",
"eslint": "10.3.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.16.2",
@@ -177,14 +177,14 @@
"fancy-log": "2.0.0",
"fs-extra": "11.3.4",
"glob": "13.0.6",
"globals": "17.5.0",
"globals": "17.6.0",
"gulp": "5.0.1",
"gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "29.0.2",
"jsdom": "29.1.1",
"jszip": "3.10.1",
"lint-staged": "16.4.0",
"lit-analyzer": "2.0.3",
@@ -200,7 +200,7 @@
"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",
@@ -213,7 +213,7 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.20",
"globals": "17.5.0",
"globals": "17.6.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"glob@^10.2.2": "^10.5.0"

View File

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

17
src/common/util/uuid.ts Normal file
View File

@@ -0,0 +1,17 @@
// Generates an RFC 4122 v4 UUID. Falls back to crypto.getRandomValues when
// crypto.randomUUID is unavailable (e.g. non-secure HTTP contexts on a LAN).
export const generateUuidV4 = (): string => {
if (typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
/* eslint-disable no-bitwise */
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
/* eslint-enable no-bitwise */
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(
""
);
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
};

View File

@@ -10,6 +10,17 @@ export const setViewTransitionDisabled = (disabled: boolean): void => {
isViewTransitionDisabled = disabled;
};
const isAbortError = (err: unknown): boolean =>
err instanceof DOMException
? err.name === "AbortError"
: err instanceof Error && err.name === "AbortError";
const ignoreAbortError = (err: unknown): void => {
if (!isAbortError(err)) {
throw err;
}
};
/**
* Executes a synchronous callback within a View Transition if supported, otherwise runs it directly.
*
@@ -40,7 +51,8 @@ export const withViewTransition = (
callbackInvoked = true;
callback(true);
});
return transition.finished;
transition.ready.catch(ignoreAbortError);
return transition.finished.catch(ignoreAbortError);
} catch (err) {
// eslint-disable-next-line no-console
console.warn(

View File

@@ -11,7 +11,8 @@ 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";
public variant: "info" | "warning" | "success" | "danger" | "neutral" =
"info";
@property({ type: Boolean })
public interactive = false;
@@ -91,6 +92,12 @@ export class HaAutomationRowEventChip extends LitElement {
--text-color: var(--ha-color-on-warning-normal);
}
:host([variant="neutral"]) {
--background-color: var(--ha-color-fill-neutral-normal-resting);
--background-color-hover: var(--ha-color-fill-neutral-normal-hover);
--text-color: var(--ha-color-on-neutral-normal);
}
:host([variant="success"]) {
--background-color: var(--ha-color-fill-success-normal-resting);
--background-color-hover: var(--ha-color-fill-success-normal-hover);

View File

@@ -0,0 +1,40 @@
import type { TooltipPositionCallback } from "echarts/types/dist/shared";
export const TOOLTIP_GAP_PX = 12;
export const TOOLTIP_TOP_OFFSET_PX = 10;
/**
* Pins the tooltip near the top of the chart and offsets it horizontally
* from the cursor so it never covers the data point being inspected.
* For axis-trigger time-series tooltips where the cursor's Y is uncorrelated
* with the displayed content.
*/
export const sideTooltipPosition: TooltipPositionCallback = (
point,
_params,
dom,
_rect,
size
) => {
const [cursorX] = point;
const [viewW, viewH] = size.viewSize;
const [tipW, tipH] = size.contentSize;
const rtl =
dom instanceof HTMLElement && getComputedStyle(dom).direction === "rtl";
const rightOfCursor = cursorX + TOOLTIP_GAP_PX;
const leftOfCursor = cursorX - TOOLTIP_GAP_PX - tipW;
let x = rtl ? leftOfCursor : rightOfCursor;
const overflowsRight = x + tipW > viewW;
const overflowsLeft = x < 0;
if (overflowsRight || overflowsLeft) {
x = rtl ? rightOfCursor : leftOfCursor;
}
x = Math.max(0, Math.min(x, viewW - tipW));
const y = Math.max(0, Math.min(TOOLTIP_TOP_OFFSET_PX, viewH - tipH));
return [x, y];
};

View File

@@ -1499,6 +1499,7 @@ export class HaChartBase extends LitElement {
margin-inline-start: var(--ha-space-1);
flex-shrink: 0;
white-space: nowrap;
line-height: 1;
}
.chart-legend .legend-toggle {
background: none;

View File

@@ -11,6 +11,7 @@ import { computeRTL } from "../../common/util/compute_rtl";
import type { LineChartEntity, LineChartState } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import type { ECOption } from "../../resources/echarts/echarts";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import {
@@ -60,6 +61,11 @@ export class StateHistoryChartLine extends LitElement {
@property({ attribute: false }) public names?: Record<string, string>;
@property({ attribute: false }) public colors?: Record<
string,
string | undefined
>;
@property() public unit?: string;
@property() public identifier?: string;
@@ -405,8 +411,7 @@ export class StateHistoryChartLine extends LitElement {
tooltip: {
trigger: "axis",
renderMode: "html",
position: "bottom",
align: "center",
position: sideTooltipPosition,
confine: true,
formatter: this._renderTooltip,
},
@@ -435,9 +440,11 @@ export class StateHistoryChartLine extends LitElement {
this._chartTime = new Date();
const endTime = this.endTime;
const names = this.names || {};
const colors = this.colors || {};
entityStates.forEach((states, dataIdx) => {
const domain = states.domain;
const name = names[states.entity_id] || states.name;
const color = colors[states.entity_id];
// array containing [value1, value2, etc]
let prevValues: any[] | null = null;
@@ -468,11 +475,11 @@ export class StateHistoryChartLine extends LitElement {
const addDataSet = (
id: string,
nameY: string,
color?: string,
clr?: string,
fill = false
) => {
if (!color) {
color = getGraphColorByIndex(colorIndex, computedStyles);
if (!clr) {
clr = getGraphColorByIndex(colorIndex, computedStyles);
colorIndex++;
}
data.push({
@@ -481,7 +488,7 @@ export class StateHistoryChartLine extends LitElement {
type: "line",
cursor: "default",
name: nameY,
color,
color: clr,
symbol: "circle",
symbolSize: 1,
step: "end",
@@ -492,7 +499,7 @@ export class StateHistoryChartLine extends LitElement {
},
areaStyle: fill
? {
color: color + "7F",
color: clr + "7F",
}
: undefined,
tooltip: {
@@ -740,7 +747,7 @@ export class StateHistoryChartLine extends LitElement {
pushData(new Date(entityState.last_changed), series);
});
} else {
addDataSet(states.entity_id, name);
addDataSet(states.entity_id, name, color);
let lastValue: number;
let lastDate: Date;

View File

@@ -14,6 +14,7 @@ import { computeRTL } from "../../common/util/compute_rtl";
import type { TimelineEntity } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import { computeTimelineColor } from "./timeline-color";
import type { ECOption } from "../../resources/echarts/echarts";
import echarts from "../../resources/echarts/echarts";
@@ -256,8 +257,7 @@ export class StateHistoryChartTimeline extends LitElement {
},
tooltip: {
renderMode: "html",
position: "bottom",
align: "center",
position: sideTooltipPosition,
confine: true,
formatter: this._renderTooltip,
},

View File

@@ -52,6 +52,11 @@ export class StateHistoryCharts extends LitElement {
@property({ attribute: false }) public names?: Record<string, string>;
@property({ attribute: false }) public colors?: Record<
string,
string | undefined
>;
@property({ type: Boolean, reflect: true }) public virtualize = false;
@property({ attribute: false }) public endTime?: Date;
@@ -181,6 +186,7 @@ export class StateHistoryCharts extends LitElement {
.endTime=${this._computedEndTime}
.paddingYAxis=${this._maxYWidth}
.names=${this.names}
.colors=${this.colors}
.chartIndex=${index}
.clickForMoreInfo=${this.clickForMoreInfo}
.logarithmicScale=${this.logarithmicScale}
@@ -399,12 +405,12 @@ export class StateHistoryCharts extends LitElement {
.entry-container {
width: 100%;
overflow: visible;
}
.entry-container.line {
flex: 1;
padding-top: 8px;
overflow: hidden;
}
.entry-container:hover {

View File

@@ -37,6 +37,7 @@ import type { HomeAssistant } from "../../types";
import { getPeriodicAxisLabelConfig } from "./axis-label";
import type { CustomLegendOption } from "./ha-chart-base";
import "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import { fillDataGapsAndRoundCaps } from "./round-caps";
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
@@ -68,6 +69,11 @@ export class StatisticsChart extends LitElement {
@property({ attribute: false }) public names?: Record<string, string>;
@property({ attribute: false }) public colors?: Record<
string,
string | undefined
>;
@property() public unit?: string;
@property({ attribute: false }) public startTime?: Date;
@@ -393,8 +399,7 @@ export class StatisticsChart extends LitElement {
tooltip: {
trigger: "axis",
renderMode: "html",
position: "bottom",
align: "center",
position: sideTooltipPosition,
confine: true,
formatter: this._renderTooltip,
},
@@ -485,6 +490,7 @@ export class StatisticsChart extends LitElement {
}
const names = this.names || {};
const colors = this.colors || {};
statisticsData.forEach(([statistic_id, stats]) => {
const meta = statisticsMetaData?.[statistic_id];
let name = names[statistic_id];
@@ -529,11 +535,14 @@ export class StatisticsChart extends LitElement {
prevEndTime = end;
};
const color = getGraphColorByIndex(
colorIndex,
this._computedStyle || getComputedStyle(this)
);
colorIndex++;
let color = colors[statistic_id];
if (color === undefined) {
color = getGraphColorByIndex(
colorIndex,
this._computedStyle || getComputedStyle(this)
);
colorIndex++;
}
const statTypes: this["statTypes"] = [];

View File

@@ -95,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;
@@ -163,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);
@@ -220,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(
@@ -312,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 }),
@@ -326,6 +384,9 @@ 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",

View File

@@ -13,14 +13,17 @@ 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 {
@@ -75,27 +78,33 @@ export class HaFilterFloorAreas extends LitElement {
</div>
${this._shouldRender
? html`
<ha-list class="ha-scrollbar">
<ha-list-selectable
class="ha-scrollbar"
multi
@ha-list-selected=${this._handleListChanged}
aria-label=${this.hass.localize(
"ui.panel.config.areas.caption"
)}
>
${repeat(
areas?.floors || [],
(floor) => floor.floor_id,
(floor) => html`
<ha-check-list-item
<ha-list-item-option
appearance="checkbox"
selection-position="end"
.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="graphic"
slot="start"
.floor=${floor}
></ha-floor-icon>
${floor.name}
</ha-check-list-item>
<span slot="headline">${floor.name} </span>
</ha-list-item-option>
${repeat(
floor.areas,
(area, index) =>
@@ -110,7 +119,7 @@ export class HaFilterFloorAreas extends LitElement {
(area) => area.area_id,
(area) => this._renderArea(area)
)}
</ha-list>
</ha-list-selectable>
`
: nothing}
</ha-expansion-panel>
@@ -119,79 +128,83 @@ export class HaFilterFloorAreas extends LitElement {
private _renderArea(area, last = false) {
const hasFloor = !!area.floor_id;
return html`
<ha-check-list-item
<ha-list-item-option
appearance="checkbox"
selection-position="end"
.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
.end=${last}
slot="graphic"
></ha-tree-indicator>
`
? html`<ha-tree-indicator
slot="start"
.end=${last}
></ha-tree-indicator>`
: nothing}
${area.icon
? html`<ha-icon slot="graphic" .icon=${area.icon}></ha-icon>`
? html`<ha-icon slot="start" .icon=${area.icon}></ha-icon>`
: html`<ha-svg-icon
slot="graphic"
slot="start"
.path=${mdiTextureBox}
></ha-svg-icon>`}
${area.name}
</ha-check-list-item>
<span slot="headline">${area.name}</span>
</ha-list-item-option>
`;
}
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) {
private _handleListChanged(ev: CustomEvent<HaListSelectedDetail>) {
if (!ev.detail.diff?.added.size && !ev.detail.diff?.removed.size) {
return;
}
if (this.value?.[type]?.includes(value)) {
this.value = {
...this.value,
[type]: this.value[type].filter((val) => val !== value),
};
} else {
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) {
this.value = {};
}
this.value = {
...this.value,
[type]: [...(this.value[type] || []), 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
),
};
}
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")!.style.height =
this.renderRoot.querySelector("ha-list-selectable")!.style.height =
`${this.clientHeight - 49}px`;
}, 300);
}
@@ -317,11 +330,7 @@ export class HaFilterFloorAreas extends LitElement {
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-check-list-item {
--mdc-list-item-graphic-margin: 16px;
}
.floor {
padding-left: 48px;
.floor::part(base) {
padding-inline-start: 48px;
padding-inline-end: 16px;
}

View File

@@ -37,10 +37,6 @@ export class HaFormfield extends FormfieldBase {
input.checked = !input.checked;
fireEvent(input, "change");
break;
case "HA-RADIO":
input.checked = true;
fireEvent(input, "change");
break;
default:
input.click();
break;

View File

@@ -469,6 +469,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
--ha-bottom-sheet-padding: 0;
--ha-bottom-sheet-surface-background: var(--card-background-color);
--ha-bottom-sheet-border-radius: var(--ha-border-radius-2xl);
--ha-bottom-sheet-content-padding: 0 var(--safe-area-inset-right)
var(--safe-area-inset-bottom) var(--safe-area-inset-left);
}
ha-picker-field.opened {

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

@@ -1,22 +0,0 @@
import { RadioBase } from "@material/mwc-radio/mwc-radio-base";
import { styles } from "@material/mwc-radio/mwc-radio.css";
import { css } from "lit";
import { customElement } from "lit/decorators";
@customElement("ha-radio")
export class HaRadio extends RadioBase {
static override styles = [
styles,
css`
:host {
--mdc-theme-secondary: var(--primary-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-radio": HaRadio;
}
}

View File

@@ -1,13 +1,14 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { computeRTL } from "../common/util/compute_rtl";
import type { HomeAssistant } from "../types";
import "./ha-radio";
import type { HaRadio } from "./ha-radio";
import "./radio/ha-radio-group";
import type { HaRadioGroup } from "./radio/ha-radio-group";
import "./radio/ha-radio-option";
interface SelectBoxOptionImage {
src: string;
@@ -44,9 +45,14 @@ export class HaSelectBox extends LitElement {
const columns = Math.min(maxColumns, this.options.length);
return html`
<div class="list" style=${styleMap({ "--columns": columns })}>
<ha-radio-group
class="list"
style=${styleMap({ "--columns": columns })}
.value=${this.value}
@change=${this._radioChanged}
>
${this.options.map((option) => this._renderOption(option))}
</div>
</ha-radio-group>
`;
}
@@ -74,20 +80,24 @@ export class HaSelectBox extends LitElement {
selected: selected,
})}"
?disabled=${disabled}
@click=${this._labelClick}
>
<div class="content">
<ha-radio
.checked=${option.value === this.value}
<ha-radio-option
aria-describedby=${ifDefined(
option.description ? `desc-${option.value}` : undefined
)}
aria-labelledby=${`label-${option.value}`}
.value=${option.value}
.disabled=${disabled}
@change=${this._radioChanged}
@click=${stopPropagation}
></ha-radio>
></ha-radio-option>
<div class="text">
<span class="label">${option.label}</span>
<span id=${`label-${option.value}`} class="label"
>${option.label}</span
>
${option.description
? html`<span class="description">${option.description}</span>`
? html`<span class="description" id="desc-${option.value}"
>${option.description}</span
>`
: nothing}
</div>
</div>
@@ -100,14 +110,9 @@ export class HaSelectBox extends LitElement {
`;
}
private _labelClick(ev) {
ev.stopPropagation();
ev.currentTarget.querySelector("ha-radio")?.click();
}
private _radioChanged(ev: CustomEvent) {
ev.stopPropagation();
const radio = ev.currentTarget as HaRadio;
const radio = ev.currentTarget as HaRadioGroup;
const value = radio.value;
if (this.disabled || value === undefined || value === (this.value ?? "")) {
return;
@@ -118,7 +123,7 @@ export class HaSelectBox extends LitElement {
}
static styles = css`
.list {
.list::part(form-control-input) {
display: grid;
grid-template-columns: repeat(var(--columns, 1), minmax(0, 1fr));
gap: var(--ha-space-3);
@@ -146,8 +151,9 @@ export class HaSelectBox extends LitElement {
min-width: 0;
width: 100%;
}
.option .content ha-radio {
margin: -12px;
.option .content ha-radio-option {
--ha-radio-option-control-margin: 0;
margin: 0;
flex: none;
}
.option .content .text {
@@ -156,6 +162,7 @@ export class HaSelectBox extends LitElement {
gap: var(--ha-space-1);
min-width: 0;
flex: 1;
justify-content: center;
}
.option .content .text .label {
color: var(--primary-text-color);

View File

@@ -78,22 +78,28 @@ export class HaObjectSelector extends LitElement {
};
private _renderItem(item: any, index: number) {
const labelField =
this.selector.object!.label_field ||
Object.keys(this.selector.object!.fields!)[0];
const fields = this.selector.object!.fields!;
const preferredLabel = this.selector.object!.label_field;
const hasValidLabelField = preferredLabel && preferredLabel in fields;
const labelSelector = this.selector.object!.fields![labelField].selector;
const label = labelSelector
? formatSelectorValue(this.hass, item[labelField], labelSelector)
: "";
const label = hasValidLabelField
? formatSelectorValue(
this.hass,
item[preferredLabel!],
fields[preferredLabel!]?.selector
)
: Object.entries(fields)
.map(([key, field]) =>
formatSelectorValue(this.hass, item[key], field.selector)
)
.filter(Boolean)
.join(" · ");
let description = "";
const descriptionField = this.selector.object!.description_field;
if (descriptionField) {
const descriptionSelector =
this.selector.object!.fields![descriptionField].selector;
if (descriptionField && descriptionField in fields) {
const descriptionSelector = fields[descriptionField]?.selector;
description = descriptionSelector
? formatSelectorValue(

View File

@@ -15,10 +15,11 @@ import "../ha-dropdown-item";
import "../ha-formfield";
import "../ha-generic-picker";
import "../ha-input-helper-text";
import "../ha-radio";
import "../ha-select";
import "../ha-select-box";
import "../ha-sortable";
import "../radio/ha-radio-group";
import "../radio/ha-radio-option";
@customElement("ha-selector-select")
export class HaSelectSelector extends LitElement {
@@ -108,24 +109,23 @@ export class HaSelectSelector extends LitElement {
) {
if (!this.selector.select?.multiple) {
return html`
<div>
${this.label}
<ha-radio-group
.label=${this.label}
.disabled=${this.disabled}
.value=${this.value}
@change=${this._radioChanged}
>
${options.map(
(item: SelectOption) => html`
<ha-formfield
.label=${item.label}
.disabled=${item.disabled || this.disabled}
<ha-radio-option
.value=${item.value}
.disabled=${!!item.disabled}
>
<ha-radio
.checked=${item.value === this.value}
.value=${item.value}
.disabled=${item.disabled || this.disabled}
@change=${this._radioChanged}
></ha-radio>
</ha-formfield>
${item.label}
</ha-radio-option>
`
)}
</div>
</ha-radio-group>
${this._renderHelper()}
`;
}

View File

@@ -40,11 +40,11 @@ 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 = {
@@ -353,12 +353,12 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
private _renderAllPanels(selectedPanel: string) {
const renderList = (content, cls: string, scrollable: boolean) =>
html`<ha-md-list
html`<ha-list-nav
class=${classMap({
"ha-scrollbar": scrollable,
[cls]: true,
})}
>${content}</ha-md-list
>${content}</ha-list-nav
>`;
if (!this._panelOrder || !this._hiddenPanels) {
@@ -430,9 +430,8 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
const iconPath = getPanelIconPath(panel);
return html`
<ha-md-list-item
<ha-list-item-button
.href=${`/${urlPath}`}
type="link"
id="sidebar-panel-${urlPath}"
class=${classMap({ selected: isSelected })}
>
@@ -440,7 +439,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-md-list-item>
</ha-list-item-button>
${!this.alwaysExpand && title
? this._renderToolTip(`sidebar-panel-${urlPath}`, title)
: nothing}
@@ -457,9 +456,8 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
}
const isSelected = selectedPanel === "config";
return html`
<ha-md-list-item
<ha-list-item-button
class="configuration ${classMap({ selected: isSelected })}"
type="button"
href="/config"
id="sidebar-config"
>
@@ -481,7 +479,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
>
`
: nothing}
</ha-md-list-item>
</ha-list-item-button>
${!this.alwaysExpand
? this._renderToolTip(
"sidebar-config",
@@ -497,10 +495,9 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
: 0;
return html`
<ha-md-list-item
<ha-list-item-button
class="notifications"
@click=${this._handleShowNotificationDrawer}
type="button"
id="sidebar-notifications"
>
<ha-svg-icon slot="start" .path=${mdiBell}></ha-svg-icon>
@@ -515,7 +512,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
${notificationCount > 0
? html`<span class="badge" slot="end">${notificationCount}</span>`
: nothing}
</ha-md-list-item>
</ha-list-item-button>
${!this.alwaysExpand
? this._renderToolTip(
"sidebar-notifications",
@@ -530,9 +527,8 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
const isSelected = selectedPanel === "profile";
return html`
<ha-md-list-item
<ha-list-item-button
href="/profile"
type="link"
id="sidebar-profile"
class=${classMap({
user: true,
@@ -548,7 +544,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
<span class="item-text" slot="headline"
>${this.hass.user ? this.hass.user.name : ""}</span
>
</ha-md-list-item>
</ha-list-item-button>
${!this.alwaysExpand && this.hass.user
? this._renderToolTip("sidebar-profile", this.hass.user.name)
: nothing}
@@ -560,16 +556,15 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
return nothing;
}
return html`
<ha-md-list-item
<ha-list-item-button
@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-md-list-item>
</ha-list-item-button>
${!this.alwaysExpand
? this._renderToolTip(
"sidebar-external-config",
@@ -718,10 +713,10 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
flex: 1;
}
ha-md-list {
ha-list-nav {
overflow-x: hidden;
background: none;
margin-left: var(--safe-area-inset-left, 0px);
margin-block: var(--ha-space-2);
}
.wrapper {
@@ -731,42 +726,38 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
min-height: 0;
flex: 1;
}
ha-md-list.before-spacer {
ha-list-nav.before-spacer {
padding-bottom: 0;
}
ha-md-list.after-spacer {
ha-list-nav.after-spacer {
padding-top: 0;
min-height: fit-content;
}
ha-md-list-item {
ha-list-item-button {
flex-shrink: 0;
box-sizing: border-box;
margin: var(--ha-space-1);
margin: 0 var(--ha-space-1) var(--ha-space-1);
border-radius: var(--ha-border-radius-sm);
--md-list-item-one-line-container-height: var(--ha-space-10);
--md-list-item-top-space: 0;
--md-list-item-bottom-space: 0;
--ha-row-item-min-height: var(--ha-space-10);
--ha-row-item-padding-block: 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;
}
:host([expanded]) ha-md-list-item {
ha-list-item-button::part(headline) {
color: var(--sidebar-text-color);
}
:host([expanded]) ha-list-item-button {
width: 248px;
}
:host([narrow][expanded]) ha-md-list-item {
:host([narrow][expanded]) ha-list-item-button {
width: calc(240px - var(--safe-area-inset-left, 0px));
}
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::part(headline) {
color: var(--sidebar-selected-icon-color);
}
ha-md-list-item.selected::before {
ha-list-item-button.selected::before {
border-radius: var(--ha-border-radius-sm);
position: absolute;
top: 0;
@@ -788,12 +779,12 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
color: var(--sidebar-icon-color);
}
ha-md-list-item.selected ha-svg-icon[slot="start"],
ha-md-list-item.selected ha-icon[slot="start"] {
ha-list-item-button.selected ha-svg-icon[slot="start"],
ha-list-item-button.selected ha-icon[slot="start"] {
color: var(--sidebar-selected-icon-color);
}
ha-md-list-item .item-text {
ha-list-item-button .item-text {
display: block;
max-width: 0;
opacity: 0;
@@ -806,7 +797,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-md-list-item .item-text {
:host([expanded]) ha-list-item-button .item-text {
max-width: 100%;
opacity: 1;
transition-delay: 0ms, 80ms;
@@ -848,13 +839,17 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
pointer-events: none;
}
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-user-badge {
width: var(--ha-space-10);
height: var(--ha-space-10);
}
ha-md-list-item.user.rtl {
--md-list-item-leading-space: var(--ha-space-3);
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-user-badge {
@@ -874,8 +869,8 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
@media (prefers-reduced-motion: reduce) {
.menu,
ha-md-list-item,
ha-md-list-item .item-text,
ha-list-item-button,
ha-list-item-button .item-text,
.title {
transition: 1ms;
}

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

@@ -0,0 +1,101 @@
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

@@ -0,0 +1,109 @@
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

@@ -0,0 +1,132 @@
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

@@ -0,0 +1,170 @@
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

@@ -0,0 +1,291 @@
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

@@ -0,0 +1,44 @@
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

@@ -0,0 +1,212 @@
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

@@ -0,0 +1,19 @@
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

@@ -0,0 +1,70 @@
import RadioGroup from "@home-assistant/webawesome/dist/components/radio-group/radio-group";
import { css, type CSSResultGroup } from "lit";
import { customElement } from "lit/decorators";
/**
* Home Assistant radio group component
*
* @element ha-radio-group
* @extends {RadioGroup}
*
* @summary
* A Home Assistant themed radio group built on top of the Web Awesome radio group.
* Groups `ha-radio-option` children so they behave as a single form control.
*
* @slot - The default slot where `ha-radio-option` elements are placed.
* @slot label - The radio group's label. Required for accessibility. Alternatively, use the `label` attribute.
* @slot hint - Text that describes how to use the radio group. Alternatively, use the `hint` attribute.
*
* @csspart form-control - The form control that wraps the label, input, and hint.
* @csspart form-control-label - The label's wrapper.
* @csspart form-control-input - The input's wrapper.
* @csspart radios - The wrapper around the radio items, styled as a flex container by default.
* @csspart hint - The hint's wrapper.
*
* @cssprop --ha-radio-group-required-marker - Marker shown after the label for required fields. Defaults to `--ha-input-required-marker`, then `"*"`.
* @cssprop --ha-radio-group-required-marker-offset - Offset of the required marker. Defaults to `0.1rem`.
*
* @attr {string} label - The radio group's label.
* @attr {string} hint - The radio group's hint text.
* @attr {string} name - The name of the radio group, submitted as a name/value pair with form data.
* @attr {("horizontal"|"vertical")} orientation - The orientation in which to show radio items.
* @attr {boolean} disabled - Disables the radio group and all child radios.
* @attr {boolean} required - Ensures a child radio is checked before allowing the containing form to submit.
*
* @fires change - Emitted when the radio group's selected value changes.
* @fires input - Emitted when the radio group receives user input.
* @fires wa-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
*/
@customElement("ha-radio-group")
export class HaRadioGroup extends RadioGroup {
constructor() {
super();
this.radioTag = "ha-radio-option";
}
static get styles(): CSSResultGroup {
return [
RadioGroup.styles,
css`
:host {
--wa-form-control-required-content: var(
--ha-radio-group-required-marker,
var(--ha-input-required-marker, "*")
);
--wa-form-control-required-content-offset: var(
--ha-radio-group-required-marker-offset,
0.1rem
);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-radio-group": HaRadioGroup;
}
}

View File

@@ -0,0 +1,122 @@
import Radio from "@home-assistant/webawesome/dist/components/radio/radio";
import { css, type CSSResultGroup } from "lit";
import { customElement } from "lit/decorators";
/**
* Home Assistant radio option component
*
* @element ha-radio-option
* @extends {Radio}
*
* @summary
* A Home Assistant themed radio built on top of the Web Awesome radio.
* Intended to be used as a child of `ha-radio-group`.
*
* @slot - The radio option's label.
*
* @csspart control - The circular container that wraps the radio's checked state.
* @csspart checked-icon - The checked icon.
* @csspart label - The container that wraps the radio option's label.
*
* @cssprop --ha-radio-option-active-color - Accent color used for the checked indicator and border. Defaults to `--ha-color-fill-primary-loud-resting`.
* @cssprop --ha-radio-option-height - Minimum height of the option in `button` appearance. Defaults to `40px`.
* @cssprop --ha-radio-option-toggle-size - Size of the radio toggle circle in `default` appearance. Defaults to `20px`.
* @cssprop --ha-radio-option-border-width - Border width of the radio control. Defaults to `--ha-border-width-md`.
* @cssprop --ha-radio-option-border-color - Border color of the radio control. Defaults to `--ha-color-border-neutral-normal`.
* @cssprop --ha-radio-option-border-color-hover - Border color of the radio control on hover. Defaults to `--ha-radio-option-border-color`, then `--ha-color-border-neutral-loud`.
* @cssprop --ha-radio-option-background-color - Background color of the radio control. Defaults to `--wa-form-control-background-color`.
* @cssprop --ha-radio-option-background-color-hover - Background color of the radio control on hover. Defaults to `--ha-color-fill-neutral-quiet-hover`.
* @cssprop --ha-radio-option-checked-background-color - Background color of the radio control when checked. Defaults to `--ha-color-fill-primary-normal-resting`.
* @cssprop --ha-radio-option-checked-icon-color - Color of the checked indicator dot. Defaults to `--ha-radio-option-active-color`.
* @cssprop --ha-radio-option-checked-icon-scale - Size of the checked indicator relative to the toggle. Defaults to `0.7`.
* @cssprop --ha-radio-option-control-margin - Margin around the radio toggle in `default` appearance. Defaults to `var(--ha-space-3) var(--ha-space-2) var(--ha-space-3) var(--ha-space-3)`.
*
* @attr {("default"|"button")} appearance - Sets the radio option's visual appearance.
* @attr {("small"|"medium"|"large")} size - Sets the radio option's size. Overridden by the parent `ha-radio-group`.
* @attr {boolean} checked - Draws the radio option in a checked state.
* @attr {boolean} disabled - Disables the radio option.
*/
@customElement("ha-radio-option")
export class HaRadioOption extends Radio {
static get styles(): CSSResultGroup {
return [
Radio.styles,
css`
:host {
--wa-form-control-activated-color: var(
--ha-radio-option-active-color,
var(--ha-color-fill-primary-loud-resting)
);
--wa-form-control-height: var(--ha-radio-option-height, 40px);
--wa-form-control-toggle-size: var(
--ha-radio-option-toggle-size,
20px
);
--wa-form-control-border-width: var(
--ha-radio-option-border-width,
var(--ha-border-width-md)
);
--wa-form-control-border-color: var(
--ha-radio-option-border-color,
var(--ha-color-border-neutral-normal)
);
--wa-form-control-background-color: var(
--ha-radio-option-background-color,
var(--wa-form-control-background-color)
);
--checked-icon-color: var(
--ha-radio-option-checked-icon-color,
var(--wa-form-control-activated-color)
);
--checked-icon-scale: var(--ha-radio-option-checked-icon-scale, 0.7);
}
:host([appearance="default"]) .control {
margin: var(
--ha-radio-option-control-margin,
var(--ha-space-3) var(--ha-space-2) var(--ha-space-3)
var(--ha-space-3)
);
}
:host(:not([aria-checked="true"], [aria-disabled="true"]):hover)
.control {
border-color: var(
--ha-radio-option-border-color-hover,
var(
--ha-radio-option-border-color,
var(--ha-color-border-neutral-loud)
)
);
background-color: var(
--ha-radio-option-background-color-hover,
var(--ha-color-fill-neutral-quiet-hover)
);
}
:host([aria-checked="true"]) .control {
background-color: var(
--ha-radio-option-checked-background-color,
var(--ha-color-fill-primary-normal-resting)
);
}
[part~="label"] {
display: inline-flex;
align-items: center;
cursor: pointer;
}
:host([disabled]) [part~="label"] {
cursor: not-allowed;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-radio-option": HaRadioOption;
}
}

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

@@ -1,11 +1,11 @@
import type { HomeAssistant } from "../types";
interface ValidConfig {
export interface ValidConfig {
valid: true;
error: null;
}
interface InvalidConfig {
export interface InvalidConfig {
valid: false;
error: string;
}

View File

@@ -98,5 +98,30 @@ export const formatSelectorValue = (
.join(", ");
}
return ensureArray(value).join(", ");
if ("object" in selector) {
const { fields } = selector.object ?? {};
const items = ensureArray(value);
return items
.map((item) => {
if (item == null || typeof item !== "object") {
return String(item);
}
if (fields) {
return Object.entries(fields)
.filter(([key]) => key in item && item[key] != null)
.map(([key, field]) =>
formatSelectorValue(hass, item[key], field.selector)
)
.join(" = ");
}
return JSON.stringify(item);
})
.join(", ");
}
return ensureArray(value)
.map((v) =>
v != null && typeof v === "object" ? JSON.stringify(v) : String(v)
)
.join(", ");
};

View File

@@ -1,5 +1,7 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import deepClone from "deep-clone-simple";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
import "../../components/ha-form/ha-form";
@@ -7,9 +9,15 @@ import "../../components/ha-dialog-footer";
import "../../components/ha-dialog";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import type { HassDialog } from "../make-dialog-manager";
import type { HassDialog, ShowDialogParams } from "../make-dialog-manager";
import type { FormDialogData, FormDialogParams } from "./show-form-dialog";
interface StackEntry {
params: FormDialogParams;
data: FormDialogData;
nestedField?: string;
}
@customElement("dialog-form")
export class DialogForm
extends LitElement
@@ -25,6 +33,8 @@ export class DialogForm
@state() private _closeState?: "canceled" | "submitted";
@state() private _stack: StackEntry[] = [];
public async showDialog(params: FormDialogParams): Promise<void> {
this._params = params;
this._data = params.data || {};
@@ -36,11 +46,41 @@ export class DialogForm
return true;
}
private _handleNestedShowDialog = (
ev: HASSDomEvent<ShowDialogParams<unknown>>
) => {
if (ev.detail.dialogTag !== "dialog-form") {
return;
}
ev.stopPropagation();
const origin = ev.composedPath()[0] as HTMLElement & { name?: string };
this._stack = [
...this._stack,
{ params: this._params!, data: this._data, nestedField: origin?.name },
];
const nested = ev.detail.dialogParams as FormDialogParams;
this._params = nested;
this._data = nested?.data || {};
};
private _popStack(): string | undefined {
if (!this._stack.length) {
return undefined;
}
const prev = this._stack[this._stack.length - 1];
this._stack = this._stack.slice(0, -1);
this._params = prev.params;
this._data = prev.data;
return prev.nestedField;
}
private _dialogClosed(): void {
if (!this._closeState) {
this._params?.cancel?.();
}
this._closeState = undefined;
this._stack = [];
this._params = undefined;
this._data = {};
this._open = false;
@@ -49,14 +89,44 @@ export class DialogForm
private _submit(): void {
this._closeState = "submitted";
this._params?.submit?.(this._data);
this.closeDialog();
const submit = this._params?.submit;
const data = this._data;
const nestedField = this._popStack();
submit?.(data);
if (!nestedField) {
this.closeDialog();
return;
}
const schemaField = this._params?.schema.find(
(f) => "selector" in f && f.name === nestedField
);
const isMultiple =
schemaField &&
"selector" in schemaField &&
"object" in schemaField.selector &&
schemaField.selector.object?.multiple === true;
const current = this._data[nestedField];
const newValue = isMultiple
? [...(Array.isArray(current) ? current : []), data]
: data;
this._data = deepClone({ ...this._data, [nestedField]: newValue });
}
private _cancel(): void {
this._closeState = "canceled";
this._params?.cancel?.();
this.closeDialog();
const cancel = this._params?.cancel;
const nestedField = this._popStack();
cancel?.();
if (!nestedField) {
this.closeDialog();
}
}
private _valueChanged(ev: CustomEvent): void {
@@ -84,6 +154,7 @@ export class DialogForm
.data=${this._data}
.schema=${this._params.schema}
@value-changed=${this._valueChanged}
@show-dialog=${this._handleNestedShowDialog}
>
</ha-form>
<ha-dialog-footer slot="footer">

View File

@@ -1,6 +1,6 @@
import { mdiDevices } from "@mdi/js";
import Fuse from "fuse.js";
import type { CSSResultGroup } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -149,6 +149,14 @@ export class QuickBar extends LitElement {
this._loading = false;
}
protected updated(changedProps: PropertyValues) {
if (changedProps.has("_loading") && !this._loading && this._opened) {
requestAnimationFrame(() => {
this._comboBox?.focus();
});
}
}
private _dialogOpened = async () => {
this._opened = true;
requestAnimationFrame(() => {

View File

@@ -0,0 +1,110 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { LitElement, PropertyValues } from "lit";
import { state } from "lit/decorators";
import type { StyleInfo } from "lit/directives/style-map";
import type { Constructor } from "../types";
/**
* Public interface added by {@link MatchMinHeightMixin}.
*
* Declared separately so consumers can reference the mixin's contributed
* members in their own type annotations, per the Lit mixin authoring guide.
*/
export declare class MatchMinHeightMixinInterface {
/** Most recently observed height of `matchMinHeightTarget`, in pixels. */
protected _matchedMinHeight?: number;
/**
* `StyleInfo` exposing the matched height as a `min-height` declaration.
* Pass to `styleMap` to keep a layout at least as tall as the target
* element. Empty until a height has been observed.
*/
protected get _matchMinHeightStyle(): StyleInfo;
/**
* Element whose height should be matched as a `min-height` floor. Override
* with a getter that returns a `@query` result. Return `null` to pause
* observation (e.g. while the element is not rendered).
*/
protected get matchMinHeightTarget(): HTMLElement | null;
}
/**
* Mixin that observes a target element's height and exposes it as a
* `min-height` style. Useful for keeping a sibling layout (e.g. a YAML
* editor) at least as tall as another (e.g. a UI form) to avoid content
* shift when toggling between them.
*
* Subclasses override `matchMinHeightTarget` (typically returning a
* `@query`-decorated element) to specify which element to observe.
*/
export const MatchMinHeightMixin = <T extends Constructor<LitElement>>(
superClass: T
) => {
class MatchMinHeightMixinClass extends superClass {
@state() protected _matchedMinHeight?: number;
private _matchTarget: HTMLElement | null = null;
private _matchResize = new ResizeController(this, {
target: null,
callback: (entries) => {
const height = entries[0]?.contentRect.height;
if (height) {
this._matchedMinHeight = height;
}
},
});
private static readonly DEFAULT_MATCH_TARGET: HTMLElement | null = null;
protected get matchMinHeightTarget(): HTMLElement | null {
return MatchMinHeightMixinClass.DEFAULT_MATCH_TARGET;
}
protected get _matchMinHeightStyle(): StyleInfo {
return this._matchedMinHeight !== undefined
? { "min-height": `${this._matchedMinHeight}px` }
: {};
}
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated?.(changedProperties);
this._attachMatchTarget();
}
protected updated(changedProperties: PropertyValues<this>) {
super.updated?.(changedProperties);
this._attachMatchTarget();
}
public disconnectedCallback() {
this._detachMatchTarget();
super.disconnectedCallback();
}
private _attachMatchTarget() {
const element = this.matchMinHeightTarget;
if (element === this._matchTarget) {
return;
}
this._detachMatchTarget();
if (!element) {
return;
}
this._matchTarget = element;
this._matchResize.observe(element);
}
private _detachMatchTarget() {
if (!this._matchTarget) {
return;
}
this._matchResize.unobserve?.(this._matchTarget);
this._matchTarget = null;
}
}
return MatchMinHeightMixinClass as unknown as Constructor<MatchMinHeightMixinInterface> &
T;
};

View File

@@ -14,7 +14,6 @@ import "../components/ha-alert";
import "../components/ha-button";
import "../components/ha-list";
import "../components/ha-list-item";
import "../components/ha-radio";
import "../components/ha-spinner";
import "../components/input/ha-input";
import type { HaInput } from "../components/input/ha-input";

View File

@@ -39,6 +39,7 @@ import { handleStructError } from "../../../../common/structs/handle-errors";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import "../../../../components/automation/ha-automation-row";
import type { HaAutomationRow } from "../../../../components/automation/ha-automation-row";
import "../../../../components/automation/ha-automation-row-event-chip";
import "../../../../components/ha-card";
import "../../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
@@ -65,6 +66,7 @@ import {
manifestsContext,
} from "../../../../data/context";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { DomainManifestLookup } from "../../../../data/integration";
import type {
Action,
DeviceAction,
@@ -73,7 +75,6 @@ import type {
ServiceAction,
} from "../../../../data/script";
import { getActionType, isAction } from "../../../../data/script";
import type { DomainManifestLookup } from "../../../../data/integration";
import { describeAction } from "../../../../data/script_i18n";
import { callExecuteScript } from "../../../../data/service";
import {
@@ -203,7 +204,7 @@ export default class HaAutomationActionRow extends LitElement {
@state() private _running = false;
@state() private _runResult?: {
variant: "success" | "danger" | "info";
variant: "success" | "danger" | "neutral";
title: string;
details?: string;
};
@@ -755,13 +756,8 @@ export default class HaAutomationActionRow extends LitElement {
this.scrollIntoView();
});
this._runResult = {
variant: "info",
title: this.hass.localize(
"ui.panel.config.automation.editor.actions.run"
),
};
this._running = true;
this._running = false;
this._runResult = undefined;
const validated = await validateConfig(this.hass, {
actions: this.action,
@@ -776,9 +772,22 @@ export default class HaAutomationActionRow extends LitElement {
details: validated.actions.error,
};
} else {
const runTimeout = setTimeout(() => {
this._runResult = {
variant: "neutral",
title: `${this.hass.localize(
"ui.panel.config.automation.editor.actions.running_action"
)}...`,
};
this._running = true;
}, 500);
try {
await callExecuteScript(this.hass, this.action);
clearTimeout(runTimeout);
} catch (err: any) {
clearTimeout(runTimeout);
this._runResult = {
variant: "danger",
title: this.hass.localize(
@@ -789,7 +798,7 @@ export default class HaAutomationActionRow extends LitElement {
}
}
if (this._runResult.variant === "info") {
if (!this._runResult || this._runResult.variant === "neutral") {
this._runResult = {
variant: "success",
title: this.hass.localize(
@@ -798,6 +807,8 @@ export default class HaAutomationActionRow extends LitElement {
};
}
this._running = true;
this._runResultTimeout = window.setTimeout(() => {
this._running = false;
}, 2500);

View File

@@ -1,3 +1,4 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import { mdiHelpCircleOutline } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
@@ -9,7 +10,7 @@ import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-radio";
import "../../../../components/ha-select-box";
import "../../../../components/input/ha-input";
import type { HaInput } from "../../../../components/input/ha-input";
@@ -83,56 +84,26 @@ class DialogAutomationMode extends LitElement implements HassDialog {
target="_blank"
rel="noopener noreferrer"
></ha-icon-button>
<ha-md-list
role="listbox"
tabindex="0"
aria-activedescendant="option-${this._newMode}"
aria-label=${this.hass.localize(
"ui.panel.config.automation.editor.modes.label"
)}
>
${MODES.map((mode) => {
const label = this.hass.localize(
<ha-select-box
.options=${MODES.map((mode) => ({
label: this.hass.localize(
`ui.panel.config.automation.editor.modes.${mode}`
);
return html`
<ha-md-list-item
class="option"
type="button"
@click=${this._modeChanged}
.value=${mode}
id="option-${mode}"
role="option"
aria-label=${label}
aria-selected=${this._newMode === mode}
>
<div slot="start">
<ha-radio
inert
.checked=${this._newMode === mode}
value=${mode}
@change=${this._modeChanged}
name="mode"
></ha-radio>
</div>
<div slot="headline">
${this.hass.localize(
`ui.panel.config.automation.editor.modes.${mode}`
)}
</div>
<div slot="supporting-text">
${this.hass.localize(
`ui.panel.config.automation.editor.modes.${mode}_description`
)}
</div>
</ha-md-list-item>
`;
})}
</ha-md-list>
),
description: this.hass.localize(
`ui.panel.config.automation.editor.modes.${mode}_description`
),
value: mode,
}))}
.value=${this._newMode}
@value-changed=${this._modeChanged}
.maxColumns=${1}
.hass=${this.hass}
></ha-select-box>
${isMaxMode(this._newMode)
? html`
<div class="options">
<div class="max-value">
<wa-divider></wa-divider>
<ha-input
.label=${this.hass.localize(
`ui.panel.config.automation.editor.max.${this._newMode}`
@@ -166,7 +137,7 @@ class DialogAutomationMode extends LitElement implements HassDialog {
}
private _modeChanged(ev) {
const mode = ev.currentTarget.value;
const mode = ev.detail.value;
this._newMode = mode;
if (!isMaxMode(mode)) {
this._newMax = undefined;
@@ -200,11 +171,8 @@ class DialogAutomationMode extends LitElement implements HassDialog {
haStyle,
haStyleDialog,
css`
ha-dialog {
--dialog-content-padding: 0;
}
.options {
padding: 0 24px 24px 24px;
.max-value {
margin-top: var(--ha-space-3);
}
ha-wa-dialog ha-icon-button[slot="headerActionItems"] {
color: var(--secondary-text-color);

View File

@@ -52,7 +52,11 @@ import { isCondition, testCondition } from "../../../../data/automation";
import { describeCondition } from "../../../../data/automation_i18n";
import type { ConditionDescriptions } from "../../../../data/condition";
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
import { validateConfig } from "../../../../data/config";
import {
validateConfig,
type InvalidConfig,
type ValidConfig,
} from "../../../../data/config";
import { fullEntitiesContext } from "../../../../data/context";
import type { DeviceCondition } from "../../../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
@@ -595,8 +599,6 @@ export default class HaAutomationConditionRow extends LitElement {
clearTimeout(this._testingTimeout);
}
this._testingResult = undefined;
this._testing = true;
const condition = this.condition;
requestAnimationFrame(() => {
// @ts-ignore is supported in all browsers except firefox
@@ -608,53 +610,59 @@ export default class HaAutomationConditionRow extends LitElement {
this.scrollIntoView();
});
let validateResult: Record<"conditions", InvalidConfig | ValidConfig>;
try {
const validateResult = await validateConfig(this.hass, {
validateResult = await validateConfig(this.hass, {
conditions: condition,
});
// Abort if condition changed.
if (this.condition !== condition) {
this._testing = false;
return;
}
if (!validateResult.conditions.valid) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.automation.editor.conditions.invalid_condition"
),
text: validateResult.conditions.error,
});
this._testing = false;
return;
}
let result: { result: boolean };
try {
result = await testCondition(this.hass, condition);
} catch (err: any) {
if (this.condition !== condition) {
this._testing = false;
return;
}
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.automation.editor.conditions.test_failed"
),
text: err.message,
});
this._testing = false;
return;
}
this._testingResult = result.result;
} finally {
this._testingTimeout = window.setTimeout(() => {
this._testing = false;
}, 2500);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.automation.editor.conditions.validation_failed"
),
});
// eslint-disable-next-line no-console
console.error("Error validating condition", err);
return;
}
// Abort if condition changed.
if (this.condition !== condition) {
return;
}
if (!validateResult.conditions.valid) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.automation.editor.conditions.invalid_condition"
),
text: validateResult.conditions.error,
});
return;
}
let result: { result: boolean };
try {
result = await testCondition(this.hass, condition);
} catch (err: any) {
if (this.condition !== condition) {
return;
}
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.automation.editor.conditions.test_failed"
),
text: err.message,
});
return;
}
this._testingResult = result.result;
this._testing = true;
this._testingTimeout = window.setTimeout(() => {
this._testing = false;
}, 2500);
};
private _renameCondition = async (): Promise<void> => {

View File

@@ -546,7 +546,6 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
.readOnly=${this.readOnly}
@value-changed=${this._yamlChanged}
@editor-save=${this._handleSaveAutomation}
.showErrors=${false}
disable-fullscreen
></ha-yaml-editor>
<ha-button

View File

@@ -136,6 +136,7 @@ type AutomationItem = AutomationEntity & {
formatted_state: string;
category: string | undefined;
label_entries: LabelRegistryEntry[];
labels: string[]; // search only
assistants: string[];
assistants_sortable_key: string | undefined;
};
@@ -258,6 +259,9 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
);
const category = entityRegEntry?.categories.automation;
const labels = labelReg && entityRegEntry?.labels;
const label_entries = (labels || [])
.map((lbl) => labelReg!.find((label) => label.label_id === lbl)!)
.filter(Boolean);
const assistants = getEntityVoiceAssistantsIds(
entityReg,
automation.entity_id
@@ -273,9 +277,8 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
category: category
? categoryReg?.find((cat) => cat.category_id === category)?.name
: undefined,
label_entries: (labels || [])
.map((lbl) => labelReg!.find((label) => label.label_id === lbl)!)
.filter(Boolean),
label_entries,
labels: label_entries.map((lbl) => lbl.name),
assistants,
assistants_sortable_key: getAssistantsSortableKey(assistants),
selectable: entityRegEntry !== undefined,

View File

@@ -1,13 +1,14 @@
import "../../../../../components/entity/ha-entity-picker";
import "../../../../../components/ha-formfield";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { computeStateDomain } from "../../../../../common/entity/compute_state_domain";
import { hasLocation } from "../../../../../common/entity/has_location";
import "../../../../../components/entity/ha-entity-picker";
import "../../../../../components/radio/ha-radio-group";
import type { HaRadioGroup } from "../../../../../components/radio/ha-radio-group";
import "../../../../../components/radio/ha-radio-option";
import type { ZoneTrigger } from "../../../../../data/automation";
import type { ValueChangedEvent, HomeAssistant } from "../../../../../types";
import type { HaRadio } from "../../../../../components/ha-radio";
import type { HomeAssistant, ValueChangedEvent } from "../../../../../types";
function zoneAndLocationFilter(stateObj) {
return hasLocation(stateObj) && computeStateDomain(stateObj) !== "zone";
@@ -56,39 +57,27 @@ export class HaZoneTrigger extends LitElement {
.includeDomains=${includeDomains}
></ha-entity-picker>
<label>
${this.hass.localize(
<ha-radio-group
orientation="horizontal"
.label=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.zone.event"
)}
<ha-formfield
.disabled=${this.disabled}
.label=${this.hass.localize(
.value=${event}
.disabled=${this.disabled}
@change=${this._radioGroupPicked}
name="event"
>
<ha-radio-option value="enter">
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.zone.enter"
)}
>
<ha-radio
name="event"
value="enter"
.disabled=${this.disabled}
.checked=${event === "enter"}
@change=${this._radioGroupPicked}
></ha-radio>
</ha-formfield>
<ha-formfield
.disabled=${this.disabled}
.label=${this.hass.localize(
</ha-radio-option>
<ha-radio-option value="leave">
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.zone.leave"
)}
>
<ha-radio
name="event"
value="leave"
.disabled=${this.disabled}
.checked=${event === "leave"}
@change=${this._radioGroupPicked}
></ha-radio>
</ha-formfield>
</label>
</ha-radio-option>
</ha-radio-group>
`;
}
@@ -106,21 +95,17 @@ export class HaZoneTrigger extends LitElement {
});
}
private _radioGroupPicked(ev: CustomEvent) {
private _radioGroupPicked(ev: Event) {
ev.stopPropagation();
fireEvent(this, "value-changed", {
value: {
...this.trigger,
event: (ev.target as HaRadio).value,
event: (ev.currentTarget as HaRadioGroup).value,
},
});
}
static styles = css`
label {
display: flex;
align-items: center;
}
ha-entity-picker {
display: block;
margin-bottom: 24px;

View File

@@ -0,0 +1,123 @@
import { mdiPuzzle } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-svg-icon";
import {
getSupervisorUpdateConfig,
type SupervisorUpdateConfig,
} from "../../../../../data/supervisor/update";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
@customElement("ha-backup-overview-app-update-backup")
class HaBackupOverviewAppUpdateBackup extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _supervisorUpdateConfig?: SupervisorUpdateConfig;
protected firstUpdated() {
this._fetchSupervisorUpdateConfig();
}
public connectedCallback() {
super.connectedCallback();
if (this.hasUpdated) {
this._fetchSupervisorUpdateConfig();
}
}
private async _fetchSupervisorUpdateConfig() {
try {
this._supervisorUpdateConfig = await getSupervisorUpdateConfig(this.hass);
} catch (err) {
// eslint-disable-next-line no-console
console.error(err);
}
}
private _appUpdateBackupDescription() {
if (!this._supervisorUpdateConfig) {
return this.hass.localize(
"ui.panel.config.backup.settings.app_update_backup.local_only"
);
}
if (!this._supervisorUpdateConfig.add_on_backup_before_update) {
return this.hass.localize(
"ui.panel.config.backup.schedule.update_preference.skip_backups"
);
}
const copies =
this._supervisorUpdateConfig.add_on_backup_retain_copies || 1;
return `${this.hass.localize(
"ui.panel.config.backup.schedule.update_preference.backup_before_update"
)} ${this.hass.localize(
"ui.panel.config.backup.overview.settings.schedule_copies_backups",
{ count: copies }
)}`;
}
protected render() {
return html`
<ha-card>
<div class="card-header">
${this.hass.localize(
"ui.panel.config.backup.overview.app_update_backup.title"
)}
</div>
<div class="card-content">
<ha-md-list>
<ha-md-list-item
type="link"
href="/config/backup/app-update-backups"
>
<ha-svg-icon slot="start" .path=${mdiPuzzle}></ha-svg-icon>
<div slot="headline">${this._appUpdateBackupDescription()}</div>
<div slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.overview.app_update_backup.description"
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-md-list>
</div>
</ha-card>
`;
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
.card-header {
padding-bottom: 8px;
}
.card-content {
padding-left: 0;
padding-right: 0;
padding-top: 0;
}
ha-md-list {
padding-top: 0;
padding-bottom: 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-backup-overview-app-update-backup": HaBackupOverviewAppUpdateBackup;
}
}

View File

@@ -0,0 +1,154 @@
import { css, html, LitElement, nothing } from "lit";
import type { PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { debounce } from "../../../common/util/debounce";
import "../../../components/ha-alert";
import "../../../components/ha-card";
import {
getSupervisorUpdateConfig,
updateSupervisorUpdateConfig,
type SupervisorUpdateConfig,
} from "../../../data/supervisor/update";
import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types";
import "./components/config/ha-backup-config-addon";
@customElement("ha-config-backup-app-update-backups")
class HaConfigBackupAppUpdateBackups extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@state() private _supervisorUpdateConfig?: SupervisorUpdateConfig;
@state() private _error?: string;
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (
!this.hasUpdated &&
this.hass &&
isComponentLoaded(this.hass.config, "hassio")
) {
this._getSupervisorUpdateConfig();
}
}
protected render() {
return html`
<hass-subpage
back-path="/config/backup/overview"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize(
"ui.panel.config.backup.app_update_backups.header"
)}
>
<div class="content">
<ha-card>
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.backup.settings.app_update_backup.description"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.backup.settings.app_update_backup.local_only"
)}
</p>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<ha-backup-config-addon
.hass=${this.hass}
.supervisorUpdateConfig=${this._supervisorUpdateConfig}
@update-config-changed=${this._supervisorUpdateConfigChanged}
></ha-backup-config-addon>
</div>
</ha-card>
</div>
</hass-subpage>
`;
}
private async _getSupervisorUpdateConfig() {
try {
this._supervisorUpdateConfig = await getSupervisorUpdateConfig(this.hass);
this._error = undefined;
} catch (err: any) {
// eslint-disable-next-line no-console
console.error(err);
this._error = this.hass.localize(
"ui.panel.config.backup.settings.app_update_backup.error_load",
{
error: err?.message || err,
}
);
}
}
private async _supervisorUpdateConfigChanged(ev) {
const config = ev.detail.value as SupervisorUpdateConfig;
this._supervisorUpdateConfig = {
...this._supervisorUpdateConfig,
...config,
} as SupervisorUpdateConfig;
this._debounceSaveSupervisorUpdateConfig();
}
private _debounceSaveSupervisorUpdateConfig = debounce(
() => this._saveSupervisorUpdateConfig(),
500
);
private async _saveSupervisorUpdateConfig() {
if (!this._supervisorUpdateConfig) {
return;
}
try {
await updateSupervisorUpdateConfig(
this.hass,
this._supervisorUpdateConfig
);
this._error = undefined;
} catch (err: any) {
// eslint-disable-next-line no-console
console.error(err);
this._error = this.hass.localize(
"ui.panel.config.backup.settings.app_update_backup.error_save",
{
error: err?.message || err?.toString(),
}
);
}
}
static styles = css`
p {
color: var(--secondary-text-color);
}
.content {
padding: 28px 20px 0;
max-width: 690px;
margin: 0 auto;
gap: var(--ha-space-6);
display: flex;
flex-direction: column;
margin-bottom: 24px;
}
.card-content {
padding-bottom: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-backup-app-update-backups": HaConfigBackupAppUpdateBackups;
}
}

View File

@@ -1,8 +1,9 @@
import { mdiDotsVertical, mdiPlus, mdiUpload } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { debounce } from "../../../common/util/debounce";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-dropdown";
@@ -23,6 +24,7 @@ import {
computeBackupAgentName,
generateBackup,
generateBackupWithAutomaticSettings,
saveBackupConfig,
} from "../../../data/backup";
import type { ManagerStateEvent } from "../../../data/backup_manager";
import type { CloudStatus } from "../../../data/cloud";
@@ -32,10 +34,12 @@ import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
import { showAlertDialog } from "../../lovelace/custom-card-helpers";
import "./components/overview/ha-backup-overview-backups";
import "./components/overview/ha-backup-overview-app-update-backup";
import "./components/overview/ha-backup-overview-onboarding";
import "./components/overview/ha-backup-overview-progress";
import "./components/overview/ha-backup-overview-settings";
import "./components/overview/ha-backup-overview-summary";
import "./components/config/ha-backup-config-encryption-key";
import { showBackupOnboardingDialog } from "./dialogs/show-dialog-backup_onboarding";
import { showGenerateBackupDialog } from "./dialogs/show-dialog-generate-backup";
import { showNewBackupDialog } from "./dialogs/show-dialog-new-backup";
@@ -68,10 +72,54 @@ class HaConfigBackupOverview extends LitElement {
{ uploaded_bytes: number; total_bytes: number }
> = {};
@state() private _config?: BackupConfig;
protected willUpdate(changedProperties: PropertyValues<this>): void {
super.willUpdate(changedProperties);
if (changedProperties.has("config") && !this._config) {
this._config = this.config;
}
}
public connectedCallback(): void {
super.connectedCallback();
// Update config when the page is displayed (e.g. when coming back from a settings page)
this._config = this.config;
}
private _uploadBackup = async () => {
await showUploadBackupDialog(this, {});
};
private _encryptionKeyChanged(ev) {
if (!this._config) {
return;
}
const password = ev.detail.value as string;
this._config = {
...this._config,
create_backup: {
...this._config.create_backup,
password,
},
};
this._debounceSaveConfig();
}
private _debounceSaveConfig = debounce(() => this._saveConfig(), 500);
private async _saveConfig() {
if (!this._config) {
return;
}
await saveBackupConfig(this.hass, this._config);
fireEvent(this, "ha-refresh-backup-config");
}
private _handleOnboardingButtonClick(ev) {
ev.stopPropagation();
this._setupAutomaticBackup(true);
@@ -234,13 +282,41 @@ class HaConfigBackupOverview extends LitElement {
.backups=${this.backups}
></ha-backup-overview-backups>
${!this._needsOnboarding && this.config
${!this._needsOnboarding && this._config
? html`
<ha-card>
<div class="card-header">
${this.hass.localize(
"ui.panel.config.backup.settings.encryption_key.title"
)}
</div>
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.backup.settings.encryption_key.description"
)}
</p>
<ha-backup-config-encryption-key
.hass=${this.hass}
.value=${this._config.create_backup.password}
@value-changed=${this._encryptionKeyChanged}
></ha-backup-config-encryption-key>
</div>
</ha-card>
<ha-backup-overview-settings
.hass=${this.hass}
.config=${this.config!}
.config=${this._config}
.agents=${this.agents}
></ha-backup-overview-settings>
${this.hass.config.components.includes("hassio")
? html`
<ha-backup-overview-app-update-backup
.hass=${this.hass}
></ha-backup-overview-app-update-backup>
`
: nothing}
`
: nothing}
</div>
@@ -270,6 +346,10 @@ class HaConfigBackupOverview extends LitElement {
return [
haStyle,
css`
p {
color: var(--secondary-text-color);
}
.content {
padding: 28px 20px 0;
max-width: 690px;
@@ -283,10 +363,6 @@ class HaConfigBackupOverview extends LitElement {
display: flex;
justify-content: flex-end;
}
.card-content {
padding-left: 0;
padding-right: 0;
}
.loading {
display: flex;
}

View File

@@ -16,7 +16,7 @@ import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next";
import "../../../components/ha-svg-icon";
import type { BackupAgent, BackupConfig } from "../../../data/backup";
import { updateBackupConfig } from "../../../data/backup";
import { saveBackupConfig } from "../../../data/backup";
import type { CloudStatus } from "../../../data/cloud";
import {
getSupervisorUpdateConfig,
@@ -27,11 +27,9 @@ import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import { documentationUrl } from "../../../util/documentation-url";
import "./components/config/ha-backup-config-addon";
import "./components/config/ha-backup-config-agents";
import "./components/config/ha-backup-config-data";
import type { BackupConfigData } from "./components/config/ha-backup-config-data";
import "./components/config/ha-backup-config-encryption-key";
import "./components/config/ha-backup-config-schedule";
import type { BackupConfigSchedule } from "./components/config/ha-backup-config-schedule";
import { showLocalBackupLocationDialog } from "./dialogs/show-dialog-local-backup-location";
@@ -79,7 +77,7 @@ class HaConfigBackupSettings extends LitElement {
// eslint-disable-next-line no-console
console.error(err);
this._supervisorUpdateConfigError = this.hass.localize(
"ui.panel.config.backup.settings.app_update_backup.error_load",
"ui.panel.config.backup.settings.schedule.error_load",
{
error: err?.message || err,
}
@@ -315,57 +313,6 @@ class HaConfigBackupSettings extends LitElement {
: nothing}
</div>
</ha-card>
${supervisor
? html`<ha-card>
<div class="card-header">
${this.hass.localize(
"ui.panel.config.backup.settings.app_update_backup.title"
)}
</div>
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.backup.settings.app_update_backup.description"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.backup.settings.app_update_backup.local_only"
)}
</p>
${this._supervisorUpdateConfigError
? html`<ha-alert alert-type="error">
${this._supervisorUpdateConfigError}
</ha-alert>`
: nothing}
<ha-backup-config-addon
.hass=${this.hass}
.supervisorUpdateConfig=${this._supervisorUpdateConfig}
@update-config-changed=${this
._supervisorUpdateConfigChanged}
></ha-backup-config-addon>
</div>
</ha-card>`
: nothing}
<ha-card>
<div class="card-header">
${this.hass.localize(
"ui.panel.config.backup.settings.encryption_key.title"
)}
</div>
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.backup.settings.encryption_key.description"
)}
</p>
<ha-backup-config-encryption-key
.hass=${this.hass}
.value=${this._config.create_backup.password}
@value-changed=${this._encryptionKeyChanged}
></ha-backup-config-encryption-key>
</div>
</ha-card>
</div>
</hass-subpage>
`;
@@ -438,18 +385,6 @@ class HaConfigBackupSettings extends LitElement {
this._debounceSave();
}
private _encryptionKeyChanged(ev) {
const password = ev.detail.value as string;
this._config = {
...this._config!,
create_backup: {
...this._config!.create_backup,
password: password,
},
};
this._debounceSave();
}
private _debounceSaveSupervisorUpdateConfig = debounce(
() => this._saveSupervisorUpdateConfig(),
500
@@ -468,7 +403,7 @@ class HaConfigBackupSettings extends LitElement {
// eslint-disable-next-line no-console
console.error(err);
this._supervisorUpdateConfigError = this.hass.localize(
"ui.panel.config.backup.settings.app_update_backup.error_save",
"ui.panel.config.backup.settings.schedule.error_save",
{
error: err?.message || err?.toString(),
}
@@ -479,18 +414,7 @@ class HaConfigBackupSettings extends LitElement {
private _debounceSave = debounce(() => this._save(), 500);
private async _save() {
await updateBackupConfig(this.hass, {
create_backup: {
agent_ids: this._config!.create_backup.agent_ids,
include_folders: this._config!.create_backup.include_folders ?? [],
include_database: this._config!.create_backup.include_database,
include_addons: this._config!.create_backup.include_addons ?? [],
include_all_addons: this._config!.create_backup.include_all_addons,
password: this._config!.create_backup.password,
},
retention: this._config!.retention,
schedule: this._config!.schedule,
});
await saveBackupConfig(this.hass, this._config!);
fireEvent(this, "ha-refresh-backup-config");
}

View File

@@ -125,6 +125,11 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
load: () => import("./ha-config-backup-settings"),
cache: true,
},
"app-update-backups": {
tag: "ha-config-backup-app-update-backups",
load: () => import("./ha-config-backup-app-update-backups"),
cache: true,
},
location: {
tag: "ha-config-backup-location",
load: () => import("./ha-config-backup-location"),

View File

@@ -1,16 +1,15 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
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";
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";
@customElement("ha-navigation-list")
class HaNavigationList extends LitElement {
@customElement("ha-config-navigation-list")
class HaConfigNavigationList extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@@ -24,16 +23,11 @@ class HaNavigationList extends LitElement {
public render(): TemplateResult {
return html`
<ha-md-list
innerRole="menu"
itemRoles="menuitem"
innerAriaLabel=${ifDefined(this.label)}
>
<ha-list-nav .ariaLabel=${this.label}>
${this.pages.map((page) => {
const externalApp = page.path.endsWith("#external-app-configuration");
return html`
<ha-md-list-item
.type=${externalApp ? "button" : "link"}
<ha-list-item-button
.href=${externalApp ? undefined : page.path}
@click=${externalApp ? this._handleExternalApp : undefined}
>
@@ -55,10 +49,10 @@ class HaNavigationList extends LitElement {
${!this.narrow
? html`<ha-icon-next slot="end"></ha-icon-next>`
: ""}
</ha-md-list-item>
</ha-list-item-button>
`;
})}
</ha-md-list>
</ha-list-nav>
`;
}
@@ -83,14 +77,11 @@ class HaNavigationList 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-navigation-list": HaNavigationList;
"ha-config-navigation-list": HaConfigNavigationList;
}
}

View File

@@ -1,14 +1,18 @@
import { mdiDownload } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { extractSearchParam } from "../../../common/url/search-params";
import "../../../components/ha-analytics";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-md-list";
import "../../../components/ha-md-list-item";
import "../../../components/ha-spinner";
import "../../../components/ha-svg-icon";
import "../../../components/ha-switch";
import { getSignedPath } from "../../../data/auth";
import type { HaSwitch } from "../../../components/ha-switch";
import type { Analytics } from "../../../data/analytics";
import {
@@ -26,6 +30,7 @@ import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { fileDownload } from "../../../util/file_download";
@customElement("ha-config-analytics")
class ConfigAnalytics extends SubscribeMixin(LitElement) {
@@ -119,6 +124,18 @@ class ConfigAnalytics extends SubscribeMixin(LitElement) {
</ha-md-list-item>
</ha-md-list>
</div>
<div class="card-actions">
<ha-button
size="small"
appearance="plain"
@click=${this._downloadDeviceInfo}
>
<ha-svg-icon slot="start" .path=${mdiDownload}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.analytics.download_device_info"
)}
</ha-button>
</div>
</ha-card>`
: nothing}
${this._zwaveEntryId !== undefined
@@ -290,6 +307,11 @@ class ConfigAnalytics extends SubscribeMixin(LitElement) {
this._save();
}
private async _downloadDeviceInfo(): Promise<void> {
const signedPath = await getSignedPath(this.hass, "/api/analytics/devices");
fileDownload(signedPath.path);
}
static get styles(): CSSResultGroup {
return [
haStyle,

View File

@@ -1,17 +1,9 @@
import { mdiDotsVertical, mdiDownload } from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-svg-icon";
import { getSignedPath } from "../../../data/auth";
import "../../../layouts/hass-subpage";
import type { HomeAssistant, Route } from "../../../types";
import { fileDownload } from "../../../util/file_download";
import "./ha-config-analytics";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
@customElement("ha-config-section-analytics")
class HaConfigSectionAnalytics extends LitElement {
@@ -29,19 +21,6 @@ class HaConfigSectionAnalytics extends LitElement {
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.analytics.caption")}
>
<ha-dropdown
@wa-select=${this._handleOverflowAction}
slot="toolbar-icon"
>
<ha-icon-button slot="trigger" .path=${mdiDotsVertical}>
</ha-icon-button>
<ha-dropdown-item .value=${"download_device_info"}>
<ha-svg-icon slot="icon" .path=${mdiDownload}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.analytics.download_device_info"
)}
</ha-dropdown-item>
</ha-dropdown>
<div class="content">
<ha-config-analytics .hass=${this.hass}></ha-config-analytics>
</div>
@@ -49,18 +28,6 @@ class HaConfigSectionAnalytics extends LitElement {
`;
}
private async _handleOverflowAction(
ev: HaDropdownSelectEvent
): Promise<void> {
if (ev.detail.item.value === "download_device_info") {
const signedPath = await getSignedPath(
this.hass,
"/api/analytics/devices"
);
fileDownload(signedPath.path);
}
}
static styles = css`
.content {
padding: 28px 20px 0;

View File

@@ -13,10 +13,8 @@ import "../../../components/ha-checkbox";
import type { HaCheckbox } from "../../../components/ha-checkbox";
import "../../../components/ha-country-picker";
import "../../../components/ha-currency-picker";
import "../../../components/ha-formfield";
import "../../../components/ha-language-picker";
import "../../../components/ha-radio";
import type { HaRadio } from "../../../components/ha-radio";
import "../../../components/ha-select-box";
import "../../../components/ha-timezone-picker";
import "../../../components/input/ha-input";
import type { HaInput } from "../../../components/input/ha-input";
@@ -210,75 +208,61 @@ class HaConfigSectionGeneral extends LitElement {
"ui.panel.config.core.section.core.core_config.unit_system"
)}
</div>
<ha-formfield
.label=${html`
<span style="font-size: 14px">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.metric_example"
)}
</span>
<div style="color: var(--secondary-text-color)">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.unit_system_metric"
)}
</div>
`}
>
<ha-radio
<div class="unit-system-options">
<ha-select-box
name="unit_system"
value="metric"
.checked=${this._unitSystem === "metric"}
@change=${this._unitSystemChanged}
.hass=${this.hass}
.value=${this._unitSystem}
.disabled=${disabled}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${html`
<span style="font-size: 14px">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.us_customary_example"
)}
</span>
<div style="color: var(--secondary-text-color)">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.unit_system_us_customary"
)}
</div>
`}
>
<ha-radio
name="unit_system"
value="us_customary"
.checked=${this._unitSystem === "us_customary"}
@change=${this._unitSystemChanged}
.disabled=${disabled}
></ha-radio>
</ha-formfield>
${this._unitSystem !== this._configuredUnitSystem()
? html`
<ha-checkbox
.checked=${this._updateUnits}
.disabled=${this._submittingRegional}
@change=${this._updateUnitsChanged}
>
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.update_units_label"
)}
<div slot="hint">
@value-changed=${this._unitSystemChanged}
.options=${[
{
value: "metric",
label: this.hass.localize(
"ui.panel.config.core.section.core.core_config.metric_example"
),
description: this.hass.localize(
"ui.panel.config.core.section.core.core_config.unit_system_metric"
),
},
{
value: "us_customary",
label: this.hass.localize(
"ui.panel.config.core.section.core.core_config.us_customary_example"
),
description: this.hass.localize(
"ui.panel.config.core.section.core.core_config.unit_system_us_customary"
),
},
]}
>
</ha-select-box>
${this._unitSystem !== this._configuredUnitSystem()
? html`
<ha-checkbox
.checked=${this._updateUnits}
.disabled=${this._submittingRegional}
@change=${this._updateUnitsChanged}
>
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.update_units_text_1"
"ui.panel.config.core.section.core.core_config.update_units_label"
)}
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.update_units_text_2"
)}
<br /><br />
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.update_units_text_3"
)}
</div>
</ha-checkbox>
`
: ""}
<div slot="hint">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.update_units_text_1"
)}
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.update_units_text_2"
)}
<br /><br />
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.update_units_text_3"
)}
</div>
</ha-checkbox>
`
: nothing}
</div>
</div>
<div>
<ha-currency-picker
@@ -365,10 +349,8 @@ class HaConfigSectionGeneral extends LitElement {
this[`_${target.name}`] = target.value;
}
private _unitSystemChanged(ev: CustomEvent) {
this._unitSystem = (ev.target as HaRadio).value as
| "metric"
| "us_customary";
private _unitSystemChanged(ev: ValueChangedEvent<string>) {
this._unitSystem = ev.detail.value as "metric" | "us_customary";
}
private _updateUnitsChanged(ev: CustomEvent) {
@@ -505,6 +487,15 @@ class HaConfigSectionGeneral extends LitElement {
margin-top: var(--ha-space-2);
margin-bottom: var(--ha-space-3);
}
.unit-system-options {
padding-top: var(--ha-space-2);
}
.unit-system-options ha-checkbox {
display: block;
margin-top: var(--ha-space-3);
margin-inline-start: var(--ha-space-3);
}
`,
];
}

View File

@@ -1,5 +1,5 @@
import { mdiPower } from "@mdi/js";
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { canShowPage } from "../../../common/config/can_show_page";
@@ -8,7 +8,6 @@ import { relativeTime } from "../../../common/datetime/relative_time";
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
import "../../../components/ha-card";
import "../../../components/ha-icon-button";
import "../../../components/ha-navigation-list";
import type { BackupContent } from "../../../data/backup";
import { fetchBackupInfo } from "../../../data/backup";
import type { CloudStatus } from "../../../data/cloud";
@@ -29,6 +28,7 @@ import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart"
import "../../../layouts/hass-subpage";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import "../components/ha-config-navigation-list";
import "../ha-config-section";
import { configSections } from "../ha-panel-config";
@@ -146,7 +146,7 @@ class HaConfigSystemNavigation extends LitElement {
full-width
>
<ha-card outlined>
<ha-navigation-list
<ha-config-navigation-list
.hass=${this.hass}
.narrow=${this.narrow}
.pages=${pages}
@@ -154,7 +154,7 @@ class HaConfigSystemNavigation extends LitElement {
.label=${this.hass.localize(
"ui.panel.config.dashboard.system.main"
)}
></ha-navigation-list>
></ha-config-navigation-list>
</ha-card>
</ha-config-section>
</hass-subpage>
@@ -286,10 +286,6 @@ class HaConfigSystemNavigation extends LitElement {
margin-top: -42px;
}
}
ha-navigation-list {
--navigation-list-item-title-font-size: var(--ha-font-size-l);
}
`,
];
}

View File

@@ -434,7 +434,7 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
}
.dashboard-alert-title {
padding: var(--ha-space-4) var(--ha-space-4) 0;
padding: var(--ha-space-4) var(--ha-space-4) var(--ha-space-2);
font-size: var(--ha-font-size-l);
}

View File

@@ -4,11 +4,11 @@ import { customElement, property, state } from "lit/decorators";
import { filterNavigationPages } from "../../../common/config/filter_navigation_pages";
import "../../../components/ha-card";
import "../../../components/ha-icon-next";
import "../../../components/ha-navigation-list";
import type { CloudStatus } from "../../../data/cloud";
import { getConfigEntries } from "../../../data/config_entries";
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../../../types";
import "../components/ha-config-navigation-list";
@customElement("ha-config-navigation")
class HaConfigNavigation extends LitElement {
@@ -65,20 +65,17 @@ class HaConfigNavigation extends LitElement {
<div class="visually-hidden" role="heading" aria-level="2">
${this.hass.localize("panel.config")}
</div>
<ha-navigation-list
<ha-config-navigation-list
has-secondary
.hass=${this.hass}
.narrow=${this.narrow}
.pages=${pages}
.label=${this.hass.localize("panel.config")}
></ha-navigation-list>
></ha-config-navigation-list>
`;
}
static styles: CSSResultGroup = css`
ha-navigation-list {
--navigation-list-item-title-font-size: var(--ha-font-size-l);
}
/* Accessibility */
.visually-hidden {
position: absolute;

View File

@@ -10,9 +10,9 @@ import { getDeviceArea } from "../../../common/entity/context/get_device_context
import "../../../components/entity/state-badge";
import "../../../components/ha-alert";
import "../../../components/ha-icon-next";
import "../../../components/ha-md-list";
import "../../../components/ha-md-list-item";
import "../../../components/ha-spinner";
import "../../../components/item/ha-list-item-button";
import "../../../components/list/ha-list-base";
import "../../../components/progress/ha-progress-ring";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import { subscribeDeviceRegistry } from "../../../data/device/device_registry";
@@ -86,7 +86,9 @@ class HaConfigUpdates extends SubscribeMixin(LitElement) {
const updates = this.updateEntities;
return html`
<ha-md-list>
<ha-list-base
aria-label=${this.hass.localize("ui.panel.config.updates.caption")}
>
${updates.map((entity) => {
const entityEntry = this.getEntityEntry(entity.entity_id);
const deviceEntry =
@@ -101,13 +103,12 @@ class HaConfigUpdates extends SubscribeMixin(LitElement) {
: undefined;
return html`
<ha-md-list-item
<ha-list-item-button
class=${ifDefined(
entity.attributes.skipped_version ? "skipped" : undefined
)}
.entity_id=${entity.entity_id}
.hasMeta=${!this.narrow}
type="button"
@click=${this._openMoreInfo}
>
<div slot="start">
@@ -149,10 +150,10 @@ class HaConfigUpdates extends SubscribeMixin(LitElement) {
${this._renderUpdateProgress(entity)}
</div>`
: nothing}
</ha-md-list-item>
</ha-list-item-button>
`;
})}
</ha-md-list>
</ha-list-base>
`;
}
@@ -168,10 +169,10 @@ class HaConfigUpdates extends SubscribeMixin(LitElement) {
.skipped {
background: var(--secondary-background-color);
}
ha-md-list-item {
ha-list-item-button {
--md-list-item-leading-icon-size: 40px;
}
ha-icon-next {
ha-list-item-button ha-icon-next {
color: var(--secondary-text-color);
height: 24px;
width: 24px;

View File

@@ -5,9 +5,11 @@ import { dump, JSON_SCHEMA, load } from "js-yaml";
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { until } from "lit/directives/until";
import memoizeOne from "memoize-one";
import { storage } from "../../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { computeObjectId } from "../../../../common/entity/compute_object_id";
import {
@@ -23,6 +25,7 @@ import { showToast } from "../../../../util/toast";
import "../../../../components/entity/ha-entity-picker";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-button-toggle-group";
import "../../../../components/ha-card";
import "../../../../components/buttons/ha-progress-button";
import "../../../../components/ha-expansion-panel";
@@ -39,12 +42,14 @@ import {
serviceCallWillDisconnect,
} from "../../../../data/service";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { HomeAssistant, ToggleButton } from "../../../../types";
import { documentationUrl } from "../../../../util/documentation-url";
import { resolveMediaSource } from "../../../../data/media_source";
import { MatchMinHeightMixin } from "../../../../mixins/match-min-height-mixin";
import { withViewTransition } from "../../../../common/util/view-transition";
@customElement("developer-tools-action")
class HaPanelDevAction extends LitElement {
class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@@ -80,6 +85,12 @@ class HaPanelDevAction extends LitElement {
@query("#yaml-editor") private _yamlEditor?: HaYamlEditor;
@query(".ui-mode-content") private _uiModeContent?: HTMLElement;
protected get matchMinHeightTarget(): HTMLElement | null {
return this._yamlMode ? null : (this._uiModeContent ?? null);
}
protected willUpdate() {
if (
!this.hasUpdated &&
@@ -117,6 +128,21 @@ class HaPanelDevAction extends LitElement {
this._serviceData?.action
);
const modeButtons: ToggleButton[] = [
{
label: this.hass.localize(
"ui.panel.config.developer-tools.tabs.actions.ui_mode"
),
value: "ui",
},
{
label: this.hass.localize(
"ui.panel.config.developer-tools.tabs.actions.yaml_mode"
),
value: "yaml",
},
];
const domain = this._serviceData?.action
? computeDomain(this._serviceData?.action)
: undefined;
@@ -132,14 +158,34 @@ class HaPanelDevAction extends LitElement {
return html`
<div class="content">
<p>
${this.hass.localize(
"ui.panel.config.developer-tools.tabs.actions.description"
)}
</p>
<ha-card>
<div class="card-header">
<div class="header-row">
<div class="header-title">
${this.hass.localize(
"ui.panel.config.developer-tools.tabs.actions.title"
)}
</div>
<ha-button-toggle-group
size="small"
class="yaml-mode-toggle"
.buttons=${modeButtons}
.active=${this._yamlMode ? "yaml" : "ui"}
.disabled=${!this._uiAvailable}
@value-changed=${this._modeChanged}
></ha-button-toggle-group>
</div>
<p class="secondary">
${this.hass.localize(
"ui.panel.config.developer-tools.tabs.actions.description"
)}
</p>
</div>
${this._yamlMode
? html`<div class="card-content">
? html`<div
class="card-content"
style=${styleMap(this._matchMinHeightStyle)}
>
<ha-service-picker
.hass=${this.hass}
.value=${this._serviceData?.action}
@@ -161,44 +207,27 @@ class HaPanelDevAction extends LitElement {
show-advanced
show-service-id
@value-changed=${this._serviceDataChanged}
class="card-content"
class="card-content ui-mode-content"
></ha-service-control>
`}
${this._error !== undefined
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
</ha-card>
</div>
<div class="button-row">
<div class="buttons">
<div class="switch-mode-container">
<ha-button
appearance="plain"
@click=${this._toggleYaml}
.disabled=${!this._uiAvailable}
>
${this._yamlMode
? this.hass.localize(
"ui.panel.config.developer-tools.tabs.actions.ui_mode"
)
: this.hass.localize(
"ui.panel.config.developer-tools.tabs.actions.yaml_mode"
)}
</ha-button>
<div class="card-actions">
${!this._uiAvailable
? html`<span class="error"
>${this.hass.localize(
"ui.panel.config.developer-tools.tabs.actions.no_template_ui_support"
)}</span
>`
: ""}
: nothing}
<ha-progress-button raised @click=${this._callService}>
${this.hass.localize(
"ui.panel.config.developer-tools.tabs.actions.call_service"
)}
</ha-progress-button>
</div>
<ha-progress-button raised @click=${this._callService}>
${this.hass.localize(
"ui.panel.config.developer-tools.tabs.actions.call_service"
)}
</ha-progress-button>
</div>
</ha-card>
</div>
${this._response?.result
? html`<div class="content response">
@@ -439,7 +468,7 @@ class HaPanelDevAction extends LitElement {
}
);
private async _callService(ev) {
private async _callService(ev: Event) {
const button = ev.currentTarget as HaProgressButton;
if (this._yamlMode && !this._yamlValid) {
@@ -560,13 +589,20 @@ class HaPanelDevAction extends LitElement {
button.actionSuccess();
}
private _toggleYaml() {
this._yamlMode = !this._yamlMode;
this._yamlValid = true;
this._error = undefined;
private _modeChanged(ev: HASSDomEvent<{ value: string }>) {
ev.stopPropagation();
const yamlMode = ev.detail.value === "yaml";
if (yamlMode === this._yamlMode) {
return;
}
withViewTransition(() => {
this._yamlMode = yamlMode;
this._yamlValid = true;
this._error = undefined;
});
}
private _yamlChanged(ev) {
private _yamlChanged(ev: HASSDomEvent<{ value: any; isValid: boolean }>) {
if (!ev.detail.isValid) {
this._yamlValid = false;
return;
@@ -602,7 +638,7 @@ class HaPanelDevAction extends LitElement {
}
}
private _serviceDataChanged(ev) {
private _serviceDataChanged(ev: HASSDomEvent<{ value: any }>) {
if (this._serviceData?.action !== ev.detail.value.action) {
this._error = undefined;
}
@@ -610,7 +646,7 @@ class HaPanelDevAction extends LitElement {
this._checkUiSupported();
}
private _serviceChanged(ev) {
private _serviceChanged(ev: HASSDomEvent<{ value: any }>) {
ev.stopPropagation();
if (ev.detail.value) {
this._serviceData = { action: ev.detail.value, data: {} };
@@ -667,30 +703,55 @@ class HaPanelDevAction extends LitElement {
max-width: 1200px;
margin: auto;
}
.button-row {
padding: var(--ha-space-2) var(--ha-space-4);
border-top: 1px solid var(--divider-color);
border-bottom: 1px solid var(--divider-color);
background: var(--card-background-color);
position: sticky;
bottom: 0;
box-sizing: border-box;
width: 100%;
}
.button-row .buttons {
.card-header {
display: flex;
justify-content: space-between;
max-width: 1200px;
margin: auto;
flex-direction: column;
gap: var(--ha-space-1);
}
.switch-mode-container {
.header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ha-space-2);
}
.switch-mode-container .error {
margin-left: var(--ha-space-2);
margin-inline-start: var(--ha-space-2);
margin-inline-end: initial;
.header-title {
flex: 1;
min-width: 0;
}
.secondary {
margin: 0;
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-normal);
line-height: normal;
letter-spacing: normal;
color: var(--secondary-text-color);
}
.card-content {
display: flex;
align-items: stretch;
justify-content: flex-start;
flex-direction: column;
gap: var(--ha-space-4);
margin: var(--ha-space-2);
--service-control-padding: 0;
}
.card-content ha-yaml-editor {
flex: 1;
display: flex;
flex-direction: column;
}
.yaml-mode-toggle {
flex-shrink: 0;
}
.card-actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: var(--ha-space-2);
}
.card-actions .error {
flex: 1;
color: var(--error-color);
}
.attributes {
width: 100%;

View File

@@ -244,9 +244,13 @@ class HaPanelDevAssist extends SubscribeMixin(LitElement) {
max-width: 1040px;
margin: 0 auto;
}
.card-content {
display: flex;
flex-direction: column;
gap: var(--ha-space-4);
}
.description {
margin: 0;
margin-bottom: var(--ha-space-4);
}
ha-textarea {
width: 100%;

View File

@@ -18,7 +18,7 @@ import "./events-list";
class HaPanelDevEvent extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public narrow = false;
@state() private _eventType = "";
@@ -94,6 +94,7 @@ class HaPanelDevEvent extends LitElement {
<event-subscribe-card
.hass=${this.hass}
.narrow=${this.narrow}
.selectedEventType=${this._selectedEventType}
></event-subscribe-card>
</div>
@@ -158,6 +159,8 @@ class HaPanelDevEvent extends LitElement {
padding: var(--ha-space-4);
max-width: 1200px;
margin: auto;
height: 100%;
box-sizing: border-box;
}
:host {
@@ -165,10 +168,26 @@ class HaPanelDevEvent extends LitElement {
-webkit-user-select: initial;
-moz-user-select: initial;
display: block;
height: 100%;
}
:host([narrow]) {
height: auto;
}
:host([narrow]) .content {
height: auto;
}
.flex {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
:host([narrow]) .flex {
min-height: auto;
}
.inputs {
@@ -180,11 +199,19 @@ class HaPanelDevEvent extends LitElement {
}
event-subscribe-card {
display: block;
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
margin-top: var(--ha-space-4);
direction: var(--direction);
}
:host([narrow]) event-subscribe-card {
flex: none;
min-height: auto;
}
a {
color: var(--primary-color);
}

View File

@@ -1,21 +1,39 @@
import {
mdiChevronDoubleLeft,
mdiChevronDoubleRight,
mdiChevronLeft,
mdiChevronRight,
mdiInformationOutline,
} from "@mdi/js";
import type { HassEvent } from "home-assistant-js-websocket";
import type { TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { formatTime } from "../../../../common/datetime/format_time";
import { formatTimeWithSeconds } from "../../../../common/datetime/format_time";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-tooltip";
import "../../../../components/ha-yaml-editor";
import "../../../../components/input/ha-input";
import type { HaInput } from "../../../../components/input/ha-input";
import type { HomeAssistant } from "../../../../types";
const MAX_BUFFERED_EVENTS = 100;
interface SubscribedEvent {
id: number;
event: HassEvent;
}
@customElement("event-subscribe-card")
class EventSubscribeCard extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: false }) public selectedEventType = "";
@state() private _eventType = "";
@@ -24,13 +42,12 @@ class EventSubscribeCard extends LitElement {
@state() private _eventFilter = "";
@state() private _events: {
id: number;
event: HassEvent;
}[] = [];
@state() private _events: SubscribedEvent[] = [];
@state() private _error?: string;
@state() private _viewedEventId?: number;
private _eventCount = 0;
@state() _ignoredEventsCount = 0;
@@ -113,43 +130,161 @@ class EventSubscribeCard extends LitElement {
</ha-button>
</div>
</ha-card>
<ha-card>
<div class="card-content">
<div class="events">
${repeat(
this._events,
(event) => event.id,
(event) => html`
<div class="event">
${this.hass!.localize(
"ui.panel.config.developer-tools.tabs.events.event_fired",
{ name: event.id }
)}
${formatTime(
new Date(event.event.time_fired),
this.hass!.locale,
this.hass!.config
)}:
<ha-yaml-editor
.hass=${this.hass}
.defaultValue=${event.event}
read-only
></ha-yaml-editor>
</div>
`
${this._renderEventsCard()}
`;
}
private _renderEventsCard(): TemplateResult {
if (!this._events.length) {
const message = this._subscribed
? this.hass!.localize(
"ui.panel.config.developer-tools.tabs.events.waiting_for_events"
)
: this.hass!.localize(
"ui.panel.config.developer-tools.tabs.events.subscribe_prompt"
);
return html`
<ha-card class="events-card">
<div class="empty-state">${message}</div>
</ha-card>
`;
}
const bufferTotal = this._events.length;
const index = this._resolveViewedIndex();
const event = this._events[index];
const position = event.id + 1;
const bufferPosition = bufferTotal - index;
const atNewest = index === 0;
const hasRolledOver = this._events[bufferTotal - 1].id > 0;
return html`
<ha-card class="events-card">
<div class="events-toolbar">
<ha-icon-button
.path=${mdiChevronDoubleLeft}
.disabled=${index >= bufferTotal - 1}
.label=${this.hass!.localize(
"ui.panel.config.developer-tools.tabs.events.oldest_event"
)}
@click=${this._showOldest}
></ha-icon-button>
<ha-icon-button
.path=${mdiChevronLeft}
.disabled=${index >= bufferTotal - 1}
.label=${this.hass!.localize(
"ui.panel.config.developer-tools.tabs.events.older_event"
)}
@click=${this._showOlder}
></ha-icon-button>
<div class="event-info">
${this.hass!.localize(
"ui.panel.config.developer-tools.tabs.events.event_fired",
{
name: position,
time: formatTimeWithSeconds(
new Date(event.event.time_fired),
this.hass!.locale,
this.hass!.config
),
}
)}
<span class="counter">(${bufferPosition} / ${bufferTotal})</span>
${hasRolledOver
? html`
<ha-svg-icon
id="buffer-info"
class="buffer-info"
.path=${mdiInformationOutline}
></ha-svg-icon>
<ha-tooltip for="buffer-info" placement="bottom">
<span class="buffer-tooltip">
${this.hass!.localize(
"ui.panel.config.developer-tools.tabs.events.buffer_disclaimer",
{ count: MAX_BUFFERED_EVENTS }
)}
</span>
</ha-tooltip>
`
: nothing}
</div>
<ha-icon-button
.path=${mdiChevronRight}
.disabled=${atNewest}
.label=${this.hass!.localize(
"ui.panel.config.developer-tools.tabs.events.newer_event"
)}
@click=${this._showNewer}
></ha-icon-button>
<ha-icon-button
.path=${mdiChevronDoubleRight}
.disabled=${atNewest}
.label=${this.hass!.localize(
"ui.panel.config.developer-tools.tabs.events.newest_event"
)}
@click=${this._showNewest}
></ha-icon-button>
</div>
<ha-yaml-editor
.hass=${this.hass}
.value=${event.event}
auto-update
read-only
></ha-yaml-editor>
</ha-card>
`;
}
private _valueChanged(ev: InputEvent): void {
private _resolveViewedIndex(): number {
if (this._viewedEventId === undefined) {
return 0;
}
const found = this._events.findIndex((e) => e.id === this._viewedEventId);
// Fall back to the oldest available event when the viewed one has aged out.
return found === -1 ? this._events.length - 1 : found;
}
private _showOldest() {
if (!this._events.length) {
return;
}
this._viewedEventId = this._events[this._events.length - 1].id;
}
private _showOlder() {
if (!this._events.length) {
return;
}
const next = Math.min(
this._resolveViewedIndex() + 1,
this._events.length - 1
);
this._viewedEventId = this._events[next].id;
}
private _showNewest() {
if (!this._events.length) {
return;
}
this._viewedEventId = this._events[0].id;
}
private _showNewer() {
if (!this._events.length) {
return;
}
const next = Math.max(this._resolveViewedIndex() - 1, 0);
this._viewedEventId = this._events[next].id;
}
private _valueChanged(ev: InputEvent) {
this._eventType = (ev.target as HaInput).value ?? "";
this._error = undefined;
}
private _filterChanged(ev: InputEvent): void {
private _filterChanged(ev: InputEvent) {
this._eventFilter = (ev.target as HaInput).value ?? "";
}
@@ -160,7 +295,7 @@ class EventSubscribeCard extends LitElement {
const searchStr = this._eventFilter;
function visit(node) {
function visit(node: unknown) {
// Handle primitives directly
if (node === null || typeof node !== "object") {
return String(node).includes(searchStr);
@@ -203,55 +338,116 @@ class EventSubscribeCard extends LitElement {
return;
}
const tail =
this._events.length > 30
? this._events.slice(0, 29)
this._events.length >= MAX_BUFFERED_EVENTS
? this._events.slice(0, MAX_BUFFERED_EVENTS - 1)
: this._events;
const id = this._eventCount++;
this._events = [
{
event,
id: this._eventCount++,
id,
},
...tail,
];
if (this._viewedEventId === undefined) {
this._viewedEventId = id;
}
}, this._eventType);
} catch (error: any) {
} catch (error) {
this._error = this.hass!.localize(
"ui.panel.config.developer-tools.tabs.events.subscribe_failed",
{ error: error.message || "Unknown error" }
{
error:
error instanceof Error
? error.message
: this.hass!.localize(
"ui.panel.config.developer-tools.tabs.events.unknown_error"
),
}
);
}
}
}
private _clearEvents(): void {
private _clearEvents() {
this._events = [];
this._eventCount = 0;
this._ignoredEventsCount = 0;
this._error = undefined;
this._viewedEventId = undefined;
}
static styles = css`
:host {
display: flex;
flex-direction: column;
min-height: 0;
}
ha-input {
margin-bottom: var(--ha-space-2);
}
.error-message {
margin-top: var(--ha-space-2);
}
.event {
border-top: 1px solid var(--divider-color);
padding-top: var(--ha-space-2);
padding-bottom: var(--ha-space-2);
margin: var(--ha-space-4) 0;
}
.event:last-child {
border-bottom: 0;
margin-bottom: 0;
}
pre {
font-family: var(--ha-font-family-code);
}
ha-card {
margin-bottom: var(--ha-space-1);
margin-bottom: var(--ha-space-2);
}
.events-card {
display: flex;
flex-direction: column;
height: 620px;
padding: var(--ha-space-2);
}
:host([narrow]) .events-card {
height: auto;
min-height: 360px;
}
.events-toolbar {
display: flex;
align-items: center;
gap: var(--ha-space-2);
}
.empty-state {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
padding: var(--ha-space-8);
color: var(--primary-text-color);
text-align: center;
font-size: var(--ha-font-size-xl);
line-height: var(--ha-line-height-normal);
}
.event-info {
flex: 1;
text-align: center;
font-size: var(--ha-font-size-m);
}
.counter {
color: var(--secondary-text-color);
margin-left: var(--ha-space-2);
}
.buffer-info {
color: var(--secondary-text-color);
margin-left: var(--ha-space-1);
vertical-align: middle;
--mdc-icon-size: 16px;
}
.buffer-tooltip {
white-space: pre-line;
display: block;
max-width: 320px;
}
ha-yaml-editor {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
margin-top: var(--ha-space-2);
--code-mirror-height: 100%;
}
`;
}

View File

@@ -1,12 +1,13 @@
import type { CSSResultGroup } from "lit";
import { html, LitElement, nothing } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-dialog";
import "../../../../components/ha-button";
import "../../../../components/ha-formfield";
import "../../../../components/ha-radio";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/radio/ha-radio-group";
import type { HaRadioGroup } from "../../../../components/radio/ha-radio-group";
import "../../../../components/radio/ha-radio-option";
import {
clearStatistics,
getStatisticLabel,
@@ -79,37 +80,26 @@ export class DialogStatisticsFixUnitsChanged extends LitElement {
)}
</p>
<h3>
${this.hass.localize(
<ha-radio-group
.label=${this.hass.localize(
"ui.panel.config.developer-tools.tabs.statistics.fix_issue.units_changed.how_to_fix"
)}
</h3>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.developer-tools.tabs.statistics.fix_issue.units_changed.update",
this._params.issue.data
)}
.value=${this._action}
name="action"
@change=${this._handleActionChanged}
>
<ha-radio
value="update"
name="action"
.checked=${this._action === "update"}
@change=${this._handleActionChanged}
autofocus
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
`ui.panel.config.developer-tools.tabs.statistics.fix_issue.units_changed.clear`
)}
>
<ha-radio
value="clear"
name="action"
.checked=${this._action === "clear"}
@change=${this._handleActionChanged}
></ha-radio>
</ha-formfield>
<ha-radio-option value="update" autofocus>
${this.hass.localize(
"ui.panel.config.developer-tools.tabs.statistics.fix_issue.units_changed.update",
this._params.issue.data
)}
</ha-radio-option>
<ha-radio-option value="clear">
${this.hass.localize(
`ui.panel.config.developer-tools.tabs.statistics.fix_issue.units_changed.clear`
)}
</ha-radio-option>
</ha-radio-group>
<ha-dialog-footer slot="footer">
<ha-button
@@ -129,8 +119,10 @@ export class DialogStatisticsFixUnitsChanged extends LitElement {
`;
}
private _handleActionChanged(ev): void {
this._action = ev.target.value;
private _handleActionChanged(ev: Event): void {
this._action = (ev.currentTarget as HaRadioGroup).value as
| "update"
| "clear";
}
private _cancel(): void {
@@ -154,7 +146,15 @@ export class DialogStatisticsFixUnitsChanged extends LitElement {
}
static get styles(): CSSResultGroup {
return [haStyle, haStyleDialog];
return [
haStyle,
haStyleDialog,
css`
ha-radio-group::part(form-control-label) {
font-weight: var(--ha-font-weight-medium);
}
`,
];
}
}

View File

@@ -3,11 +3,13 @@ import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { debounce } from "../../../../common/util/debounce";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-code-editor";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-spinner";
import "../../../../components/ha-tip";
import type { RenderTemplateResult } from "../../../../data/ws-templates";
@@ -54,6 +56,8 @@ class HaPanelDevTemplate extends LitElement {
@state() private _unsubRenderTemplate?: Promise<UnsubscribeFunc>;
@state() private _descriptionExpanded = false;
private _template = "";
private _inited = false;
@@ -88,47 +92,58 @@ class HaPanelDevTemplate extends LitElement {
? "list"
: "dict"
: type;
return html`
<div class="content">
<div class="description">
<p>
${this.hass.localize(
"ui.panel.config.developer-tools.tabs.templates.description"
)}
</p>
<ul>
<li>
<a
href="https://jinja.palletsprojects.com/en/latest/templates/"
target="_blank"
rel="noreferrer"
>${this.hass.localize(
"ui.panel.config.developer-tools.tabs.templates.jinja_documentation"
)}
</a>
</li>
<li>
<a
href=${documentationUrl(
this.hass,
"/docs/configuration/templating/"
)}
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.developer-tools.tabs.templates.template_extensions"
)}</a
>
</li>
</ul>
</div>
<ha-expansion-panel
.header=${this.hass.localize(
"ui.panel.config.developer-tools.tabs.templates.about"
)}
outlined
.expanded=${this._descriptionExpanded}
@expanded-changed=${this._expandedChanged}
>
<div class="description">
<p>
${this.hass.localize(
"ui.panel.config.developer-tools.tabs.templates.description"
)}
</p>
<ul>
<li>
<a
href="https://jinja.palletsprojects.com/en/latest/templates/"
target="_blank"
rel="noreferrer"
>${this.hass.localize(
"ui.panel.config.developer-tools.tabs.templates.jinja_documentation"
)}
</a>
</li>
<li>
<a
href=${documentationUrl(
this.hass,
"/docs/configuration/templating/"
)}
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.developer-tools.tabs.templates.template_extensions"
)}</a
>
</li>
</ul>
</div>
</ha-expansion-panel>
</div>
<div
class="content ${classMap({
layout: !this.narrow,
horizontal: !this.narrow,
})}"
style="--description-expanded: ${this._descriptionExpanded ? 1 : 0}"
>
<ha-card
class="edit-pane"
@@ -274,6 +289,12 @@ ${type === "object"
`;
}
private _expandedChanged(
ev: HASSDomEvent<HASSDomEvents["expanded-changed"]>
) {
this._descriptionExpanded = ev.detail.expanded;
}
static get styles(): CSSResultGroup {
return [
haStyle,
@@ -288,13 +309,41 @@ ${type === "object"
padding: var(--ha-space-4);
}
.content:has(ha-expansion-panel) {
padding-bottom: 0;
}
.content.horizontal {
--panel-header-height: calc(
var(--header-height) + 1em * 2 + var(--ha-line-height-normal) *
var(--ha-font-size-m) + 1px + 2px
);
--description-pane-height: calc(
var(--ha-space-4) + 48px +
(
var(--ha-line-height-normal) * var(--ha-font-size-m) * 3 +
var(--ha-space-1) * 2
) *
var(--description-expanded) + var(--ha-card-border-width, 1px) * 2
);
--card-header-height: calc(
var(--ha-space-3) + var(--ha-space-4) +
var(--ha-line-height-expanded) *
var(--ha-card-header-font-size, var(--ha-font-size-2xl))
);
--card-actions-height: calc(1px + var(--ha-space-2) * 2 + 40px);
--edit-pane-height: calc(
100vh - var(--panel-header-height) - var(
--description-pane-height
) - var(--ha-space-4) *
2
);
--code-mirror-max-height: calc(
100vh - var(--header-height) -
(var(--ha-line-height-normal) * var(--ha-font-size-m) * 3) -
(max(16px, var(--safe-area-inset-top)) * 2) -
(max(16px, var(--safe-area-inset-bottom)) * 2) -
(var(--ha-card-border-width, 1px) * 3) - (1em * 2) - 192px
var(--edit-pane-height) - var(--card-header-height) +
var(--ha-space-2) - var(--card-actions-height) - var(
--ha-space-4
) - var(--ha-card-border-width, 1px) *
2
);
}
@@ -345,6 +394,13 @@ ${type === "object"
ul {
margin-block-end: 0;
}
.description > p {
margin-block-start: 0;
}
.description > ul {
margin-block-start: var(--ha-space-1);
margin-block-end: var(--ha-space-1);
}
.render-pane .card-content {
user-select: text;

View File

@@ -7,7 +7,6 @@ import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-radio";
import "../../../../components/ha-select";
import type { HaSelectSelectEvent } from "../../../../components/ha-select";
import "../../../../components/input/ha-input";

View File

@@ -7,7 +7,6 @@ import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-radio";
import "../../../../components/ha-select";
import type {
HaSelectOption,

View File

@@ -7,10 +7,10 @@ import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-formfield";
import "../../../../components/ha-markdown";
import "../../../../components/ha-radio";
import type { HaRadio } from "../../../../components/ha-radio";
import "../../../../components/radio/ha-radio-group";
import type { HaRadioGroup } from "../../../../components/radio/ha-radio-group";
import "../../../../components/radio/ha-radio-option";
import "../../../../components/input/ha-input";
import type { GasSourceTypeEnergyPreference } from "../../../../data/energy";
import {
@@ -197,34 +197,31 @@ export class DialogEnergyGasSettings
)}
></ha-statistic-picker>
<p>
${this.hass.localize("ui.panel.config.energy.gas.dialog.cost_para")}
</p>
<ha-formfield
<ha-radio-group
.label=${this.hass.localize(
"ui.panel.config.energy.gas.dialog.no_cost"
"ui.panel.config.energy.gas.dialog.cost_para"
)}
.value=${this._costs}
name="costs"
@change=${this._handleCostChanged}
>
<ha-radio
value="no-costs"
name="costs"
.checked=${this._costs === "no-costs"}
@change=${this._handleCostChanged}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.energy.gas.dialog.cost_stat"
)}
>
<ha-radio
value="statistic"
name="costs"
.checked=${this._costs === "statistic"}
@change=${this._handleCostChanged}
></ha-radio>
</ha-formfield>
<ha-radio-option value="no-costs">
${this.hass.localize("ui.panel.config.energy.gas.dialog.no_cost")}
</ha-radio-option>
<ha-radio-option value="statistic">
${this.hass.localize("ui.panel.config.energy.gas.dialog.cost_stat")}
</ha-radio-option>
<ha-radio-option value="entity" .disabled=${externalSource}>
${this.hass.localize(
"ui.panel.config.energy.gas.dialog.cost_entity"
)}
</ha-radio-option>
<ha-radio-option value="number" .disabled=${externalSource}>
${this.hass.localize(
"ui.panel.config.energy.gas.dialog.cost_number"
)}
</ha-radio-option>
</ha-radio-group>
${this._costs === "statistic"
? html`<ha-statistic-picker
class="price-options"
@@ -236,87 +233,59 @@ export class DialogEnergyGasSettings
)} (${this.hass.config.currency})`}
@value-changed=${this._priceStatChanged}
></ha-statistic-picker>`
: ""}
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.energy.gas.dialog.cost_entity"
)}
>
<ha-radio
value="entity"
name="costs"
.checked=${this._costs === "entity"}
.disabled=${externalSource}
@change=${this._handleCostChanged}
></ha-radio>
</ha-formfield>
${this._costs === "entity"
? html`<ha-entity-picker
class="price-options"
.hass=${this.hass}
include-domains='["sensor", "input_number"]'
.value=${this._source.entity_energy_price}
.label=${this.hass.localize(
"ui.panel.config.energy.gas.dialog.cost_entity_input"
)}
.helper=${pickedUnitClass
? html`<ha-markdown
.content=${this.hass.localize(
"ui.panel.config.energy.gas.dialog.cost_entity_helper",
pickedUnitClass === "energy"
? {
currency: this.hass.config.currency,
class: this.hass.localize(
"ui.panel.config.energy.gas.dialog.cost_entity_helper_energy"
),
unit1: "kWh",
unit2: "Wh",
}
: {
currency: this.hass.config.currency,
class: this.hass.localize(
"ui.panel.config.energy.gas.dialog.cost_entity_helper_volume"
),
unit1: "m³",
unit2: "ft³",
}
)}
></ha-markdown>`
: nothing}
@value-changed=${this._priceEntityChanged}
></ha-entity-picker>`
: ""}
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.energy.gas.dialog.cost_number"
)}
>
<ha-radio
value="number"
name="costs"
.checked=${this._costs === "number"}
.disabled=${externalSource}
@change=${this._handleCostChanged}
></ha-radio>
</ha-formfield>
${this._costs === "number"
? html`<ha-input
.label=${`${this.hass.localize(
"ui.panel.config.energy.gas.dialog.cost_number_input"
)} ${unitPrice ? ` (${unitPrice})` : ""}`}
class="price-options"
step="any"
type="number"
.value=${this._source.number_energy_price !== null
? String(this._source.number_energy_price)
: ""}
@change=${this._numberPriceChanged}
>
${unitPrice
? html`<span slot="end">${unitPrice}</span>`
: nothing}
</ha-input>`
: ""}
: this._costs === "entity"
? html`<ha-entity-picker
class="price-options"
.hass=${this.hass}
include-domains='["sensor", "input_number"]'
.value=${this._source.entity_energy_price}
.label=${this.hass.localize(
"ui.panel.config.energy.gas.dialog.cost_entity_input"
)}
.helper=${pickedUnitClass
? html`<ha-markdown
.content=${this.hass.localize(
"ui.panel.config.energy.gas.dialog.cost_entity_helper",
pickedUnitClass === "energy"
? {
currency: this.hass.config.currency,
class: this.hass.localize(
"ui.panel.config.energy.gas.dialog.cost_entity_helper_energy"
),
unit1: "kWh",
unit2: "Wh",
}
: {
currency: this.hass.config.currency,
class: this.hass.localize(
"ui.panel.config.energy.gas.dialog.cost_entity_helper_volume"
),
unit1: "m³",
unit2: "ft³",
}
)}
></ha-markdown>`
: nothing}
@value-changed=${this._priceEntityChanged}
></ha-entity-picker>`
: this._costs === "number"
? html`<ha-input
.label=${`${this.hass.localize(
"ui.panel.config.energy.gas.dialog.cost_number_input"
)} ${unitPrice ? ` (${unitPrice})` : ""}`}
class="price-options"
step="any"
type="number"
.value=${this._source.number_energy_price !== null
? String(this._source.number_energy_price)
: ""}
@change=${this._numberPriceChanged}
>
${unitPrice
? html`<span slot="end">${unitPrice}</span>`
: nothing}
</ha-input>`
: nothing}
<ha-dialog-footer slot="footer">
<ha-button
@@ -338,9 +307,12 @@ export class DialogEnergyGasSettings
`;
}
private _handleCostChanged(ev: CustomEvent) {
const input = ev.currentTarget as HaRadio;
this._costs = input.value as any;
private _handleCostChanged(ev: Event) {
this._costs = (ev.currentTarget as HaRadioGroup).value as
| "no-costs"
| "number"
| "entity"
| "statistic";
}
private _numberPriceChanged(ev: InputEvent) {
@@ -420,15 +392,12 @@ export class DialogEnergyGasSettings
display: block;
margin-bottom: var(--ha-space-4);
}
ha-formfield {
display: block;
ha-radio-group {
margin-top: var(--ha-space-4);
}
.price-options {
display: block;
padding-left: 52px;
padding-inline-start: 52px;
padding-inline-end: initial;
margin-top: -8px;
margin-top: var(--ha-space-3);
}
`,
];

View File

@@ -7,9 +7,9 @@ import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-formfield";
import "../../../../components/ha-radio";
import type { HaRadio } from "../../../../components/ha-radio";
import "../../../../components/radio/ha-radio-group";
import type { HaRadioGroup } from "../../../../components/radio/ha-radio-group";
import "../../../../components/radio/ha-radio-option";
import "../../../../components/input/ha-input";
import type {
GridSourceTypeEnergyPreference,
@@ -234,56 +234,32 @@ export class DialogEnergyGridSettings
)}
</p>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.no_cost_tracking"
)}
<ha-radio-group
.value=${this._importCostType}
name="importCostType"
@change=${this._handleImportCostTypeChanged}
>
<ha-radio
value="no_cost"
name="importCostType"
.checked=${this._importCostType === "no_cost"}
@change=${this._handleImportCostTypeChanged}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.cost_stat"
)}
>
<ha-radio
value="stat"
name="importCostType"
.checked=${this._importCostType === "stat"}
@change=${this._handleImportCostTypeChanged}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.cost_entity"
)}
>
<ha-radio
value="entity"
name="importCostType"
.checked=${this._importCostType === "entity"}
.disabled=${externalImportSource}
@change=${this._handleImportCostTypeChanged}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.cost_number"
)}
>
<ha-radio
value="number"
name="importCostType"
.checked=${this._importCostType === "number"}
.disabled=${externalImportSource}
@change=${this._handleImportCostTypeChanged}
></ha-radio>
</ha-formfield>
<ha-radio-option value="no_cost">
${this.hass.localize(
"ui.panel.config.energy.grid.dialog.no_cost_tracking"
)}
</ha-radio-option>
<ha-radio-option value="stat">
${this.hass.localize(
"ui.panel.config.energy.grid.dialog.cost_stat"
)}
</ha-radio-option>
<ha-radio-option value="entity" .disabled=${externalImportSource}>
${this.hass.localize(
"ui.panel.config.energy.grid.dialog.cost_entity"
)}
</ha-radio-option>
<ha-radio-option value="number" .disabled=${externalImportSource}>
${this.hass.localize(
"ui.panel.config.energy.grid.dialog.cost_number"
)}
</ha-radio-option>
</ha-radio-group>
${this._importCostType === "stat"
? html`
@@ -340,56 +316,38 @@ export class DialogEnergyGridSettings
)}
</p>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.no_compensation_tracking"
)}
<ha-radio-group
.value=${this._exportCostType}
name="exportCostType"
@change=${this._handleExportCostTypeChanged}
>
<ha-radio
value="no_cost"
name="exportCostType"
.checked=${this._exportCostType === "no_cost"}
@change=${this._handleExportCostTypeChanged}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.compensation_stat"
)}
>
<ha-radio
value="stat"
name="exportCostType"
.checked=${this._exportCostType === "stat"}
@change=${this._handleExportCostTypeChanged}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.compensation_entity"
)}
>
<ha-radio
<ha-radio-option value="no_cost">
${this.hass.localize(
"ui.panel.config.energy.grid.dialog.no_compensation_tracking"
)}
</ha-radio-option>
<ha-radio-option value="stat">
${this.hass.localize(
"ui.panel.config.energy.grid.dialog.compensation_stat"
)}
</ha-radio-option>
<ha-radio-option
value="entity"
name="exportCostType"
.checked=${this._exportCostType === "entity"}
.disabled=${externalExportSource}
@change=${this._handleExportCostTypeChanged}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.energy.grid.dialog.compensation_number"
)}
>
<ha-radio
>
${this.hass.localize(
"ui.panel.config.energy.grid.dialog.compensation_entity"
)}
</ha-radio-option>
<ha-radio-option
value="number"
name="exportCostType"
.checked=${this._exportCostType === "number"}
.disabled=${externalExportSource}
@change=${this._handleExportCostTypeChanged}
></ha-radio>
</ha-formfield>
>
${this.hass.localize(
"ui.panel.config.energy.grid.dialog.compensation_number"
)}
</ha-radio-option>
</ha-radio-group>
${this._exportCostType === "stat"
? html`
@@ -534,8 +492,7 @@ export class DialogEnergyGridSettings
}
private _handleImportCostTypeChanged(ev: Event) {
const input = ev.currentTarget as HaRadio;
this._importCostType = input.value as CostType;
this._importCostType = (ev.currentTarget as HaRadioGroup).value as CostType;
// Clear other cost fields when switching types
this._source = {
...this._source!,
@@ -546,8 +503,7 @@ export class DialogEnergyGridSettings
}
private _handleExportCostTypeChanged(ev: Event) {
const input = ev.currentTarget as HaRadio;
this._exportCostType = input.value as CostType;
this._exportCostType = (ev.currentTarget as HaRadioGroup).value as CostType;
// Clear other cost fields when switching types
this._source = {
...this._source!,
@@ -647,8 +603,8 @@ export class DialogEnergyGridSettings
ha-input:last-of-type {
margin-bottom: 0;
}
ha-formfield {
display: block;
ha-radio-group {
margin-bottom: var(--ha-space-4);
}
.section-label {
margin-top: var(--ha-space-4);

View File

@@ -9,10 +9,10 @@ import "../../../../components/ha-checkbox";
import type { HaCheckbox } from "../../../../components/ha-checkbox";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-formfield";
import "../../../../components/ha-radio";
import type { HaRadio } from "../../../../components/ha-radio";
import "../../../../components/ha-svg-icon";
import "../../../../components/radio/ha-radio-group";
import type { HaRadioGroup } from "../../../../components/radio/ha-radio-group";
import "../../../../components/radio/ha-radio-option";
import type { ConfigEntry } from "../../../../data/config_entries";
import { getConfigEntries } from "../../../../data/config_entries";
import type { SolarSourceTypeEnergyPreference } from "../../../../data/energy";
@@ -156,30 +156,22 @@ export class DialogEnergySolarSettings
)}
</p>
<ha-formfield
label=${this.hass.localize(
"ui.panel.config.energy.solar.dialog.dont_forecast_production"
)}
<ha-radio-group
.value=${this._forecast ? "true" : "false"}
name="forecast"
@change=${this._handleForecastChanged}
>
<ha-radio
value="false"
name="forecast"
.checked=${!this._forecast}
@change=${this._handleForecastChanged}
></ha-radio>
</ha-formfield>
<ha-formfield
label=${this.hass.localize(
"ui.panel.config.energy.solar.dialog.forecast_production"
)}
>
<ha-radio
value="true"
name="forecast"
.checked=${this._forecast}
@change=${this._handleForecastChanged}
></ha-radio>
</ha-formfield>
<ha-radio-option value="false">
${this.hass.localize(
"ui.panel.config.energy.solar.dialog.dont_forecast_production"
)}
</ha-radio-option>
<ha-radio-option value="true">
${this.hass.localize(
"ui.panel.config.energy.solar.dialog.forecast_production"
)}
</ha-radio-option>
</ha-radio-group>
${this._forecast
? html`<div class="forecast-options">
${this._configEntries?.map(
@@ -257,9 +249,8 @@ export class DialogEnergySolarSettings
);
}
private _handleForecastChanged(ev: CustomEvent) {
const input = ev.currentTarget as HaRadio;
this._forecast = input.value === "true";
private _handleForecastChanged(ev: Event) {
this._forecast = (ev.currentTarget as HaRadioGroup).value === "true";
}
private _forecastCheckChanged(ev) {
@@ -329,20 +320,18 @@ export class DialogEnergySolarSettings
margin-inline-end: 16px;
margin-inline-start: initial;
}
ha-formfield {
display: block;
}
ha-statistic-picker {
width: 100%;
}
ha-radio-group {
margin-bottom: var(--ha-space-3);
}
.forecast-options {
padding-left: 32px;
padding-inline-start: 32px;
padding-inline-end: initial;
display: flex;
flex-direction: column;
min-width: 0;
gap: var(--ha-space-2);
margin-inline-start: var(--ha-space-3);
}
.forecast-options ha-button {
margin-top: var(--ha-space-4);

View File

@@ -6,11 +6,11 @@ import "../../../../components/entity/ha-entity-picker";
import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-formfield";
import "../../../../components/ha-radio";
import "../../../../components/ha-markdown";
import "../../../../components/ha-dialog";
import type { HaRadio } from "../../../../components/ha-radio";
import "../../../../components/radio/ha-radio-group";
import type { HaRadioGroup } from "../../../../components/radio/ha-radio-group";
import "../../../../components/radio/ha-radio-option";
import "../../../../components/input/ha-input";
import type { WaterSourceTypeEnergyPreference } from "../../../../data/energy";
import {
@@ -155,34 +155,33 @@ export class DialogEnergyWaterSettings
)}
></ha-statistic-picker>
<p>
${this.hass.localize("ui.panel.config.energy.water.dialog.cost_para")}
</p>
<ha-formfield
<ha-radio-group
.label=${this.hass.localize(
"ui.panel.config.energy.water.dialog.no_cost"
"ui.panel.config.energy.water.dialog.cost_para"
)}
.value=${this._costs}
name="costs"
@change=${this._handleCostChanged}
>
<ha-radio
value="no-costs"
name="costs"
.checked=${this._costs === "no-costs"}
@change=${this._handleCostChanged}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.energy.water.dialog.cost_stat"
)}
>
<ha-radio
value="statistic"
name="costs"
.checked=${this._costs === "statistic"}
@change=${this._handleCostChanged}
></ha-radio>
</ha-formfield>
<ha-radio-option value="no-costs">
${this.hass.localize("ui.panel.config.energy.water.dialog.no_cost")}
</ha-radio-option>
<ha-radio-option value="statistic">
${this.hass.localize(
"ui.panel.config.energy.water.dialog.cost_stat"
)}
</ha-radio-option>
<ha-radio-option value="entity" .disabled=${externalSource}>
${this.hass.localize(
"ui.panel.config.energy.water.dialog.cost_entity"
)}
</ha-radio-option>
<ha-radio-option value="number" .disabled=${externalSource}>
${this.hass.localize(
"ui.panel.config.energy.water.dialog.cost_number"
)}
</ha-radio-option>
</ha-radio-group>
${this._costs === "statistic"
? html`<ha-statistic-picker
class="price-options"
@@ -194,67 +193,39 @@ export class DialogEnergyWaterSettings
)} (${this.hass.config.currency})`}
@value-changed=${this._priceStatChanged}
></ha-statistic-picker>`
: ""}
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.energy.water.dialog.cost_entity"
)}
>
<ha-radio
value="entity"
name="costs"
.checked=${this._costs === "entity"}
.disabled=${externalSource}
@change=${this._handleCostChanged}
></ha-radio>
</ha-formfield>
${this._costs === "entity"
? html`<ha-entity-picker
class="price-options"
.hass=${this.hass}
include-domains='["sensor", "input_number"]'
.value=${this._source.entity_energy_price}
.label=${this.hass.localize(
"ui.panel.config.energy.water.dialog.cost_entity_input"
)}
.helper=${html`<ha-markdown
.content=${this.hass.localize(
"ui.panel.config.energy.water.dialog.cost_entity_helper",
{ currency: this.hass.config.currency }
: this._costs === "entity"
? html`<ha-entity-picker
class="price-options"
.hass=${this.hass}
include-domains='["sensor", "input_number"]'
.value=${this._source.entity_energy_price}
.label=${this.hass.localize(
"ui.panel.config.energy.water.dialog.cost_entity_input"
)}
></ha-markdown>`}
@value-changed=${this._priceEntityChanged}
></ha-entity-picker>`
: ""}
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.energy.water.dialog.cost_number"
)}
>
<ha-radio
value="number"
name="costs"
.checked=${this._costs === "number"}
.disabled=${externalSource}
@change=${this._handleCostChanged}
></ha-radio>
</ha-formfield>
${this._costs === "number"
? html`<ha-input
.label=${`${this.hass.localize(
"ui.panel.config.energy.water.dialog.cost_number_input"
)} (${unitPriceFixed})`}
class="price-options"
step="any"
type="number"
.value=${this._source.number_energy_price !== null
? String(this._source.number_energy_price)
: ""}
@change=${this._numberPriceChanged}
>
<span slot="end">${unitPriceFixed}</span>
</ha-input>`
: ""}
.helper=${html`<ha-markdown
.content=${this.hass.localize(
"ui.panel.config.energy.water.dialog.cost_entity_helper",
{ currency: this.hass.config.currency }
)}
></ha-markdown>`}
@value-changed=${this._priceEntityChanged}
></ha-entity-picker>`
: this._costs === "number"
? html`<ha-input
.label=${`${this.hass.localize(
"ui.panel.config.energy.water.dialog.cost_number_input"
)} (${unitPriceFixed})`}
class="price-options"
step="any"
type="number"
.value=${this._source.number_energy_price !== null
? String(this._source.number_energy_price)
: ""}
@change=${this._numberPriceChanged}
>
<span slot="end">${unitPriceFixed}</span>
</ha-input>`
: nothing}
<ha-dialog-footer slot="footer">
<ha-button
@@ -276,9 +247,12 @@ export class DialogEnergyWaterSettings
`;
}
private _handleCostChanged(ev: CustomEvent) {
const input = ev.currentTarget as HaRadio;
this._costs = input.value as any;
private _handleCostChanged(ev: Event) {
this._costs = (ev.currentTarget as HaRadioGroup).value as
| "no-costs"
| "number"
| "entity"
| "statistic";
}
private _numberPriceChanged(ev: InputEvent) {
@@ -352,15 +326,12 @@ export class DialogEnergyWaterSettings
display: block;
margin-bottom: var(--ha-space-4);
}
ha-formfield {
display: block;
ha-radio-group {
margin-top: var(--ha-space-4);
}
.price-options {
display: block;
padding-left: 52px;
padding-inline-start: 52px;
padding-inline-end: initial;
margin-top: -8px;
margin-top: var(--ha-space-3);
}
`,
];

View File

@@ -4,9 +4,9 @@ import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeKeys } from "../../../../common/translations/localize";
import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-formfield";
import "../../../../components/ha-radio";
import type { HaRadio } from "../../../../components/ha-radio";
import "../../../../components/radio/ha-radio-group";
import type { HaRadioGroup } from "../../../../components/radio/ha-radio-group";
import "../../../../components/radio/ha-radio-option";
import type { PowerConfig } from "../../../../data/energy";
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
@@ -145,54 +145,32 @@ export class HaEnergyPowerConfig extends LitElement {
)}
</p>
<ha-formfield
.label=${this.hass.localize(
`${this.localizeBaseKey}.type_none` as LocalizeKeys
)}
<ha-radio-group
.value=${this.powerType}
name="powerType"
@change=${this._handlePowerTypeChanged}
>
<ha-radio
value="none"
name="powerType"
.checked=${this.powerType === "none"}
@change=${this._handlePowerTypeChanged}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
`${this.localizeBaseKey}.type_standard` as LocalizeKeys
)}
>
<ha-radio
value="standard"
name="powerType"
.checked=${this.powerType === "standard"}
@change=${this._handlePowerTypeChanged}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
`${this.localizeBaseKey}.type_inverted` as LocalizeKeys
)}
>
<ha-radio
value="inverted"
name="powerType"
.checked=${this.powerType === "inverted"}
@change=${this._handlePowerTypeChanged}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
`${this.localizeBaseKey}.type_two_sensors` as LocalizeKeys
)}
>
<ha-radio
value="two_sensors"
name="powerType"
.checked=${this.powerType === "two_sensors"}
@change=${this._handlePowerTypeChanged}
></ha-radio>
</ha-formfield>
<ha-radio-option value="none">
${this.hass.localize(
`${this.localizeBaseKey}.type_none` as LocalizeKeys
)}
</ha-radio-option>
<ha-radio-option value="standard">
${this.hass.localize(
`${this.localizeBaseKey}.type_standard` as LocalizeKeys
)}
</ha-radio-option>
<ha-radio-option value="inverted">
${this.hass.localize(
`${this.localizeBaseKey}.type_inverted` as LocalizeKeys
)}
</ha-radio-option>
<ha-radio-option value="two_sensors">
${this.hass.localize(
`${this.localizeBaseKey}.type_two_sensors` as LocalizeKeys
)}
</ha-radio-option>
</ha-radio-group>
${this.powerType === "standard"
? html`
@@ -263,8 +241,7 @@ export class HaEnergyPowerConfig extends LitElement {
}
private _handlePowerTypeChanged(ev: Event) {
const input = ev.currentTarget as HaRadio;
const newPowerType = input.value as PowerType;
const newPowerType = (ev.currentTarget as HaRadioGroup).value as PowerType;
// Clear power config when switching types
fireEvent(this, "power-config-changed", {
powerType: newPowerType,
@@ -334,8 +311,8 @@ export class HaEnergyPowerConfig extends LitElement {
ha-statistic-picker:last-of-type {
margin-bottom: 0;
}
ha-formfield {
display: block;
ha-radio-group {
margin-bottom: var(--ha-space-4);
}
.power-section-label {
margin-top: var(--ha-space-4);

View File

@@ -29,7 +29,6 @@ import "../../../components/ha-icon-picker";
import "../../../components/ha-labels-picker";
import "../../../components/ha-list-item";
import "../../../components/ha-md-list-item";
import "../../../components/ha-radio";
import "../../../components/ha-select";
import type { HaSelectSelectEvent } from "../../../components/ha-select";
import "../../../components/ha-state-icon";

View File

@@ -2,11 +2,11 @@ import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-formfield";
import "../../../../components/ha-icon-picker";
import "../../../../components/ha-radio";
import type { HaRadio } from "../../../../components/ha-radio";
import "../../../../components/input/ha-input";
import "../../../../components/radio/ha-radio-group";
import type { HaRadioGroup } from "../../../../components/radio/ha-radio-group";
import "../../../../components/radio/ha-radio-option";
import type { InputDateTime } from "../../../../data/input_datetime";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
@@ -87,55 +87,37 @@ class HaInputDateTimeForm extends LitElement {
)}
.disabled=${this.disabled}
></ha-icon-picker>
<br />
${this.hass.localize("ui.dialogs.helper_settings.input_datetime.mode")}:
<br />
<ha-formfield
<ha-radio-group
.label=${this.hass.localize(
"ui.dialogs.helper_settings.input_datetime.date"
"ui.dialogs.helper_settings.input_datetime.mode"
)}
.value=${this._mode}
.disabled=${this.disabled}
name="mode"
@change=${this._modeChanged}
>
<ha-radio
name="mode"
value="date"
.checked=${this._mode === "date"}
@change=${this._modeChanged}
.disabled=${this.disabled}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
"ui.dialogs.helper_settings.input_datetime.time"
)}
>
<ha-radio
name="mode"
value="time"
.checked=${this._mode === "time"}
@change=${this._modeChanged}
.disabled=${this.disabled}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
"ui.dialogs.helper_settings.input_datetime.datetime"
)}
>
<ha-radio
name="mode"
value="datetime"
.checked=${this._mode === "datetime"}
@change=${this._modeChanged}
.disabled=${this.disabled}
></ha-radio>
</ha-formfield>
<ha-radio-option value="date">
${this.hass.localize(
"ui.dialogs.helper_settings.input_datetime.date"
)}
</ha-radio-option>
<ha-radio-option value="time">
${this.hass.localize(
"ui.dialogs.helper_settings.input_datetime.time"
)}
</ha-radio-option>
<ha-radio-option value="datetime">
${this.hass.localize(
"ui.dialogs.helper_settings.input_datetime.datetime"
)}
</ha-radio-option>
</ha-radio-group>
</div>
`;
}
private _modeChanged(ev: CustomEvent) {
const mode = (ev.target as HaRadio).value;
private _modeChanged(ev: Event) {
const mode = String((ev.currentTarget as HaRadioGroup).value);
fireEvent(this, "value-changed", {
value: {
...this._item,
@@ -179,6 +161,9 @@ class HaInputDateTimeForm extends LitElement {
ha-input {
margin: var(--ha-space-2) 0;
}
ha-radio-group {
margin-top: var(--ha-space-5);
}
`,
];
}

View File

@@ -2,10 +2,10 @@ import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-formfield";
import "../../../../components/ha-icon-picker";
import "../../../../components/ha-radio";
import type { HaRadio } from "../../../../components/ha-radio";
import "../../../../components/radio/ha-radio-group";
import type { HaRadioGroup } from "../../../../components/radio/ha-radio-group";
import "../../../../components/radio/ha-radio-option";
import "../../../../components/ha-selector/ha-selector-select";
import "../../../../components/input/ha-input";
import type { InputNumber } from "../../../../data/input_number";
@@ -137,35 +137,26 @@ class HaInputNumberForm extends LitElement {
.disabled=${this.disabled}
></ha-input>
<div class="layout horizontal center justified mode">
${this.hass.localize("ui.dialogs.helper_settings.input_number.mode")}
<ha-formfield
.label=${this.hass.localize(
<ha-radio-group
orientation="horizontal"
class="mode"
.label=${this.hass.localize(
"ui.dialogs.helper_settings.input_number.mode"
)}
.value=${this._mode}
.disabled=${this.disabled}
name="mode"
@change=${this._modeChanged}
>
<ha-radio-option value="slider">
${this.hass.localize(
"ui.dialogs.helper_settings.input_number.slider"
)}
>
<ha-radio
name="mode"
value="slider"
.checked=${this._mode === "slider"}
@change=${this._modeChanged}
.disabled=${this.disabled}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
"ui.dialogs.helper_settings.input_number.box"
)}
>
<ha-radio
name="mode"
value="box"
.checked=${this._mode === "box"}
@change=${this._modeChanged}
.disabled=${this.disabled}
></ha-radio>
</ha-formfield>
</div>
</ha-radio-option>
<ha-radio-option value="box">
${this.hass.localize("ui.dialogs.helper_settings.input_number.box")}
</ha-radio-option>
</ha-radio-group>
<ha-input
.value=${this._step !== undefined ? String(this._step) : ""}
.configValue=${"step"}
@@ -194,9 +185,12 @@ class HaInputNumberForm extends LitElement {
`;
}
private _modeChanged(ev: CustomEvent) {
private _modeChanged(ev: Event) {
fireEvent(this, "value-changed", {
value: { ...this._item, mode: (ev.target as HaRadio).value },
value: {
...this._item,
mode: (ev.currentTarget as HaRadioGroup).value,
},
});
}

View File

@@ -4,10 +4,10 @@ import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-form/ha-form";
import "../../../../components/ha-formfield";
import "../../../../components/ha-icon-picker";
import "../../../../components/ha-radio";
import type { HaRadio } from "../../../../components/ha-radio";
import "../../../../components/radio/ha-radio-group";
import type { HaRadioGroup } from "../../../../components/radio/ha-radio-group";
import "../../../../components/radio/ha-radio-option";
import "../../../../components/input/ha-input";
import type { InputText } from "../../../../data/input_text";
import { haStyle } from "../../../../resources/styles";
@@ -122,35 +122,27 @@ class HaInputTextForm extends LitElement {
"ui.dialogs.helper_settings.input_text.max"
)}
></ha-input>
<div class="layout horizontal center justified">
${this.hass.localize("ui.dialogs.helper_settings.input_text.mode")}
<ha-formfield
.label=${this.hass.localize(
<ha-radio-group
orientation="horizontal"
.label=${this.hass.localize(
"ui.dialogs.helper_settings.input_text.mode"
)}
.value=${this._mode}
.disabled=${this.disabled}
name="mode"
@change=${this._modeChanged}
>
<ha-radio-option value="text">
${this.hass.localize(
"ui.dialogs.helper_settings.input_text.text"
)}
>
<ha-radio
name="mode"
value="text"
.checked=${this._mode === "text"}
@change=${this._modeChanged}
.disabled=${this.disabled}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
</ha-radio-option>
<ha-radio-option value="password">
${this.hass.localize(
"ui.dialogs.helper_settings.input_text.password"
)}
>
<ha-radio
name="mode"
value="password"
.checked=${this._mode === "password"}
@change=${this._modeChanged}
.disabled=${this.disabled}
></ha-radio>
</ha-formfield>
</div>
</ha-radio-option>
</ha-radio-group>
<ha-input
.value=${this._pattern || ""}
.configValue=${"pattern"}
@@ -168,9 +160,12 @@ class HaInputTextForm extends LitElement {
`;
}
private _modeChanged(ev: CustomEvent) {
private _modeChanged(ev: Event) {
fireEvent(this, "value-changed", {
value: { ...this._item, mode: (ev.target as HaRadio).value },
value: {
...this._item,
mode: (ev.currentTarget as HaRadioGroup).value,
},
});
}

View File

@@ -146,6 +146,7 @@ interface HelperItem {
category: string | undefined;
area?: string;
label_entries: LabelRegistryEntry[];
labels: string[]; // search only
assistants: string[];
assistants_sortable_key: string | undefined;
disabled?: boolean;
@@ -552,6 +553,9 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
const entityRegEntry =
entityRegistryByEntityId(entityReg)[item.entity_id];
const labels = labelReg && entityRegEntry?.labels;
const label_entries = (labels || []).map(
(lbl) => labelReg!.find((label) => label.label_id === lbl)!
);
const category = entityRegEntry?.categories.helpers;
const deviceId = entityRegEntry?.device_id;
const areaId =
@@ -572,9 +576,8 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
`ui.panel.config.helpers.types.${item.type}` as LocalizeKeys
) ||
item.type,
label_entries: (labels || []).map(
(lbl) => labelReg!.find((label) => label.label_id === lbl)!
),
label_entries,
labels: label_entries.map((lbl) => lbl.name),
category: category
? categoryReg?.find((cat) => cat.category_id === category)?.name
: undefined,

View File

@@ -5,12 +5,12 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../../../../common/entity/compute_device_name";
import "../../../../../components/ha-dialog";
import "../../../../../components/ha-expansion-panel";
import "../../../../../components/ha-help-tooltip";
import "../../../../../components/ha-list";
import "../../../../../components/ha-list-item";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/ha-dialog";
import "../../../../../components/item/ha-list-item-base";
import "../../../../../components/list/ha-list-base";
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
import { subscribeDeviceRegistry } from "../../../../../data/device/device_registry";
import type {
@@ -91,107 +91,103 @@ class DialogZWaveJSNodeStatistics extends LitElement {
)}
@closed=${this._dialogClosed}
>
<ha-list noninteractive>
<ha-list-item twoline hasmeta>
<span>
<ha-list-base>
<ha-list-item-base>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.zwave_js.node_statistics.commands_tx.label"
)}</span
>
<span slot="secondary">
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.zwave_js.node_statistics.commands_tx.tooltip"
)}
</span>
<span slot="meta">${this._nodeStatistics?.commands_tx}</span>
</ha-list-item>
<ha-list-item twoline hasmeta>
<span>
<span slot="end">${this._nodeStatistics?.commands_tx}</span>
</ha-list-item-base>
<ha-list-item-base>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.zwave_js.node_statistics.commands_rx.label"
)}</span
>
<span slot="secondary">
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.zwave_js.node_statistics.commands_rx.tooltip"
)}
</span>
<span slot="meta">${this._nodeStatistics?.commands_rx}</span>
</ha-list-item>
<ha-list-item twoline hasmeta>
<span>
<span slot="end">${this._nodeStatistics?.commands_rx}</span>
</ha-list-item-base>
<ha-list-item-base>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.zwave_js.node_statistics.commands_dropped_tx.label"
)}</span
>
<span slot="secondary">
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.zwave_js.node_statistics.commands_dropped_tx.tooltip"
)}
</span>
<span slot="meta"
>${this._nodeStatistics?.commands_dropped_tx}</span
>
</ha-list-item>
<ha-list-item twoline hasmeta>
<span>
<span slot="end">${this._nodeStatistics?.commands_dropped_tx}</span>
</ha-list-item-base>
<ha-list-item-base>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.zwave_js.node_statistics.commands_dropped_rx.label"
)}</span
>
<span slot="secondary">
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.zwave_js.node_statistics.commands_dropped_rx.tooltip"
)}
</span>
<span slot="meta"
>${this._nodeStatistics?.commands_dropped_rx}</span
>
</ha-list-item>
<ha-list-item twoline hasmeta>
<span>
<span slot="end">${this._nodeStatistics?.commands_dropped_rx}</span>
</ha-list-item-base>
<ha-list-item-base>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.zwave_js.node_statistics.timeout_response.label"
)}</span
>
<span slot="secondary">
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.zwave_js.node_statistics.timeout_response.tooltip"
)}
</span>
<span slot="meta">${this._nodeStatistics?.timeout_response}</span>
</ha-list-item>
<span slot="end">${this._nodeStatistics?.timeout_response}</span>
</ha-list-item-base>
${this._nodeStatistics?.rtt
? html`<ha-list-item twoline hasmeta>
<span>
? html`<ha-list-item-base>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.zwave_js.node_statistics.rtt.label"
)}</span
>
<span slot="secondary">
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.zwave_js.node_statistics.rtt.tooltip"
)}
</span>
<span slot="meta">${this._nodeStatistics.rtt}</span>
</ha-list-item>`
<span slot="end">${this._nodeStatistics.rtt}</span>
</ha-list-item-base>`
: ``}
${this._nodeStatistics?.rssi_translated
? html`<ha-list-item twoline hasmeta>
<span>
? html`<ha-list-item-base>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.zwave_js.node_statistics.rssi.label"
)}</span
>
<span slot="secondary">
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.zwave_js.node_statistics.rssi.tooltip"
)}
</span>
<span slot="meta">${this._nodeStatistics.rssi_translated}</span>
</ha-list-item>`
<span slot="end">${this._nodeStatistics.rssi_translated}</span>
</ha-list-item-base>`
: ``}
</ha-list>
</ha-list-base>
${Object.entries(this._workingRoutes).map(([wrKey, wrValue]) =>
wrValue
? html`
@@ -450,10 +446,6 @@ class DialogZWaveJSNodeStatistics extends LitElement {
return [
haStyleDialog,
css`
ha-list-item {
height: 60px;
}
.row {
display: flex;
justify-content: space-between;

View File

@@ -5,8 +5,10 @@ import {
mdiDotsVertical,
mdiHelpCircleOutline,
mdiLabelOutline,
mdiPalette,
mdiPlus,
mdiRobot,
mdiScriptText,
mdiShape,
} from "@mdi/js";
import type { PropertyValues } from "lit";
@@ -15,7 +17,10 @@ import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { storage } from "../../../common/decorators/storage";
import { navigate } from "../../../common/navigate";
import type { LocalizeFunc } from "../../../common/translations/localize";
import type {
FlattenObjectKeys,
LocalizeFunc,
} from "../../../common/translations/localize";
import type {
DataTableColumnContainer,
RowClickedEvent,
@@ -47,7 +52,7 @@ import {
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-tabs-subpage-data-table";
import type { HomeAssistant, Route } from "../../../types";
import type { HomeAssistant, Route, TranslationDict } from "../../../types";
import {
getCreatedAtTableColumn,
getModifiedAtTableColumn,
@@ -55,6 +60,42 @@ import {
import { configSections } from "../ha-panel-config";
import { showLabelDetailDialog } from "./show-dialog-label-detail";
type ConfigTranslationKey = FlattenObjectKeys<
TranslationDict["ui"]["panel"]["config"]
>;
const NAVIGATION_ACTIONS: {
value: string;
icon: string;
translationKey: ConfigTranslationKey;
}[] = [
{
value: "navigate-entities",
icon: mdiShape,
translationKey: "entities.caption",
},
{
value: "navigate-devices",
icon: mdiDevices,
translationKey: "devices.caption",
},
{
value: "navigate-automations",
icon: mdiRobot,
translationKey: "automation.caption",
},
{
value: "navigate-scenes",
icon: mdiPalette,
translationKey: "scene.caption",
},
{
value: "navigate-scripts",
icon: mdiScriptText,
translationKey: "script.caption",
},
] as const;
@customElement("ha-config-labels")
export class HaConfigLabels extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -245,18 +286,14 @@ export class HaConfigLabels extends LitElement {
@wa-after-show=${this._overflowMenuOpened}
@wa-after-hide=${this._overflowMenuClosed}
>
<ha-dropdown-item value="navigate-entities">
<ha-svg-icon slot="icon" .path=${mdiShape}></ha-svg-icon>
${this.hass.localize("ui.panel.config.entities.caption")}
</ha-dropdown-item>
<ha-dropdown-item value="navigate-devices">
<ha-svg-icon slot="icon" .path=${mdiDevices}></ha-svg-icon>
${this.hass.localize("ui.panel.config.devices.caption")}
</ha-dropdown-item>
<ha-dropdown-item value="navigate-automations">
<ha-svg-icon slot="icon" .path=${mdiRobot}></ha-svg-icon>
${this.hass.localize("ui.panel.config.automation.caption")}
</ha-dropdown-item>
${NAVIGATION_ACTIONS.map(
(action) => html`
<ha-dropdown-item value=${action.value}>
<ha-svg-icon slot="icon" .path=${action.icon}></ha-svg-icon>
${this.hass.localize(`ui.panel.config.${action.translationKey}`)}
</ha-dropdown-item>
`
)}
<wa-divider></wa-divider>
<ha-dropdown-item variant="danger" value="remove">
<ha-svg-icon slot="icon" .path=${mdiDelete}></ha-svg-icon>
@@ -362,13 +399,19 @@ export class HaConfigLabels extends LitElement {
}
switch (action) {
case "navigate-entities":
this._navigateEntities();
this._navigateConfig("/config/entities");
break;
case "navigate-devices":
this._navigateDevices();
this._navigateConfig("/config/devices/dashboard");
break;
case "navigate-automations":
this._navigateAutomations();
this._navigateConfig("/config/automation/dashboard");
break;
case "navigate-scenes":
this._navigateConfig("/config/scene/dashboard");
break;
case "navigate-scripts":
this._navigateConfig("/config/script/dashboard");
break;
case "remove":
this._handleRemoveLabelClick();
@@ -376,22 +419,8 @@ export class HaConfigLabels extends LitElement {
}
};
private _navigateEntities = () => {
navigate(
`/config/entities?historyBack=1&label=${this._overflowLabel.label_id}`
);
};
private _navigateDevices = () => {
navigate(
`/config/devices/dashboard?historyBack=1&label=${this._overflowLabel.label_id}`
);
};
private _navigateAutomations = () => {
navigate(
`/config/automation/dashboard?historyBack=1&label=${this._overflowLabel.label_id}`
);
private _navigateConfig = (path: string) => {
navigate(`${path}?historyBack=1&label=${this._overflowLabel.label_id}`);
};
private _handleSortingChanged(ev: CustomEvent) {

View File

@@ -764,19 +764,12 @@ class ErrorLogCard extends LitElement {
padding-top: 16px;
padding-bottom: 16px;
overflow: auto;
min-height: var(--error-log-card-height, calc(100vh - 244px));
max-height: var(--error-log-card-height, calc(100vh - 244px));
min-height: var(--error-log-card-height, calc(100vh - 255px));
max-height: var(--error-log-card-height, calc(100vh - 255px));
border-top: 1px solid var(--divider-color);
direction: ltr;
}
@media all and (max-width: 870px) {
.error-log {
min-height: var(--error-log-card-height, calc(100vh - 190px));
max-height: var(--error-log-card-height, calc(100vh - 190px));
}
}
.error-log > div {
overflow: auto;
overflow-wrap: break-word;

View File

@@ -100,28 +100,16 @@ export class HaConfigLogs extends LitElement {
}
protected render(): TemplateResult {
const search = this.narrow
? html`
<div slot="header">
<ha-input-search
appearance="outlined"
class="header"
@input=${this._filterChanged}
.value=${this._filter}
.placeholder=${this.hass.localize("ui.panel.config.logs.search")}
></ha-input-search>
</div>
`
: html`
<div class="search">
<ha-input-search
appearance="outlined"
@input=${this._filterChanged}
.value=${this._filter}
.placeholder=${this.hass.localize("ui.panel.config.logs.search")}
></ha-input-search>
</div>
`;
const search = html`
<div class="search">
<ha-input-search
appearance="outlined"
@input=${this._filterChanged}
.value=${this._filter}
.placeholder=${this.hass.localize("ui.panel.config.logs.search")}
></ha-input-search>
</div>
`;
const selectedProvider = this._getActiveProvider(this._selectedLogProvider);
@@ -348,22 +336,17 @@ export class HaConfigLogs extends LitElement {
top: 0;
z-index: 2;
}
ha-input-search {
.search ha-input-search {
padding: var(--ha-space-3);
background: var(--sidebar-background-color);
border-bottom: 1px solid var(--divider-color);
}
ha-input-search.header {
padding-inline-start: 0;
background: transparent;
border: none;
}
.content {
direction: ltr;
}
ha-generic-picker {
--md-list-item-leading-icon-color: var(--ha-color-primary-50);
--mdc-icon-size: 32px;
--mdc-icon-size: var(--ha-space-6);
}
img {
@@ -372,7 +355,7 @@ export class HaConfigLogs extends LitElement {
@media all and (max-width: 870px) {
ha-generic-picker {
max-width: max(30%, 160px);
max-width: max(30%, 180px);
}
ha-button {
max-width: 100%;

View File

@@ -6,7 +6,6 @@ import "../../../components/ha-card";
import "../../../components/ha-button";
import "../../../components/ha-expansion-panel";
import "../../../components/ha-icon-button";
import "../../../components/ha-radio";
import "../../../components/ha-settings-row";
import "../../../components/input/ha-input";
import { extractApiErrorMessage } from "../../../data/hassio/common";

View File

@@ -9,12 +9,12 @@ import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-expansion-panel";
import "../../../components/ha-formfield";
import "../../../components/ha-icon-button";
import "../../../components/ha-list";
import "../../../components/ha-list-item";
import "../../../components/ha-radio";
import type { HaRadio } from "../../../components/ha-radio";
import "../../../components/radio/ha-radio-group";
import type { HaRadioGroup } from "../../../components/radio/ha-radio-group";
import "../../../components/radio/ha-radio-option";
import "../../../components/ha-spinner";
import "../../../components/ha-tab-group";
import "../../../components/ha-tab-group-tab";
@@ -182,53 +182,28 @@ export class HassioNetwork extends LitElement {
: nothing}
${this._wifiConfiguration
? html`
<div class="radio-row">
<ha-formfield
.label=${this.hass.localize(
<ha-radio-group
orientation="horizontal"
.value=${this._wifiConfiguration.auth || "open"}
name="auth"
@change=${this._handleRadioValueChangedAp}
>
<ha-radio-option value="open">
${this.hass.localize(
"ui.panel.config.network.supervisor.open"
)}
>
<ha-radio
@change=${this._handleRadioValueChangedAp}
.ap=${this._wifiConfiguration}
value="open"
name="auth"
.checked=${this._wifiConfiguration.auth ===
undefined ||
this._wifiConfiguration.auth === "open"}
>
</ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
</ha-radio-option>
<ha-radio-option value="wep">
${this.hass.localize(
"ui.panel.config.network.supervisor.wep"
)}
>
<ha-radio
@change=${this._handleRadioValueChangedAp}
.ap=${this._wifiConfiguration}
value="wep"
name="auth"
.checked=${this._wifiConfiguration.auth === "wep"}
>
</ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
</ha-radio-option>
<ha-radio-option value="wpa-psk">
${this.hass.localize(
"ui.panel.config.network.supervisor.wpa"
)}
>
<ha-radio
@change=${this._handleRadioValueChangedAp}
.ap=${this._wifiConfiguration}
value="wpa-psk"
name="auth"
.checked=${this._wifiConfiguration.auth ===
"wpa-psk"}
>
</ha-radio>
</ha-formfield>
</div>
</ha-radio-option>
</ha-radio-group>
${this._wifiConfiguration.auth === "wpa-psk" ||
this._wifiConfiguration.auth === "wep"
? html`
@@ -337,51 +312,23 @@ export class HassioNetwork extends LitElement {
.header=${`IPv${version.charAt(version.length - 1)}`}
outlined
>
<div class="radio-row">
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.network.supervisor.auto"
)}
>
<ha-radio
@change=${this._handleRadioValueChanged}
.version=${version}
value="auto"
name="${version}method"
.checked=${this._interface![version]?.method === "auto"}
>
</ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.network.supervisor.static"
)}
>
<ha-radio
@change=${this._handleRadioValueChanged}
.version=${version}
value="static"
name="${version}method"
.checked=${this._interface![version]?.method === "static"}
>
</ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.network.supervisor.disabled"
)}
class="warning"
>
<ha-radio
@change=${this._handleRadioValueChanged}
.version=${version}
value="disabled"
name="${version}method"
.checked=${this._interface![version]?.method === "disabled"}
>
</ha-radio>
</ha-formfield>
</div>
<ha-radio-group
orientation="horizontal"
.version=${version}
.value=${this._interface![version]?.method}
name="${version}method"
@change=${this._handleRadioValueChanged}
>
<ha-radio-option value="auto">
${this.hass.localize("ui.panel.config.network.supervisor.auto")}
</ha-radio-option>
<ha-radio-option value="static">
${this.hass.localize("ui.panel.config.network.supervisor.static")}
</ha-radio-option>
<ha-radio-option value="disabled">
${this.hass.localize("ui.panel.config.network.supervisor.disabled")}
</ha-radio-option>
</ha-radio-group>
${["static", "auto"].includes(this._interface![version].method)
? html`
${this._interface![version].address.map(
@@ -653,9 +600,9 @@ export class HassioNetwork extends LitElement {
}
private _handleRadioValueChanged(ev: Event): void {
const source = ev.target as HaRadio;
const source = ev.currentTarget as HaRadioGroup;
const value = source.value as "disabled" | "auto" | "static";
const version = (ev.target as any).version as "ipv4" | "ipv6";
const version = (source as any).version as "ipv4" | "ipv6";
if (
!value ||
@@ -671,8 +618,8 @@ export class HassioNetwork extends LitElement {
}
private _handleRadioValueChangedAp(ev: Event): void {
const source = ev.target as HaRadio;
const value = source.value as string as "open" | "wep" | "wpa-psk";
const source = ev.currentTarget as HaRadioGroup;
const value = source.value as "open" | "wep" | "wpa-psk";
this._wifiConfiguration!.auth = value;
this._dirty = true;
this.requestUpdate("_wifiConfiguration");

View File

@@ -1,10 +1,10 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { STRINGS_SEPARATOR_DOT } from "../../../common/const";
import { relativeTime } from "../../../common/datetime/relative_time";
import { capitalizeFirstLetter } from "../../../common/string/capitalize-first-letter";
import { STRINGS_SEPARATOR_DOT } from "../../../common/const";
import "../../../components/ha-md-list";
import "../../../components/ha-md-list-item";
import "../../../components/item/ha-list-item-button";
import "../../../components/list/ha-list-base";
import { domainToName } from "../../../data/integration";
import type { StatisticsValidationResult } from "../../../data/recorder";
import {
@@ -19,9 +19,9 @@ import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-c
import type { HomeAssistant } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import { fixStatisticsIssue } from "../developer-tools/statistics/fix-statistics";
import { showVacuumSegmentMappingDialog } from "../entities/dialogs/show-dialog-vacuum-segment-mapping";
import { showRepairsFlowDialog } from "./show-dialog-repair-flow";
import { showRepairsIssueDialog } from "./show-repair-issue-dialog";
import { showVacuumSegmentMappingDialog } from "../entities/dialogs/show-dialog-vacuum-segment-mapping";
@customElement("ha-config-repairs")
class HaConfigRepairs extends LitElement {
@@ -40,7 +40,9 @@ class HaConfigRepairs extends LitElement {
const issues = this.repairsIssues;
return html`
<ha-md-list>
<ha-list-base
aria-label=${this.hass.localize("ui.panel.config.repairs.caption")}
>
${issues.map((issue) => {
const domainName = domainToName(this.hass.localize, issue.domain);
@@ -55,12 +57,11 @@ class HaConfigRepairs extends LitElement {
: "";
return html`
<ha-md-list-item
<ha-list-item-button
.hasMeta=${!this.narrow}
.issue=${issue}
class=${issue.ignored ? "ignored" : ""}
@click=${this._openShowMoreDialog}
type="button"
>
<img
slot="start"
@@ -92,12 +93,12 @@ class HaConfigRepairs extends LitElement {
`ui.panel.config.repairs.${issue.severity}`
)}</span
>`
: ""}
: nothing}
${(issue.severity === "critical" ||
issue.severity === "error") &&
issue.created
? STRINGS_SEPARATOR_DOT
: ""}
: nothing}
${createdBy
? html`<span .title=${createdBy}>${createdBy}</span>`
: nothing}
@@ -106,15 +107,15 @@ class HaConfigRepairs extends LitElement {
"ui.panel.config.repairs.dialog.ignored_in_version_short",
{ version: issue.dismissed_version }
)}`
: ""}
: nothing}
</span>
${!this.narrow
? html`<ha-icon-next slot="end"></ha-icon-next>`
: ""}
</ha-md-list-item>
: nothing}
</ha-list-item-button>
`;
})}
</ha-md-list>
</ha-list-base>
`;
}
@@ -202,11 +203,14 @@ class HaConfigRepairs extends LitElement {
outline: none;
text-decoration: underline;
}
ha-md-list-item img[slot="start"] {
ha-list-item-button img[slot="start"] {
width: 40px;
height: 40px;
}
ha-md-list-item span[slot="supporting-text"] {
ha-list-item-button ha-icon-next[slot="end"] {
color: var(--secondary-text-color);
}
ha-list-item-button span[slot="supporting-text"] {
white-space: nowrap;
}
.error {

View File

@@ -122,6 +122,7 @@ type SceneItem = SceneEntity & {
area: string | undefined;
category: string | undefined;
label_entries: LabelRegistryEntry[];
labels: string[]; // search only
assistants: string[];
assistants_sortable_key: string | undefined;
editable: boolean;
@@ -239,6 +240,9 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
);
const category = entityRegEntry?.categories.scene;
const labels = labelReg && entityRegEntry?.labels;
const label_entries = (labels || []).map(
(lbl) => labelReg!.find((label) => label.label_id === lbl)!
);
const assistants = getEntityVoiceAssistantsIds(
entityReg,
scene.entity_id
@@ -252,9 +256,8 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
category: category
? categoryReg?.find((cat) => cat.category_id === category)?.name
: undefined,
label_entries: (labels || []).map(
(lbl) => labelReg!.find((label) => label.label_id === lbl)!
),
label_entries,
labels: label_entries.map((lbl) => lbl.name),
assistants,
assistants_sortable_key: getAssistantsSortableKey(assistants),
selectable: entityRegEntry !== undefined,

View File

@@ -329,7 +329,6 @@ export class HaSceneEditor extends PreventUnsavedMixin(
.defaultValue=${this._config}
@value-changed=${this._yamlChanged}
@editor-save=${this._saveScene}
.showErrors=${false}
disable-fullscreen
></ha-yaml-editor>`;
}

View File

@@ -464,7 +464,6 @@ export class HaScriptEditor extends SubscribeMixin(
disable-fullscreen
@value-changed=${this._yamlChanged}
@editor-save=${this._handleSaveScript}
.showErrors=${false}
></ha-yaml-editor>
<ha-button
slot="fab"

View File

@@ -127,6 +127,7 @@ type ScriptItem = ScriptEntity & {
last_triggered: string | undefined;
category: string | undefined;
label_entries: LabelRegistryEntry[];
labels: string[]; // search only
assistants: string[];
assistants_sortable_key: string | undefined;
};
@@ -245,6 +246,9 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
);
const category = entityRegEntry?.categories.script;
const labels = labelReg && entityRegEntry?.labels;
const label_entries = (labels || []).map(
(lbl) => labelReg!.find((label) => label.label_id === lbl)!
);
const assistants = getEntityVoiceAssistantsIds(
entityReg,
script.entity_id
@@ -259,9 +263,8 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
category: category
? categoryReg?.find((cat) => cat.category_id === category)?.name
: undefined,
label_entries: (labels || []).map(
(lbl) => labelReg!.find((label) => label.label_id === lbl)!
),
label_entries,
labels: label_entries.map((lbl) => lbl.name),
assistants,
assistants_sortable_key: getAssistantsSortableKey(assistants),
selectable: entityRegEntry !== undefined,

View File

@@ -1,13 +1,17 @@
import { mdiContentCopy } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { copyToClipboard } from "../../../common/util/copy-clipboard";
import { generateUuidV4 } from "../../../common/util/uuid";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-dialog";
import "../../../components/ha-dialog-footer";
import "../../../components/ha-expansion-panel";
import "../../../components/ha-icon-button";
import "../../../components/ha-qr-code";
import "../../../components/ha-switch";
import "../../../components/input/ha-input";
import type { HaInput } from "../../../components/input/ha-input";
import type { Tag, UpdateTagParams } from "../../../data/tag";
@@ -15,6 +19,7 @@ import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { showToast } from "../../../util/toast";
import type { TagDetailDialogParams } from "./show-dialog-tag-detail";
@customElement("dialog-tag-detail")
@@ -28,6 +33,8 @@ class DialogTagDetail
@state() private _name!: string;
@state() private _useCustomId = false;
@state() private _error?: string;
@state() private _params?: TagDetailDialogParams;
@@ -42,6 +49,7 @@ class DialogTagDetail
this._params = params;
this._error = undefined;
this._open = true;
this._useCustomId = false;
if (this._params.entry) {
this._name = this._params.entry.name || "";
} else {
@@ -76,7 +84,7 @@ class DialogTagDetail
.hass=${this.hass}
.open=${this._open}
header-title=${this._params.entry
? this._params.entry.name || this._params.entry.id
? this.hass!.localize("ui.panel.config.tag.detail.tag_details")
: this.hass!.localize("ui.panel.config.tag.detail.new_tag")}
prevent-scrim-close
@closed=${this._dialogClosed}
@@ -97,18 +105,35 @@ class DialogTagDetail
)}
required
></ha-input>
<ha-input
.value=${this._params.entry
? this._params.entry.id
: this._id || ""}
.readonly=${!!this._params.entry}
.configValue=${"id"}
@input=${this._valueChanged}
.label=${this.hass!.localize("ui.panel.config.tag.detail.tag_id")}
.placeholder=${this.hass!.localize(
"ui.panel.config.tag.detail.tag_id_placeholder"
)}
></ha-input>
${this._params.entry
? nothing
: html`
<ha-expansion-panel
outlined
.header=${this.hass!.localize(
"ui.panel.config.tag.detail.use_custom_id"
)}
.expanded=${this._useCustomId}
@expanded-changed=${this._useCustomIdChanged}
>
<ha-input
.value=${this._id || ""}
.configValue=${"id"}
@input=${this._valueChanged}
.label=${this.hass!.localize(
"ui.panel.config.tag.detail.tag_id"
)}
.placeholder=${this.hass!.localize(
"ui.panel.config.tag.detail.tag_id_placeholder"
)}
></ha-input>
<ha-alert alert-type="info">
${this.hass!.localize(
"ui.panel.config.tag.detail.custom_id_warning"
)}
</ha-alert>
</ha-expansion-panel>
`}
</div>
${this._params.entry
? html`
@@ -139,6 +164,17 @@ class DialogTagDetail
`
: nothing}
</div>
<div class="tag-id">
<span class="tag-id-label">
${this.hass!.localize("ui.panel.config.tag.detail.tag_id")}:
</span>
<span class="tag-id-value">${this._params.entry.id}</span>
<ha-icon-button
.path=${mdiContentCopy}
.label=${this.hass!.localize("ui.common.copy")}
@click=${this._copyId}
></ha-icon-button>
</div>
`
: ``}
</div>
@@ -189,6 +225,27 @@ class DialogTagDetail
this[`_${configValue}`] = target.value;
}
private _useCustomIdChanged(ev: CustomEvent) {
this._useCustomId = ev.detail.expanded;
if (this._useCustomId) {
if (!this._id) {
this._id = generateUuidV4();
}
} else {
this._id = "";
}
}
private async _copyId() {
if (!this._params?.entry) {
return;
}
await copyToClipboard(this._params.entry.id);
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
});
}
private async _updateEntry() {
this._submitting = true;
let newValue: Tag | undefined;
@@ -199,7 +256,10 @@ class DialogTagDetail
if (this._params!.entry) {
newValue = await this._params!.updateEntry!(values);
} else {
newValue = await this._params!.createEntry(values, this._id);
newValue = await this._params!.createEntry(
values,
this._useCustomId ? this._id : ""
);
}
this.closeDialog();
} catch (err: any) {
@@ -246,6 +306,29 @@ class DialogTagDetail
ha-input:not([required]) {
margin-bottom: var(--ha-space-5);
}
ha-expansion-panel {
display: block;
margin-bottom: var(--ha-space-2);
}
ha-expansion-panel[expanded] {
--expansion-panel-content-padding: var(--ha-space-3) var(--ha-space-2);
}
ha-alert {
display: block;
margin-top: var(--ha-space-2);
}
.tag-id {
display: flex;
align-items: center;
justify-content: center;
gap: var(--ha-space-1);
margin-top: var(--ha-space-3);
color: var(--secondary-text-color);
}
.tag-id-value {
font-family: var(--ha-font-family-code);
color: var(--primary-text-color);
}
::slotted(img) {
height: 100%;
}

View File

@@ -90,9 +90,9 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
},
id: {
title: localize("ui.panel.config.tag.headers.tag_id"),
main: true,
sortable: true,
filterable: true,
defaultHidden: true,
},
last_scanned_datetime: {
title: localize("ui.panel.config.tag.headers.last_scanned"),

View File

@@ -48,6 +48,8 @@ class PanelHome extends LitElement {
@state() private _extraActionItems?: ExtraActionItem[];
private _loadConfigPromise?: Promise<void>;
private get _showBanner(): boolean {
// Don't show if already dismissed
if (this._config.welcome_banner_dismissed) {
@@ -121,6 +123,12 @@ class PanelHome extends LitElement {
private async _setup() {
this._updateExtraActionItems();
this._loadConfigPromise = this._loadConfig();
await this._loadConfigPromise;
this._setLovelace();
}
private async _loadConfig() {
try {
const [_, data] = await Promise.all([
this.hass.loadFragmentTranslation("lovelace"),
@@ -132,7 +140,6 @@ class PanelHome extends LitElement {
console.error("Failed to load favorites:", err);
this._config = {};
}
this._setLovelace();
}
private _debounceRegistriesChanged = debounce(
@@ -313,6 +320,9 @@ class PanelHome extends LitElement {
}
private async _setLovelace() {
if (this._loadConfigPromise) {
await this._loadConfigPromise;
}
const strategyConfig: LovelaceDashboardStrategyConfig = {
strategy: {
type: "home",

View File

@@ -8,6 +8,8 @@ import {
mdiSkipNext,
mdiSkipPrevious,
mdiStop,
mdiVolumeMinus,
mdiVolumePlus,
} from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -52,6 +54,8 @@ const MEDIA_PLAYER_PLAYBACK_CONTROLS_FEATURES: Record<
media_stop: [MediaPlayerEntityFeature.STOP],
media_previous_track: [MediaPlayerEntityFeature.PREVIOUS_TRACK],
media_next_track: [MediaPlayerEntityFeature.NEXT_TRACK],
volume_down: [MediaPlayerEntityFeature.VOLUME_STEP],
volume_up: [MediaPlayerEntityFeature.VOLUME_STEP],
};
export const supportsMediaPlayerPlaybackControl = (
@@ -266,6 +270,16 @@ class HuiMediaPlayerPlaybackCardFeature
buttons.push({ icon: mdiSkipNext, action: "media_next_track" });
}
break;
case "volume_down":
if (supportsFeature(stateObj, MediaPlayerEntityFeature.VOLUME_STEP)) {
buttons.push({ icon: mdiVolumeMinus, action: "volume_down" });
}
break;
case "volume_up":
if (supportsFeature(stateObj, MediaPlayerEntityFeature.VOLUME_STEP)) {
buttons.push({ icon: mdiVolumePlus, action: "volume_up" });
}
break;
}
}

View File

@@ -5,7 +5,6 @@ import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { computeDomain } from "../../../common/entity/compute_domain";
import { isNumericFromAttributes } from "../../../common/number/format_number";
import "../../../components/ha-spinner";
import {
limitedHistoryFromStateObj,
subscribeHistoryStatesTimeWindow,
@@ -49,6 +48,8 @@ class HuiHistoryChartCardFeature
@state() private _yAxisOrigin?: number;
@state() private _loading = true;
@state() private _error?: { code: string; message: string };
private _subscribed?: Promise<UnsubscribeFunc | undefined>;
@@ -90,9 +91,29 @@ class HuiHistoryChartCardFeature
}
protected firstUpdated() {
this._setLoadingCoordinates();
this._subscribeHistory();
}
private _setLoadingCoordinates() {
const entityId = this.context?.entity_id;
if (!entityId || !this.hass) {
return;
}
const stateObj = this.hass.states[entityId];
if (!stateObj) {
return;
}
const { points, yAxisOrigin } = coordinatesMinimalResponseCompressedState(
limitedHistoryFromStateObj(stateObj),
this.clientWidth,
this.clientHeight,
10
);
this._coordinates = points;
this._yAxisOrigin = yAxisOrigin;
}
protected render() {
if (
!this._config ||
@@ -109,14 +130,7 @@ class HuiHistoryChartCardFeature
</div>
`;
}
if (!this._coordinates) {
return html`
<div class="container loading">
<ha-spinner size="small"></ha-spinner>
</div>
`;
}
if (!this._coordinates.length) {
if (this._coordinates && !this._coordinates.length) {
return html`
<div class="container">
<div class="info">No state history found.</div>
@@ -125,6 +139,7 @@ class HuiHistoryChartCardFeature
}
return html`
<hui-graph-base
?loading=${this._loading}
.coordinates=${this._coordinates}
.yAxisOrigin=${this._yAxisOrigin}
></hui-graph-base>
@@ -197,6 +212,7 @@ class HuiHistoryChartCardFeature
);
this._coordinates = points;
this._yAxisOrigin = yAxisOrigin;
this._loading = false;
},
hourToShow,
[this.context!.entity_id!]
@@ -218,13 +234,6 @@ class HuiHistoryChartCardFeature
pointer-events: none !important;
}
.container.loading {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
hui-graph-base {
width: 100%;
--accent-color: var(--feature-color);

View File

@@ -63,6 +63,8 @@ export const MEDIA_PLAYER_PLAYBACK_CONTROLS = [
"media_stop",
"media_previous_track",
"media_next_track",
"volume_down",
"volume_up",
] as const;
export type MediaPlayerPlaybackControl =

View File

@@ -2,6 +2,7 @@ import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { theme2hex } from "../../../common/color/convert-color";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { createSearchParam } from "../../../common/url/search-params";
import "../../../components/chart/state-history-charts";
@@ -21,9 +22,8 @@ import { getSensorNumericDeviceClasses } from "../../../data/sensor";
import type { HomeAssistant } from "../../../types";
import { hasConfigOrEntitiesChanged } from "../common/has-changed";
import { processConfigEntities } from "../common/process-config-entities";
import type { EntityConfig } from "../entity-rows/types";
import type { LovelaceCard, LovelaceGridOptions } from "../types";
import type { HistoryGraphCardConfig } from "./types";
import type { GraphEntityConfig, HistoryGraphCardConfig } from "./types";
export const DEFAULT_HOURS_TO_SHOW = 24;
@@ -51,9 +51,11 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
private _names: Record<string, string> = {};
private _colors: Record<string, string | undefined> = {};
private _entityIds: string[] = [];
private _entities: EntityConfig[] = [];
private _entities: GraphEntityConfig[] = [];
private _historyLinkId = `history-${Math.random().toString(36).substring(2, 9)}`;
@@ -95,6 +97,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
this._config = config;
this._computeNames();
this._computeColors();
}
private _computeNames() {
@@ -110,6 +113,19 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
});
}
private _computeColors() {
if (!this._config) {
return;
}
this._colors = {};
this._entities.forEach((entity) => {
// if color = undefined, it is automatically defined inside a chart component
this._colors[entity.entity] = entity.color
? theme2hex(entity.color)
: undefined;
});
}
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (changedProps.has("hass")) {
@@ -371,6 +387,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
.minYAxis=${this._config.min_y_axis}
.maxYAxis=${this._config.max_y_axis}
.fitYData=${this._config.fit_y_data || false}
.colors=${this._colors}
.height=${hasFixedHeight ? "100%" : undefined}
.narrow=${narrow}
.expandLegend=${this._config.expand_legend}
@@ -402,6 +419,12 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
flex: 1;
overflow: hidden;
}
.content:has(state-history-charts) {
overflow: visible;
}
.content.has-height:has(state-history-charts) {
overflow: hidden;
}
.has-header {
padding-top: 0;
}

View File

@@ -12,7 +12,7 @@ import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import type { HomeAssistant } from "../../../types";
import { actionHandler } from "../common/directives/action-handler-directive";
import { handleAction } from "../common/handle-action";
import { hasAction } from "../common/has-action";
import { hasAction, hasAnyAction } from "../common/has-action";
import { hasConfigChanged } from "../common/has-changed";
import { createEntityNotFoundWarning } from "../components/hui-warning";
import type { LovelaceCard, LovelaceCardEditor } from "../types";
@@ -52,10 +52,17 @@ export class HuiPictureCard extends LitElement implements LovelaceCard {
throw new Error("Image required");
}
this._config = {
tap_action: { action: "more-info" },
...config,
};
if (config.image_entity) {
this._config = {
tap_action: { action: "more-info" },
...config,
};
} else {
this._config = {
tap_action: { action: "none" },
...config,
};
}
}
protected shouldUpdate(changedProps: PropertyValues): boolean {
@@ -167,6 +174,11 @@ export class HuiPictureCard extends LitElement implements LovelaceCard {
return nothing;
}
const clickable = Boolean(
(this._config.image_entity && !this._config.tap_action) ||
hasAnyAction(this._config)
);
return html`
<ha-card
@action=${this._handleAction}
@@ -180,15 +192,7 @@ export class HuiPictureCard extends LitElement implements LovelaceCard {
: undefined
)}
class=${classMap({
clickable: Boolean(
(this._config.image_entity && !this._config.tap_action) ||
(this._config.tap_action &&
this._config.tap_action.action !== "none") ||
(this._config.hold_action &&
this._config.hold_action.action !== "none") ||
(this._config.double_tap_action &&
this._config.double_tap_action.action !== "none")
),
clickable,
})}
>
<img

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