Compare commits

..

29 Commits

Author SHA1 Message Date
Bram Kragten c4f4cbd323 Bumped version to 20260429.3 2026-05-06 11:18:01 +02:00
Paul Bottein 2e0df00f0f Fix name for battery entities without device (#51879) 2026-05-06 11:17:09 +02:00
Wendelin ce02f8072d Reduce progress bar default height (#51878)
reduce progress bar default height to 12px
2026-05-06 11:17:08 +02:00
Paul Bottein c973aa7516 Fix media controls in media player more info dialog (#51877) 2026-05-06 11:17:07 +02:00
Paul Bottein 1e2328707c Fix switch clipping in view visibility editor (#51876) 2026-05-06 11:17:06 +02:00
Wendelin 56368b88cd Remove duplicate definition in semantic colors (#51875)
* Remove duplicate definition in semantic colors

* rearrange surface tokens
2026-05-06 11:17:05 +02:00
Aidan Timson fcd4f177c1 Fix Safari 14 legacy bundle require errors (#51868)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 11:17:04 +02:00
Wendelin 7423ae7316 Fix integration search shrink on mobile (#51867) 2026-05-06 11:17:03 +02:00
Marcin Bauer 4427c581f1 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 11:17:02 +02:00
Paul Bottein cf86bb9821 Use ha-switch instead of ha-control-switch in entity toggle (#51852) 2026-05-06 11:17:01 +02:00
karwosts 897802dc16 Change display for uptime sensors (#51830) 2026-05-06 11:17:00 +02:00
Paul Bottein dd65173c5a Bumped version to 20260429.2 2026-05-04 17:04:06 +02:00
Paul Bottein cf26753f7d Remove daily and hourly forecast card features (#51854) 2026-05-04 17:03:54 +02:00
Paul Bottein d6ab8ffb16 Resolve service name and icon for shortcut card and badge (#51850) 2026-05-04 17:03:53 +02:00
Wendelin 2dc4b16eac Fix automation row target width (#51848) 2026-05-04 17:03:52 +02:00
Paul Bottein 1eba765bc2 Group areas floor vacuum clean (#51847) 2026-05-04 17:03:51 +02:00
Wendelin 398479ddd7 Use ha-switch in ha-automation-picker (#51846)
use ha-switch in ha-automation-picker
2026-05-04 17:03:50 +02:00
Paul Bottein c4fd7bb3e1 Fix entity toggle switch size (#51845) 2026-05-04 17:03:49 +02:00
Isaac (Kwangjin Ko) 4cfc67a95e ha-humidifier-state: fix incorrect translation key for 'Currently' (#51843) 2026-05-04 17:03:48 +02:00
Brooke Hatton e38d1964ca Remove battery chargers from maintenance dashboard (#51835) 2026-05-04 17:03:47 +02:00
Paul Bottein ec8b5c77bd Add min touch size for control switch (#51826) 2026-05-04 17:03:46 +02:00
Simon Lamon 425f2775e2 Missing toggle in switch group (#51825)
Missing toggle
2026-05-04 17:03:45 +02:00
Brooke Hatton 3a3d8191a3 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-05-04 17:03:44 +02:00
Aidan Timson 04fca68549 Add gap between hui editors and previews on mobile (#51811) 2026-05-04 17:03:43 +02:00
Paul Bottein 35601a0900 Bumped version to 20260429.1 2026-04-30 20:32:28 +02:00
Wendelin e7016c15af Fix ha-select undefined value (#51800)
Fix ha-select undefined

Co-authored-by: Copilot <copilot@github.com>
2026-04-30 20:32:08 +02:00
Wendelin 624521e30b Hide tooltip on mobile clients in ha-sidebar component (#51799) 2026-04-30 20:32:08 +02:00
Bram Kragten 4876bfa639 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 20:32:07 +02:00
AlCalzone 5dea0764b2 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 20:32:05 +02:00
364 changed files with 4009 additions and 8468 deletions
+3 -3
View File
@@ -41,14 +41,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
with:
languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
uses: github/codeql-action/autobuild@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -62,4 +62,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
+1 -1
View File
@@ -18,6 +18,6 @@ jobs:
pull-requests: read
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@563bf132657a13ded0b01fcb723c5a58cdd824e2 # v7.2.1
- uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-2
View File
@@ -1,4 +1,3 @@
/* global process */
// Tasks to generate entry HTML
import {
@@ -26,7 +25,6 @@ const SAFARI_TO_MACOS = {
16: [11, 0, 0],
17: [12, 0, 0],
18: [13, 0, 0],
26: [26, 0, 0],
};
const getCommonTemplateVars = () => {
+18 -25
View File
@@ -1,7 +1,6 @@
import {
addDays,
addHours,
addMinutes,
addMonths,
differenceInHours,
endOfDay,
@@ -13,19 +12,6 @@ import type {
} from "../../../src/data/recorder";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const getNextDate = (
currentDate: Date,
period: "5minute" | "hour" | "day" | "month"
): Date => {
return period === "day"
? addDays(currentDate, 1)
: period === "month"
? addMonths(currentDate, 1)
: period === "hour"
? addHours(currentDate, 1)
: addMinutes(currentDate, 5);
};
const generateMeanStatistics = (
start: Date,
end: Date,
@@ -39,10 +25,9 @@ const generateMeanStatistics = (
while (end > currentDate && currentDate < now) {
const delta = Math.random() * maxDiff;
const mean = delta;
const nextDate = getNextDate(currentDate, period);
statistics.push({
start: currentDate.getTime(),
end: nextDate.getTime(),
end: currentDate.getTime(),
mean,
min: mean - Math.random() * maxDiff,
max: mean + Math.random() * maxDiff,
@@ -50,7 +35,12 @@ const generateMeanStatistics = (
state: mean,
sum: null,
});
currentDate = nextDate;
currentDate =
period === "day"
? addDays(currentDate, 1)
: period === "month"
? addMonths(currentDate, 1)
: addHours(currentDate, 1);
}
return statistics;
};
@@ -68,12 +58,11 @@ const generateSumStatistics = (
let sum = initValue;
const now = new Date();
while (end > currentDate && currentDate < now) {
const nextDate = getNextDate(currentDate, period);
const add = Math.random() * maxDiff;
sum += add;
statistics.push({
start: currentDate.getTime(),
end: nextDate.getTime(),
end: currentDate.getTime(),
mean: null,
min: null,
max: null,
@@ -82,7 +71,12 @@ const generateSumStatistics = (
state: initValue + sum,
sum,
});
currentDate = nextDate;
currentDate =
period === "day"
? addDays(currentDate, 1)
: period === "month"
? addMonths(currentDate, 1)
: addHours(currentDate, 1);
}
return statistics;
};
@@ -90,7 +84,7 @@ const generateSumStatistics = (
const generateCurvedStatistics = (
start: Date,
end: Date,
period: "5minute" | "hour" | "day" | "month" = "hour",
_period: "5minute" | "hour" | "day" | "month" = "hour",
initValue: number,
maxDiff: number,
metered: boolean
@@ -104,12 +98,11 @@ const generateCurvedStatistics = (
let half = false;
const now = new Date();
while (end > currentDate && currentDate < now) {
const nextDate = getNextDate(currentDate, period);
const add = i * (Math.random() * maxDiff);
sum += add;
statistics.push({
start: currentDate.getTime(),
end: nextDate.getTime(),
end: currentDate.getTime(),
mean: null,
min: null,
max: null,
@@ -118,7 +111,7 @@ const generateCurvedStatistics = (
state: initValue + sum,
sum: metered ? sum : null,
});
currentDate = nextDate;
currentDate = addHours(currentDate, 1);
if (!half && i > hours / 2) {
half = true;
}
@@ -296,7 +289,7 @@ const statisticsFunctions: Record<
end,
period,
productionFinalVal,
0.2
2
);
return [...morning, ...production, ...evening, ...rest];
},
@@ -1,188 +0,0 @@
---
title: List
---
# List
The list family provides accessible, keyboard-navigable list containers and
item variants. Pick the container based on semantics, then the item based on
interactivity.
## Containers
### `<ha-list-base>`
A styled container with roving-tabindex keyboard navigation. Host role is
`list`. Children should be `<ha-list-item-*>`. Arrow keys rove focus;
Home/End jump to the first/last enabled item; Enter/Space activates the
focused item.
**Attributes**
| Name | Type | Default | Description |
| ------------ | ------- | ------- | ------------------------------ |
| `wrap-focus` | Boolean | `false` | Arrow keys wrap past the ends. |
| `aria-label` | String | — | Accessible name. |
**Events**
- `ha-list-activated` — Enter/Space on a focused item. Detail
`{ index: number, item: HaListItemBase }`.
**Methods**
- `focus()` — focus the active item (or the first focusable one).
- `focusItemAtIndex(index)` — make the item at `index` active and focus it.
- `getActiveItemIndex()` — current active index, or `-1`.
- `setActiveItemIndex(index, focusItem?)` — move the active index without
necessarily focusing.
- `updateListItems()` — re-discover slotted items (called automatically on
slotchange).
**CSS parts**
- `base` — the outer `<div role="list">`.
**CSS custom properties**
- `--ha-list-gap` — spacing between items. Defaults to `0`.
- `--ha-list-padding` — padding around the list. Defaults to `0`.
### `<ha-list-selectable>`
Selectable list. Extends `ha-list-base`. Host role is `listbox`; items must be
`<ha-list-item-option>` (role `option`). Set `multi` for multi-select; the
host reflects `aria-multiselectable`.
**Attributes**
| Name | Type | Default | Description |
| ------- | ------- | ------- | -------------------------------------- |
| `multi` | Boolean | `false` | Allow multiple options to be selected. |
**Events**
- `ha-list-selected` — selection changed. Detail
`{ index: number | Set<number>, diff: { added: Set<number>, removed: Set<number> } }`.
`index` is a `number` in single mode (`-1` when nothing selected) and a
`Set<number>` in multi mode.
**Methods / getters**
- `selected` (getter) — current selection (`number` or `Set<number>`).
- `selectedItems` (getter) — selected `HaListItemOption` elements, in index
order.
- `setSelected(indices)` — replace the entire selection.
- `select(index)` — add `index` to the selection (replaces in single mode).
- `toggle(index, force?)` — toggle a single index, or force on/off.
- `clearSelection()` — clear all.
### `<ha-list-nav>`
Same as `ha-list-base`, but wrapped in a `<nav>` landmark
(`<nav><div role="list">…</div></nav>`). Use `aria-label` to name the
landmark — the value is forwarded to the inner `<nav>`. Items should be
`<ha-list-item-button>` with an `href`.
**CSS parts**
- `nav` — the `<nav>` wrapper.
- `base` — the inner `<div role="list">`.
## Items
All items inherit from `ha-row-item`, which provides the row layout and the
shared slots/attributes below.
### Shared row layout (`ha-row-item`)
**Slots**
- `start` — leading container (icon/avatar).
- `end` — trailing container (meta/chevron).
- `headline` — primary text (overrides the `headline` attribute).
- `supporting-text` — secondary text (overrides the `supporting-text` attribute).
- `content` — escape hatch: replaces the entire middle column.
**Attributes**
| Name | Type | Default | Description |
| ----------------- | ------- | ------- | --------------------------------------- |
| `headline` | String | — | Primary text. Overridden by the slot. |
| `supporting-text` | String | — | Secondary text. Overridden by the slot. |
| `disabled` | Boolean | `false` | Dims the row and blocks pointer events. |
**CSS parts**
`base`, `start`, `content`, `headline`, `supporting-text`, `end`.
**CSS custom properties**
- `--ha-row-item-padding-block` — vertical padding.
- `--ha-row-item-padding-inline` — horizontal padding.
- `--ha-row-item-gap` — gap between `start`, `content`, and `end`.
- `--ha-row-item-min-height` — minimum row height (default `48px`).
### `<ha-list-item-base>`
Non-interactive list row. Host role is `listitem`. Inherits everything from
`ha-row-item`.
**Attributes**
- `interactive` (Boolean, default `false`) — opt this row into the parent
list's roving tabindex. Useful for sortable rows that need keyboard focus
but no click action. Interactive subclasses set this automatically.
**CSS custom properties**
- `--ha-list-item-focus-radius` — focus outline border-radius.
- `--ha-list-item-focus-width` — focus outline width (steady state).
- `--ha-list-item-focus-width-start` — focus outline width at the start of
the focus-in animation.
- `--ha-list-item-focus-offset` — focus outline offset.
- `--ha-list-item-focus-background` — background color on keyboard focus.
### `<ha-list-item-button>`
Interactive row. Renders an inner `<a>` when `href` is set, otherwise a
`<button>`. The full row is the hit target. When placed inside a list using
roving tabindex, the host is the tab stop and the inner element carries
`tabindex="-1"`.
**Attributes**
- `href` (String) — when set, renders an `<a>` instead of a `<button>`.
- `target` (String) — anchor `target` (requires `href`).
- `rel` (String) — anchor `rel` (requires `href`).
- `download` (String) — anchor `download` (requires `href`).
**CSS parts**
- `ripple` — the ripple effect element.
### `<ha-list-item-option>`
Selectable row. Host role is `option`; reflects `aria-selected`. Designed to
sit inside `<ha-list-selectable>`, which owns selection state and toggles
`selected` on this item — the option itself does not fire selection events.
**Attributes**
- `selected` (Boolean, default `false`, reflected) — set by the parent
`ha-list-selectable`.
- `value` (String) — value identifying the option.
- `appearance` (`"line"` | `"checkbox"`, default `"line"`) — `"line"`
highlights the row; `"checkbox"` renders a decorative `<ha-checkbox>`.
- `selection-position` (`"start"` | `"end"`, default `"start"`) — side the
checkbox sits on when `appearance="checkbox"`.
**CSS parts**
- `checkbox` — wrapper around the `<ha-checkbox>` when `appearance="checkbox"`.
- `ripple` — the ripple effect element.
**CSS custom properties**
- `--ha-list-item-selected-background` — background color when selected
(`appearance="line"`).
-415
View File
@@ -1,415 +0,0 @@
import {
mdiAccount,
mdiChevronRight,
mdiCog,
mdiHome,
mdiInformationOutline,
mdiMapMarker,
mdiOpenInNew,
mdiViewDashboard,
mdiWifi,
} from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-svg-icon";
import "../../../../src/components/item/ha-list-item-base";
import "../../../../src/components/item/ha-list-item-button";
import "../../../../src/components/item/ha-list-item-option";
import "../../../../src/components/list/ha-list-base";
import "../../../../src/components/list/ha-list-nav";
import "../../../../src/components/list/ha-list-selectable";
import type { HaListSelectedDetail } from "../../../../src/components/list/types";
type Appearance = "line" | "checkbox";
type Position = "start" | "end";
const appearances: Appearance[] = ["line", "checkbox"];
const positions: Position[] = ["start", "end"];
const selectedStates = [false, true];
const disabledStates = [false, true];
@customElement("demo-components-ha-list")
export class DemoHaList extends LitElement {
@state() private _buttonClicks = 0;
@state() private _single: number | Set<number> = -1;
@state() private _multiLine: number | Set<number> = new Set();
@state() private _multiCheckStart: number | Set<number> = new Set();
@state() private _multiCheckEnd: number | Set<number> = new Set();
private _options = ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"];
protected render(): TemplateResult {
return html`
<h2>ha-list-base</h2>
<p>
Styled container with keyboard focus navigation. Children should be
<code>ha-list-item-*</code>.
</p>
<ha-card header="Info list (non-interactive rows)">
<ha-list-base aria-label="Device info">
<ha-list-item-base
headline="IP address"
supporting-text="192.168.1.42"
>
<ha-svg-icon slot="start" .path=${mdiWifi}></ha-svg-icon>
</ha-list-item-base>
<ha-list-item-base headline="Location" supporting-text="Living room">
<ha-svg-icon slot="start" .path=${mdiMapMarker}></ha-svg-icon>
</ha-list-item-base>
<ha-list-item-base headline="Firmware" supporting-text="2026.4.1">
<ha-svg-icon
slot="start"
.path=${mdiInformationOutline}
></ha-svg-icon>
</ha-list-item-base>
</ha-list-base>
</ha-card>
<ha-card header="Vertical list (default)">
<ha-list-base aria-label="Example list">
<ha-list-item-button>
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
<span slot="headline">First row</span>
<span slot="supporting-text">Supporting text</span>
<ha-svg-icon slot="end" .path=${mdiChevronRight}></ha-svg-icon>
</ha-list-item-button>
<ha-list-item-button>
<ha-svg-icon slot="start" .path=${mdiAccount}></ha-svg-icon>
<span slot="headline">Second row</span>
</ha-list-item-button>
<ha-list-item-button disabled>
<span slot="headline">Disabled row</span>
</ha-list-item-button>
<ha-list-item-button>
<span slot="headline">Fourth row</span>
</ha-list-item-button>
</ha-list-base>
</ha-card>
<ha-card header="Vertical list with wrap-focus">
<ha-list-base wrap-focus aria-label="Wrap focus">
<ha-list-item-button>
<span slot="headline">A</span>
</ha-list-item-button>
<ha-list-item-button>
<span slot="headline">B</span>
</ha-list-item-button>
<ha-list-item-button>
<span slot="headline">C</span>
</ha-list-item-button>
</ha-list-base>
</ha-card>
<h2>ha-list-item-base</h2>
<p>Non-interactive base row with slot permutations.</p>
<ha-card header="Slot permutations">
<ha-list-base aria-label="Slot permutations">
<ha-list-item-base headline="Headline only"></ha-list-item-base>
<ha-list-item-base
headline="Headline"
supporting-text="Supporting text"
></ha-list-item-base>
<ha-list-item-base headline="Start + headline">
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
</ha-list-item-base>
<ha-list-item-base headline="Start + headline + end">
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
<ha-svg-icon slot="end" .path=${mdiChevronRight}></ha-svg-icon>
</ha-list-item-base>
<ha-list-item-base
headline="Full row"
supporting-text="All slots filled"
>
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
<ha-svg-icon slot="end" .path=${mdiChevronRight}></ha-svg-icon>
</ha-list-item-base>
<ha-list-item-base>
<div slot="content" class="custom-content">
<strong>Custom content escape hatch</strong>
<span>Replaces the whole middle column</span>
</div>
</ha-list-item-base>
<ha-list-item-base headline="Disabled row" disabled>
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
</ha-list-item-base>
</ha-list-base>
</ha-card>
<h2>ha-list-item-button</h2>
<p>
Interactive row. Renders an inner <code>&lt;a&gt;</code> when
<code>href</code> is set, otherwise a <code>&lt;button&gt;</code>.
</p>
<ha-card header="Button (default) / link (with href)">
<ha-list-base aria-label="Button items">
<ha-list-item-button @click=${this._onButtonClick}>
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
<span slot="headline">Button (clicks: ${this._buttonClicks})</span>
</ha-list-item-button>
<ha-list-item-button
href="https://www.home-assistant.io/"
target="_blank"
rel="noopener noreferrer"
>
<ha-svg-icon slot="start" .path=${mdiOpenInNew}></ha-svg-icon>
<span slot="headline">Link (opens in new tab)</span>
<span slot="supporting-text"
>Cmd/Ctrl-click still opens in new tab</span
>
</ha-list-item-button>
<ha-list-item-button disabled>
<span slot="headline">Disabled button</span>
</ha-list-item-button>
<ha-list-item-button href="#nope" disabled>
<span slot="headline">Disabled link</span>
</ha-list-item-button>
</ha-list-base>
</ha-card>
<h2>ha-list-selectable + ha-list-item-option</h2>
<p>
Selectable list (<code>role="listbox"</code>). Items must be
<code>ha-list-item-option</code>. Set <code>multi</code> for
multi-select.
</p>
<ha-card header="Single select, appearance=line">
<ha-list-selectable
aria-label="Single select"
@ha-list-selected=${this._onSingle}
>
${this._options.map(
(o, i) => html`
<ha-list-item-option
.value=${o}
?selected=${this._isSel(this._single, i)}
>
<span slot="headline">${o}</span>
</ha-list-item-option>
`
)}
</ha-list-selectable>
<pre>selected: ${JSON.stringify(this._toJson(this._single))}</pre>
</ha-card>
<ha-card header="Multi select, appearance=line">
<ha-list-selectable
multi
aria-label="Multi select line"
@ha-list-selected=${this._onMultiLine}
>
${this._options.map(
(o, i) => html`
<ha-list-item-option
.value=${o}
?selected=${this._isSel(this._multiLine, i)}
>
<span slot="headline">${o}</span>
</ha-list-item-option>
`
)}
</ha-list-selectable>
<pre>selected: ${JSON.stringify(this._toJson(this._multiLine))}</pre>
</ha-card>
<ha-card
header='Multi select, appearance=checkbox, selection-position="start"'
>
<ha-list-selectable
multi
aria-label="Multi checkbox start"
@ha-list-selected=${this._onMultiCheckStart}
>
${this._options.map(
(o, i) => html`
<ha-list-item-option
appearance="checkbox"
selection-position="start"
.value=${o}
?selected=${this._isSel(this._multiCheckStart, i)}
>
<span slot="headline">${o}</span>
</ha-list-item-option>
`
)}
</ha-list-selectable>
<pre>
selected: ${JSON.stringify(this._toJson(this._multiCheckStart))}</pre
>
</ha-card>
<ha-card
header='Multi select, appearance=checkbox, selection-position="end"'
>
<ha-list-selectable
multi
aria-label="Multi checkbox end"
@ha-list-selected=${this._onMultiCheckEnd}
>
${this._options.map(
(o, i) => html`
<ha-list-item-option
appearance="checkbox"
selection-position="end"
.value=${o}
?selected=${this._isSel(this._multiCheckEnd, i)}
>
<span slot="headline">${o}</span>
<span slot="supporting-text">${o.length} characters</span>
</ha-list-item-option>
`
)}
</ha-list-selectable>
<pre>
selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre
>
</ha-card>
<ha-card header="Option: all combinations">
<div class="grid">
${appearances.map((appearance) =>
positions.map((position) =>
selectedStates.map((selected) =>
disabledStates.map(
(disabled) => html`
<div role="listbox" class="wrap" aria-label="single option">
<ha-list-item-option
appearance=${appearance}
selection-position=${position}
?selected=${selected}
?disabled=${disabled}
>
<span slot="headline"
>${appearance} / pos=${position}</span
>
<span slot="supporting-text"
>selected=${String(selected)}
disabled=${String(disabled)}</span
>
</ha-list-item-option>
</div>
`
)
)
)
)}
</div>
</ha-card>
<h2>ha-list-nav</h2>
<p>
Same as <code>ha-list-base</code> but wrapped in a
<code>&lt;nav&gt;</code> landmark.
</p>
<ha-card header="Sidebar-style navigation">
<ha-list-nav aria-label="Primary navigation">
${[
{ name: "Overview", path: "#overview", icon: mdiHome },
{ name: "Dashboards", path: "#dashboards", icon: mdiViewDashboard },
{ name: "Map", path: "#map", icon: mdiMapMarker },
{ name: "Settings", path: "#settings", icon: mdiCog },
].map(
(p) => html`
<ha-list-item-button .href=${p.path}>
<ha-svg-icon slot="start" .path=${p.icon}></ha-svg-icon>
<span slot="headline">${p.name}</span>
<ha-svg-icon slot="end" .path=${mdiChevronRight}></ha-svg-icon>
</ha-list-item-button>
`
)}
</ha-list-nav>
</ha-card>
`;
}
private _isSel(value: number | Set<number>, index: number): boolean {
if (typeof value === "number") {
return value === index;
}
return value.has(index);
}
private _toJson(value: number | Set<number>): unknown {
return value instanceof Set ? [...value] : value;
}
private _onButtonClick = () => {
this._buttonClicks++;
};
private _onSingle = (ev: CustomEvent<HaListSelectedDetail>) => {
this._single = ev.detail.index;
};
private _onMultiLine = (ev: CustomEvent<HaListSelectedDetail>) => {
this._multiLine = ev.detail.index;
};
private _onMultiCheckStart = (ev: CustomEvent<HaListSelectedDetail>) => {
this._multiCheckStart = ev.detail.index;
};
private _onMultiCheckEnd = (ev: CustomEvent<HaListSelectedDetail>) => {
this._multiCheckEnd = ev.detail.index;
};
static styles = css`
:host {
display: flex;
flex-direction: column;
gap: var(--ha-space-4);
padding: var(--ha-space-6);
}
h2 {
margin: var(--ha-space-4) 0 0;
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-medium);
}
p {
margin: 0 0 var(--ha-space-2);
color: var(--secondary-text-color);
}
ha-card {
max-width: 560px;
}
pre {
padding: var(--ha-space-4);
background: var(--secondary-background-color);
margin: 0;
}
.custom-content {
display: flex;
flex-direction: column;
gap: var(--ha-space-1);
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--ha-space-3);
padding: var(--ha-space-3);
}
.wrap {
border: 1px solid var(--divider-color);
border-radius: var(--ha-border-radius-sm);
}
.drag-handle {
cursor: grab;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-list": DemoHaList;
}
}
+1 -19
View File
@@ -43,22 +43,12 @@ 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",
@@ -70,12 +60,6 @@ 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")
@@ -83,14 +67,13 @@ 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, maxColumns } = select;
const { id, label, options } = select;
return html`
<ha-card>
<div class="card-content">
@@ -98,7 +81,6 @@ export class DemoHaSelectBox extends LitElement {
<ha-select-box
.value=${this.value}
.options=${options}
.maxColumns=${maxColumns}
@value-changed=${this.handleValueChanged}
>
</ha-select-box>
+38 -38
View File
@@ -28,33 +28,32 @@
"dependencies": {
"@babel/runtime": "7.29.2",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.2",
"@codemirror/autocomplete": "6.20.1",
"@codemirror/commands": "6.10.3",
"@codemirror/lang-jinja": "6.0.1",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.3",
"@codemirror/lint": "6.9.6",
"@codemirror/search": "6.7.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.42.1",
"@codemirror/view": "6.41.1",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.2",
"@formatjs/intl-displaynames": "7.3.5",
"@formatjs/intl-durationformat": "0.10.8",
"@formatjs/intl-getcanonicallocales": "3.2.6",
"@formatjs/intl-listformat": "8.3.5",
"@formatjs/intl-locale": "5.3.5",
"@formatjs/intl-numberformat": "9.3.5",
"@formatjs/intl-pluralrules": "6.3.5",
"@formatjs/intl-relativetimeformat": "12.3.5",
"@formatjs/intl-datetimeformat": "7.4.0",
"@formatjs/intl-displaynames": "7.3.3",
"@formatjs/intl-durationformat": "0.10.5",
"@formatjs/intl-getcanonicallocales": "3.2.4",
"@formatjs/intl-listformat": "8.3.3",
"@formatjs/intl-locale": "5.3.3",
"@formatjs/intl-numberformat": "9.3.3",
"@formatjs/intl-pluralrules": "6.3.3",
"@formatjs/intl-relativetimeformat": "12.3.3",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
"@fullcalendar/list": "6.1.20",
"@fullcalendar/luxon3": "6.1.20",
"@fullcalendar/timegrid": "6.1.20",
"@home-assistant/webawesome": "3.3.1-ha.3",
"@home-assistant/webawesome": "3.3.1-ha.1",
"@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.1.0",
"@lit-labs/observers": "2.1.0",
@@ -66,6 +65,7 @@
"@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",
@@ -80,7 +80,7 @@
"@vibrant/color": "4.0.4",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.1.3",
"barcode-detector": "3.1.2",
"cally": "0.9.2",
"color-name": "2.1.0",
"comlink": "4.4.2",
@@ -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.4",
"intl-messageformat": "11.2.2",
"js-yaml": "4.1.1",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
@@ -107,7 +107,7 @@
"lit": "3.3.2",
"lit-html": "3.3.2",
"luxon": "3.7.2",
"marked": "18.0.3",
"marked": "18.0.2",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -121,28 +121,28 @@
"superstruct": "2.0.2",
"tinykeys": "3.0.0",
"weekstart": "2.0.0",
"workbox-cacheable-response": "7.4.1",
"workbox-core": "7.4.1",
"workbox-expiration": "7.4.1",
"workbox-precaching": "7.4.1",
"workbox-routing": "7.4.1",
"workbox-strategies": "7.4.1",
"workbox-cacheable-response": "7.4.0",
"workbox-core": "7.4.0",
"workbox-expiration": "7.4.0",
"workbox-precaching": "7.4.0",
"workbox-routing": "7.4.0",
"workbox-strategies": "7.4.0",
"xss": "1.0.15"
},
"devDependencies": {
"@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.5",
"@babel/preset-env": "7.29.2",
"@bundle-stats/plugin-webpack-filter": "4.22.1",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.60.0",
"@lokalise/node-api": "16.0.0",
"@html-eslint/eslint-plugin": "0.59.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.10",
"@rspack/core": "2.0.2",
"@rsdoctor/rspack-plugin": "1.5.9",
"@rspack/core": "2.0.0",
"@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.3.0",
"eslint": "10.2.1",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.16.2",
@@ -175,18 +175,18 @@
"eslint-plugin-unused-imports": "4.4.1",
"eslint-plugin-wc": "3.1.0",
"fancy-log": "2.0.0",
"fs-extra": "11.3.5",
"fs-extra": "11.3.4",
"glob": "13.0.6",
"globals": "17.6.0",
"globals": "17.5.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.1.1",
"jsdom": "29.0.2",
"jszip": "3.10.1",
"lint-staged": "17.0.3",
"lint-staged": "16.4.0",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
@@ -195,17 +195,17 @@
"prettier": "3.8.3",
"rspack-manifest-plugin": "5.2.1",
"serve": "14.2.6",
"sinon": "22.0.0",
"tar": "7.5.15",
"terser-webpack-plugin": "5.6.0",
"sinon": "21.1.2",
"tar": "7.5.13",
"terser-webpack-plugin": "5.5.0",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.59.2",
"typescript-eslint": "8.59.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.5",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
"workbox-build": "patch:workbox-build@npm%3A7.4.0#~/.yarn/patches/workbox-build-npm-7.4.0-c84561662c.patch"
},
"resolutions": {
"lit": "3.3.2",
@@ -213,7 +213,7 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.20",
"globals": "17.6.0",
"globals": "17.5.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"
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260429.0"
version = "20260429.3"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
-54
View File
@@ -1,54 +0,0 @@
/**
* Walks up the composed tree (jumping shadow roots → their hosts), returning
* the ancestor chain top-down. Used to compare two nodes that may live in
* different shadow trees — `Node.compareDocumentPosition` only works within a
* single root and returns `DOCUMENT_POSITION_DISCONNECTED` otherwise.
*/
const composedAncestorPath = (node: Node): Node[] => {
const path: Node[] = [];
let cur: Node | null = node;
while (cur) {
path.push(cur);
const parent = cur.parentNode;
if (parent instanceof ShadowRoot) {
cur = parent.host;
} else if (parent) {
cur = parent;
} else {
const root = cur.getRootNode();
cur = root instanceof ShadowRoot ? root.host : null;
}
}
return path.reverse();
};
/**
* Document-order comparator that works across shadow boundaries. Suitable as
* the `Array.prototype.sort` callback for collections of nodes that may live
* in different shadow trees.
*/
export const compareNodeOrder = (a: Node, b: Node): number => {
if (a === b) {
return 0;
}
const pa = composedAncestorPath(a);
const pb = composedAncestorPath(b);
let i = 0;
while (i < pa.length && i < pb.length && pa[i] === pb[i]) {
i++;
}
if (i === 0) {
return 0;
}
if (i === pa.length) {
return -1;
}
if (i === pb.length) {
return 1;
}
// pa[i] and pb[i] are siblings under the LCA, guaranteed same root.
// eslint-disable-next-line no-bitwise
return pa[i].compareDocumentPosition(pb[i]) & Node.DOCUMENT_POSITION_FOLLOWING
? -1
: 1;
};
-38
View File
@@ -1,17 +1,3 @@
import {
mdiBattery,
mdiBattery10,
mdiBattery20,
mdiBattery30,
mdiBattery40,
mdiBattery50,
mdiBattery60,
mdiBattery70,
mdiBattery80,
mdiBattery90,
mdiBatteryAlertVariantOutline,
mdiBatteryUnknown,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
const BATTERY_ICONS = {
@@ -26,18 +12,6 @@ const BATTERY_ICONS = {
90: "mdi:battery-90",
100: "mdi:battery",
};
const BATTERY_ICON_PATHS = {
10: mdiBattery10,
20: mdiBattery20,
30: mdiBattery30,
40: mdiBattery40,
50: mdiBattery50,
60: mdiBattery60,
70: mdiBattery70,
80: mdiBattery80,
90: mdiBattery90,
100: mdiBattery,
};
const BATTERY_CHARGING_ICONS = {
10: "mdi:battery-charging-10",
20: "mdi:battery-charging-20",
@@ -83,15 +57,3 @@ export const batteryLevelIcon = (
}
return BATTERY_ICONS[batteryRound];
};
export const batteryLevelIconPath = (batteryLevel: number | string): string => {
const batteryValue = Number(batteryLevel);
if (isNaN(batteryValue)) {
return mdiBatteryUnknown;
}
if (batteryValue <= 5) {
return mdiBatteryAlertVariantOutline;
}
const batteryRound = Math.round(batteryValue / 10) * 10;
return BATTERY_ICON_PATHS[batteryRound];
};
@@ -137,10 +137,7 @@ export const computeEntityPickerDisplay = (
hass.floors
);
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const isRTL = computeRTL(hass);
const primary = entityName || deviceName || stateObj.entity_id;
const secondary =
+3
View File
@@ -117,6 +117,9 @@ export const generateEntityFilter = (
}
}
if (entityCategories) {
if (!entity) {
return false;
}
const category = entity?.entity_category || "none";
if (!entityCategories.has(category)) {
return false;
+6 -10
View File
@@ -1,20 +1,16 @@
import type { LitElement } from "lit";
import type { HomeAssistant, Translation } from "../../types";
import type { HomeAssistant } from "../../types";
export function computeRTL(
language = "en",
translations: Record<string, Translation>
) {
if (translations[language]) {
return translations[language].isRTL || false;
export function computeRTL(hass: HomeAssistant) {
const lang = hass.language || "en";
if (hass.translationMetadata.translations[lang]) {
return hass.translationMetadata.translations[lang].isRTL || false;
}
return false;
}
export function computeRTLDirection(hass: HomeAssistant) {
return emitRTLDirection(
computeRTL(hass.language, hass.translationMetadata.translations)
);
return emitRTLDirection(computeRTL(hass));
}
export function emitRTLDirection(rtl: boolean) {
-17
View File
@@ -1,17 +0,0 @@
// 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)}`;
};
+1 -13
View File
@@ -10,17 +10,6 @@ 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.
*
@@ -51,8 +40,7 @@ export const withViewTransition = (
callbackInvoked = true;
callback(true);
});
transition.ready.catch(ignoreAbortError);
return transition.finished.catch(ignoreAbortError);
return transition.finished;
} catch (err) {
// eslint-disable-next-line no-console
console.warn(
@@ -11,8 +11,7 @@ import "../ha-icon-button";
@customElement("ha-automation-row-event-chip")
export class HaAutomationRowEventChip extends LitElement {
@property({ reflect: true })
public variant: "info" | "warning" | "success" | "danger" | "neutral" =
"info";
public variant: "info" | "warning" | "success" | "danger" = "info";
@property({ type: Boolean })
public interactive = false;
@@ -92,12 +91,6 @@ 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);
+1 -2
View File
@@ -1110,7 +1110,7 @@ export class HaChartBase extends LitElement {
private _updateSankeyRoam() {
const option = this.chart?.getOption();
const sankeySeries = (option?.series as any[])?.filter(
(s: any) => s != null && s.type === "sankey"
(s: any) => s.type === "sankey"
);
if (sankeySeries?.length) {
this.chart?.setOption({
@@ -1499,7 +1499,6 @@ 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;
@@ -60,11 +60,6 @@ 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;
@@ -293,10 +288,7 @@ export class StateHistoryChartLine extends LitElement {
(changedProps.has("hass") &&
this._hasEntityStatesChanged(changedProps.get("hass")))
) {
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const rtl = computeRTL(this.hass);
let minYAxis: number | ((values: { min: number }) => number) | undefined =
this.minYAxis;
let maxYAxis: number | ((values: { max: number }) => number) | undefined =
@@ -443,11 +435,9 @@ 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;
@@ -478,11 +468,11 @@ export class StateHistoryChartLine extends LitElement {
const addDataSet = (
id: string,
nameY: string,
clr?: string,
color?: string,
fill = false
) => {
if (!clr) {
clr = getGraphColorByIndex(colorIndex, computedStyles);
if (!color) {
color = getGraphColorByIndex(colorIndex, computedStyles);
colorIndex++;
}
data.push({
@@ -491,7 +481,7 @@ export class StateHistoryChartLine extends LitElement {
type: "line",
cursor: "default",
name: nameY,
color: clr,
color,
symbol: "circle",
symbolSize: 1,
step: "end",
@@ -502,7 +492,7 @@ export class StateHistoryChartLine extends LitElement {
},
areaStyle: fill
? {
color: clr + "7F",
color: color + "7F",
}
: undefined,
tooltip: {
@@ -750,7 +740,7 @@ export class StateHistoryChartLine extends LitElement {
pushData(new Date(entityState.last_changed), series);
});
} else {
addDataSet(states.entity_id, name, color);
addDataSet(states.entity_id, name);
let lastValue: number;
let lastDate: Date;
@@ -144,10 +144,7 @@ export class StateHistoryChartTimeline extends LitElement {
"ui.components.history_charts.duration"
)}: ${millisecondsToDuration(durationInMs)}`;
const markerLocalized = !computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
const markerLocalized = !computeRTL(this.hass)
? marker
: `<span style="direction: rtl;display:inline-block;margin-right:4px;margin-inline-end:4px;border-radius:10px;width:10px;height:10px;background-color:${color};"></span>`;
@@ -170,12 +167,11 @@ export class StateHistoryChartTimeline extends LitElement {
public willUpdate(changedProps: PropertyValues) {
if (
this.isConnected &&
(changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("data") ||
this._chartTime <
new Date(this.endTime.getTime() - MIN_TIME_BETWEEN_UPDATES))
changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("data") ||
this._chartTime <
new Date(this.endTime.getTime() - MIN_TIME_BETWEEN_UPDATES)
) {
// If the line is more than 5 minutes old, re-gen it
// so the X axis grows even if there is no new data
@@ -202,10 +198,7 @@ export class StateHistoryChartTimeline extends LitElement {
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const labelMargin = 5;
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const rtl = computeRTL(this.hass);
this._chartOptions = {
xAxis: {
type: "time",
+1 -7
View File
@@ -52,11 +52,6 @@ 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;
@@ -186,7 +181,6 @@ 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}
@@ -405,12 +399,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 {
+36 -122
View File
@@ -13,9 +13,7 @@ import { isComponentLoaded } from "../../common/config/is_component_loaded";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { formatDate } from "../../common/datetime/format_date";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import { formatTimeWithSeconds } from "../../common/datetime/format_time";
import {
formatNumber,
getNumberFormatOptions,
@@ -70,11 +68,6 @@ 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;
@@ -243,8 +236,6 @@ export class StatisticsChart extends LitElement {
private _renderTooltip = (params: any) => {
const rendered: Record<string, boolean> = {};
const chartIsBar = this.chartType.startsWith("bar");
const period = this.period;
const unit = this.unit
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
: "";
@@ -256,67 +247,8 @@ export class StatisticsChart extends LitElement {
const statisticId = this._statisticIds[param.seriesIndex];
const stateObj = this.hass.states[statisticId];
const entry = this.hass.entities[statisticId];
let rawValue: string;
let rawTime: string;
if (chartIsBar) {
// For bar charts value is always second value.
rawValue = String(param.value[1]);
// Time value is third value (un-shifted date) if given, otherwise first value
let startTime: Date;
let endTime: Date | undefined;
if (param.value[2]) {
startTime = new Date(param.value[2]);
if (param.value[3]) {
endTime = new Date(param.value[3]);
}
} else {
startTime = new Date(param.value[0]);
}
if (
period === "year" ||
period === "month" ||
period === "week" ||
period === "day"
) {
// For year/month/day periods, show only the date
rawTime =
formatDate(startTime, this.hass.locale, this.hass.config) +
(endTime && period !== "day"
? ` ${formatDate(
endTime,
this.hass.locale,
this.hass.config
)}`
: "") +
"<br>";
} else {
// For other time periods, include time in render, and optionally show range
// if we have an end time.
rawTime =
formatDateTimeWithSeconds(
startTime,
this.hass.locale,
this.hass.config
) +
(endTime
? ` ${formatTimeWithSeconds(
endTime,
this.hass.locale,
this.hass.config
)}`
: "") +
"<br>";
}
} else {
// For lines max series can have 3 values, as the second value is the max-min to form a band
rawValue = String(param.value[2] ?? param.value[1]);
// Time value is always first value
rawTime = `${formatDateTimeWithSeconds(
new Date(param.value[0]),
this.hass.locale,
this.hass.config
)} <br>`;
}
// max series can have 3 values, as the second value is the max-min to form a band
const rawValue = String(param.value[2] ?? param.value[1]);
const options = getNumberFormatOptions(stateObj, entry) ?? {
maximumFractionDigits: 2,
@@ -328,7 +260,14 @@ export class StatisticsChart extends LitElement {
options
)}${unit}`;
const time = index === 0 ? rawTime : "";
const time =
index === 0
? formatDateTimeWithSeconds(
new Date(param.value[0]),
this.hass.locale,
this.hass.config
) + "<br>"
: "";
return `${time}${param.marker} ${param.seriesName}: ${value}`;
})
.filter(Boolean)
@@ -424,12 +363,7 @@ export class StatisticsChart extends LitElement {
nameTextStyle: {
align: "left",
},
position: computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? "right"
: "left",
position: computeRTL(this.hass) ? "right" : "left",
scale:
this.chartType.startsWith("line") ||
this.logarithmicScale ||
@@ -551,7 +485,6 @@ 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];
@@ -567,63 +500,40 @@ export class StatisticsChart extends LitElement {
const statDataSets: (LineSeriesOption | BarSeriesOption)[] = [];
const statLegendData: typeof legendData = [];
// Place bars at centre of their specified time range if this is a bar chart
// and the period is 5minute or hour.
const centerBars =
chartType === "bar" &&
(this.period === "5minute" || this.period === "hour");
const pushData = (
start: Date, // Data point start time
end: Date, // Data point end time
limit: Date, // Limit for end time (e.g. now)
start: Date,
end: Date,
dataValues: (number | null)[][]
) => {
if (!dataValues.length) return;
// Limit for time range is lesser of overall limit and data point end
limit = end.getTime() < limit.getTime() ? end : limit;
if (start.getTime() > limit.getTime()) {
if (start > end) {
// Drop data points that are after the requested endTime. This could happen if
// endTime is "now" and client time is not in sync with server time.
return;
}
statDataSets.forEach((d, i) => {
if (chartType === "line") {
if (
prevEndTime &&
prevValues &&
prevEndTime.getTime() !== start.getTime()
) {
// if the end of the previous data doesn't match the start of the current data,
// we have to draw a gap so add a value at the end time, and then an empty value.
d.data!.push([prevEndTime, ...prevValues[i]!]);
d.data!.push([prevEndTime, null]);
}
d.data!.push([start, ...dataValues[i]!]);
} else {
let time = start;
if (centerBars) {
// If centering bars, set the time to the midpoint between start and end instead
// of the start time.
time = new Date((start.getTime() + end.getTime()) / 2);
}
// Data value should always be a scalar for bar charts. Pass in
// real start time as extra value to allow formatting tooltip.
d.data!.push([time, dataValues[i][0]!, start, end]);
if (
chartType === "line" &&
prevEndTime &&
prevValues &&
prevEndTime.getTime() !== start.getTime()
) {
// if the end of the previous data doesn't match the start of the current data,
// we have to draw a gap so add a value at the end time, and then an empty value.
d.data!.push([prevEndTime, ...prevValues[i]!]);
d.data!.push([prevEndTime, null]);
}
d.data!.push([start, ...dataValues[i]!]);
});
prevValues = dataValues;
prevEndTime = limit;
prevEndTime = end;
};
let color = colors[statistic_id];
if (color === undefined) {
color = getGraphColorByIndex(
colorIndex,
this._computedStyle || getComputedStyle(this)
);
colorIndex++;
}
const color = getGraphColorByIndex(
colorIndex,
this._computedStyle || getComputedStyle(this)
);
colorIndex++;
const statTypes: this["statTypes"] = [];
@@ -773,7 +683,11 @@ export class StatisticsChart extends LitElement {
dataValues.push(val);
});
if (!this._hiddenStats.has(statistic_id)) {
pushData(startDate, endDate, endTime, dataValues);
pushData(
startDate,
endDate.getTime() < endTime.getTime() ? endDate : endTime,
dataValues
);
}
});
@@ -127,6 +127,7 @@ export class DialogDataTableSettings extends LitElement {
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${localize("ui.components.data-table.settings.header")}
@closed=${this._dialogClosed}
@@ -130,6 +130,7 @@ export class HaStateLabelBadge extends LitElement {
? html`<ha-state-icon
.icon=${this.icon}
.stateObj=${entityState}
.hass=${this.hass}
></ha-state-icon>`
: ""}
${value && !image && !showIcon
+2 -8
View File
@@ -210,10 +210,7 @@ export class HaStatisticPicker extends LitElement {
});
}
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const isRTL = computeRTL(hass);
const output: StatisticComboBoxItem[] = [];
@@ -356,10 +353,7 @@ export class HaStatisticPicker extends LitElement {
this.hass.floors
);
const isRTL = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const isRTL = computeRTL(this.hass);
const primary = entityName || deviceName || statisticId;
const secondary = [areaName, entityName ? deviceName : undefined]
+1
View File
@@ -98,6 +98,7 @@ export class StateBadge extends LitElement {
const domain = stateObj ? computeStateDomain(stateObj) : undefined;
return html`<ha-state-icon
.hass=${this.hass}
style=${styleMap(this._iconStyle)}
data-domain=${ifDefined(domain)}
data-state=${ifDefined(stateObj?.state)}
+5
View File
@@ -4,6 +4,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { listenMediaQuery } from "../common/dom/media_query";
import { internationalizationContext } from "../data/context";
import type { HomeAssistant } from "../types";
import "./ha-bottom-sheet";
import "./ha-dialog-header";
import "./ha-icon-button";
@@ -81,6 +82,8 @@ export const ADAPTIVE_DIALOG_MEDIA_QUERY =
*/
@customElement("ha-adaptive-dialog")
export class HaAdaptiveDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "aria-labelledby" })
public ariaLabelledBy?: string;
@@ -199,6 +202,7 @@ export class HaAdaptiveDialog extends LitElement {
.ariaLabelledBy=${this._defaultAriaLabelledBy}
.ariaDescribedBy=${this.ariaDescribedBy}
.flexContent=${this.flexContent}
.hass=${this.hass}
.open=${this.open}
.preventScrimClose=${this.preventScrimClose}
>
@@ -217,6 +221,7 @@ export class HaAdaptiveDialog extends LitElement {
return html`
<ha-dialog
.hass=${this.hass}
.open=${this.open}
.type=${this.type}
.width=${this.width}
+2 -4
View File
@@ -184,10 +184,7 @@ export class HaAreaControlsPicker extends LitElement {
const allEntityIds = Object.values(controlEntities).flat();
const uniqueEntityIds = Array.from(new Set(allEntityIds));
const isRTL = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const isRTL = computeRTL(this.hass);
uniqueEntityIds.forEach((entityId) => {
if (isSelected(entityId)) {
@@ -264,6 +261,7 @@ export class HaAreaControlsPicker extends LitElement {
${item.type === "entity" && item.stateObj
? html`<ha-state-icon
slot="start"
.hass=${this.hass}
.stateObj=${item.stateObj}
></ha-state-icon>`
: item.domain
+5 -7
View File
@@ -7,6 +7,7 @@ import { fireEvent } from "../common/dom/fire_event";
import { SwipeGestureRecognizer } from "../common/util/swipe-gesture-recognizer";
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300;
@@ -46,6 +47,8 @@ const SWIPE_LOCKED_CLASSES = new Set(["volume-slider-container", "forecast"]);
*/
@customElement("ha-bottom-sheet")
export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: "aria-labelledby" })
public ariaLabelledBy?: string;
@@ -64,11 +67,6 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
@state() private _sliderInteractionActive = false;
// disabled till iOS app fix the "focus_element" implementation
// @state()
// @consume({ context: configContext, subscribe: true })
// private _hassConfig?: ContextType<typeof configContext>;
@query("#drawer") private _drawer!: HTMLElement;
@query("#body") private _bodyElement!: HTMLDivElement;
@@ -92,13 +90,13 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
requestAnimationFrame(() => {
// disabled till iOS app fix the "focus_element" implementation
// if (this._hassConfig?.auth.external && isIosApp(this._hassConfig.auth.external)) {
// if (this.hass && isIosApp(this.hass.auth.external)) {
// const element = this.renderRoot.querySelector("[autofocus]");
// if (element !== null) {
// if (!element.id) {
// element.id = "ha-bottom-sheet-autofocus";
// }
// this._hassConfig.auth.external.fireMessage({
// this.hass.auth.external?.fireMessage({
// type: "focus_element",
// payload: {
// element_id: element.id,
+3 -64
View File
@@ -95,8 +95,6 @@ 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;
@@ -165,40 +163,6 @@ 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);
@@ -256,37 +220,16 @@ 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)
),
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()]
: []
effects: this._loadedCodeMirror!.readonlyCompartment!.reconfigure(
this._loadedCodeMirror!.EditorView!.editable.of(!this.readOnly)
),
});
this._updateToolbarButtons();
}
if (changedProps.has("linewrap")) {
transactions.push({
@@ -369,7 +312,6 @@ export class HaCodeEditor extends ReactiveElement {
...this._loadedCodeMirror.searchKeymap,
...this._loadedCodeMirror.historyKeymap,
...this._loadedCodeMirror.tabKeyBindings,
...this._loadedCodeMirror.lintKeymap,
saveKeyBinding,
]),
this._loadedCodeMirror.search({ top: true }),
@@ -384,9 +326,6 @@ 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",
+1 -6
View File
@@ -39,12 +39,7 @@ export class HaEntitiesDisplayEditor extends LitElement {
const items: DisplayItem[] = entities.map((entity) => ({
value: entity.entity_id,
label: computeStateName(entity),
icon: entityIcon(
this.hass.entities,
this.hass.config,
this.hass.connection,
entity
),
icon: entityIcon(this.hass, entity),
}));
const value: DisplayValue = {
-1
View File
@@ -59,7 +59,6 @@ export class HaExpansionPanel extends LitElement {
<slot class="secondary" name="secondary">${this.secondary}</slot>
</div>
</slot>
<slot name="event"></slot>
${!this.leftChevron ? chevronIcon : nothing}
<slot name="icons"></slot>
</div>
+5 -1
View File
@@ -122,7 +122,11 @@ export class HaFilterEntities extends LitElement {
.selected=${this.value?.includes(entity.entity_id) ?? false}
graphic="icon"
>
<ha-state-icon slot="graphic" .stateObj=${entity}></ha-state-icon>
<ha-state-icon
slot="graphic"
.hass=${this.hass}
.stateObj=${entity}
></ha-state-icon>
${computeStateName(entity)}
</ha-check-list-item>`;
+56 -68
View File
@@ -13,17 +13,14 @@ import type { RelatedResult } from "../data/search";
import { findRelated } from "../data/search";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-floor-icon";
import "./ha-icon";
import "./ha-icon-button";
import "./ha-list";
import "./ha-svg-icon";
import "./ha-tree-indicator";
import "./item/ha-list-item-option";
import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-selectable";
import type { HaListSelectable } from "./list/ha-list-selectable";
import type { HaListSelectedDetail } from "./list/types";
@customElement("ha-filter-floor-areas")
export class HaFilterFloorAreas extends LitElement {
@@ -78,33 +75,27 @@ export class HaFilterFloorAreas extends LitElement {
</div>
${this._shouldRender
? html`
<ha-list-selectable
class="ha-scrollbar"
multi
@ha-list-selected=${this._handleListChanged}
aria-label=${this.hass.localize(
"ui.panel.config.areas.caption"
)}
>
<ha-list class="ha-scrollbar">
${repeat(
areas?.floors || [],
(floor) => floor.floor_id,
(floor) => html`
<ha-list-item-option
appearance="checkbox"
selection-position="end"
<ha-check-list-item
.value=${floor.floor_id}
.type=${"floors"}
.selected=${this.value?.floors?.includes(
floor.floor_id
) || false}
graphic="icon"
@request-selected=${this._handleItemClick}
@keydown=${this._handleItemKeydown}
>
<ha-floor-icon
slot="start"
slot="graphic"
.floor=${floor}
></ha-floor-icon>
<span slot="headline">${floor.name} </span>
</ha-list-item-option>
${floor.name}
</ha-check-list-item>
${repeat(
floor.areas,
(area, index) =>
@@ -119,7 +110,7 @@ export class HaFilterFloorAreas extends LitElement {
(area) => area.area_id,
(area) => this._renderArea(area)
)}
</ha-list-selectable>
</ha-list>
`
: nothing}
</ha-expansion-panel>
@@ -128,86 +119,79 @@ export class HaFilterFloorAreas extends LitElement {
private _renderArea(area, last = false) {
const hasFloor = !!area.floor_id;
return html`
<ha-list-item-option
appearance="checkbox"
selection-position="end"
<ha-check-list-item
.value=${area.area_id}
.selected=${this.value?.areas?.includes(area.area_id) || false}
.type=${"areas"}
graphic="icon"
@request-selected=${this._handleItemClick}
@keydown=${this._handleItemKeydown}
class=${classMap({
rtl: computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
),
rtl: computeRTL(this.hass),
floor: hasFloor,
})}
>
${hasFloor
? html`<ha-tree-indicator
slot="start"
.end=${last}
></ha-tree-indicator>`
? html`
<ha-tree-indicator
.end=${last}
slot="graphic"
></ha-tree-indicator>
`
: nothing}
${area.icon
? html`<ha-icon slot="start" .icon=${area.icon}></ha-icon>`
? html`<ha-icon slot="graphic" .icon=${area.icon}></ha-icon>`
: html`<ha-svg-icon
slot="start"
slot="graphic"
.path=${mdiTextureBox}
></ha-svg-icon>`}
<span slot="headline">${area.name}</span>
</ha-list-item-option>
${area.name}
</ha-check-list-item>
`;
}
private _handleListChanged(ev: CustomEvent<HaListSelectedDetail>) {
if (!ev.detail.diff?.added.size && !ev.detail.diff?.removed.size) {
private _handleItemKeydown(ev) {
if (ev.key === " " || ev.key === "Enter") {
ev.preventDefault();
this._handleItemClick(ev);
}
}
private _handleItemClick(ev) {
ev.stopPropagation();
const listItem = ev.currentTarget;
const type = listItem?.type;
const value = listItem?.value;
if (ev.detail.selected === listItem.selected || !value) {
return;
}
if (ev.detail.diff?.added.size) {
const addedIndex = ev.detail.diff.added.values().next().value;
if (addedIndex === undefined) {
return;
}
const addedItem = (ev.currentTarget as HaListSelectable).items[
addedIndex
] as HaListItemOption & { type: string; value: string };
if (this.value?.[type]?.includes(value)) {
this.value = {
...this.value,
[type]: this.value[type].filter((val) => val !== value),
};
} else {
if (!this.value) {
this.value = {};
}
this.value = {
...this.value,
[addedItem.type]: [
...(this.value[addedItem.type] || []),
addedItem.value,
],
};
} else {
const removedIndex = ev.detail.diff?.removed.values().next().value;
if (removedIndex === undefined) {
return;
}
const removedItem = (ev.currentTarget as HaListSelectable).items[
removedIndex
] as HaListItemOption & { type: string; value: string };
this.value = {
...this.value,
[removedItem.type]: this.value![removedItem.type].filter(
(val) => val !== removedItem.value
),
[type]: [...(this.value[type] || []), value],
};
}
listItem.selected = this.value[type]?.includes(value);
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("ha-list-selectable")!.style.height =
this.renderRoot.querySelector("ha-list")!.style.height =
`${this.clientHeight - 49}px`;
}, 300);
}
@@ -333,7 +317,11 @@ export class HaFilterFloorAreas extends LitElement {
padding: 0px 2px;
color: var(--text-primary-color);
}
.floor::part(base) {
ha-check-list-item {
--mdc-list-item-graphic-margin: 16px;
}
.floor {
padding-left: 48px;
padding-inline-start: 48px;
padding-inline-end: 16px;
}
+4
View File
@@ -37,6 +37,10 @@ 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;
-2
View File
@@ -469,8 +469,6 @@ 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 {
+1 -4
View File
@@ -53,10 +53,7 @@ export class HaIconButton extends LitElement {
.download=${this.download}
>
${this.path
? html`<ha-svg-icon
aria-hidden="true"
.path=${this.path}
></ha-svg-icon>`
? html`<ha-svg-icon .path=${this.path}></ha-svg-icon>`
: html`<span><slot></slot></span>`}
</ha-button>
`;
@@ -1,15 +1,16 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-icon-next";
import "../../../components/ha-svg-icon";
import "../../../components/item/ha-list-item-button";
import "../../../components/list/ha-list-nav";
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../../../types";
import { ifDefined } from "lit/directives/if-defined";
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../types";
import "./ha-icon-next";
import "./ha-md-list";
import "./ha-md-list-item";
import "./ha-svg-icon";
@customElement("ha-config-navigation-list")
class HaConfigNavigationList extends LitElement {
@customElement("ha-navigation-list")
class HaNavigationList extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@@ -23,11 +24,16 @@ class HaConfigNavigationList extends LitElement {
public render(): TemplateResult {
return html`
<ha-list-nav .ariaLabel=${this.label}>
<ha-md-list
innerRole="menu"
itemRoles="menuitem"
innerAriaLabel=${ifDefined(this.label)}
>
${this.pages.map((page) => {
const externalApp = page.path.endsWith("#external-app-configuration");
return html`
<ha-list-item-button
<ha-md-list-item
.type=${externalApp ? "button" : "link"}
.href=${externalApp ? undefined : page.path}
@click=${externalApp ? this._handleExternalApp : undefined}
>
@@ -49,10 +55,10 @@ class HaConfigNavigationList extends LitElement {
${!this.narrow
? html`<ha-icon-next slot="end"></ha-icon-next>`
: ""}
</ha-list-item-button>
</ha-md-list-item>
`;
})}
</ha-list-nav>
</ha-md-list>
`;
}
@@ -77,11 +83,14 @@ class HaConfigNavigationList extends LitElement {
.icon-background ha-svg-icon {
color: #fff;
}
ha-md-list-item {
font-size: var(--navigation-list-item-title-font-size);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-navigation-list": HaConfigNavigationList;
"ha-navigation-list": HaNavigationList;
}
}
+22
View File
@@ -0,0 +1,22 @@
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;
}
}
+5
View File
@@ -166,6 +166,7 @@ export class HaRelatedItems extends LitElement {
graphic="icon"
>
<ha-state-icon
.hass=${this.hass}
.stateObj=${entity}
slot="graphic"
></ha-state-icon>
@@ -321,6 +322,7 @@ export class HaRelatedItems extends LitElement {
graphic="icon"
>
<ha-state-icon
.hass=${this.hass}
.stateObj=${group}
slot="graphic"
></ha-state-icon>
@@ -345,6 +347,7 @@ export class HaRelatedItems extends LitElement {
graphic="icon"
>
<ha-state-icon
.hass=${this.hass}
.stateObj=${scene}
slot="graphic"
></ha-state-icon>
@@ -397,6 +400,7 @@ export class HaRelatedItems extends LitElement {
graphic="icon"
>
<ha-state-icon
.hass=${this.hass}
.stateObj=${automation}
slot="graphic"
></ha-state-icon>
@@ -448,6 +452,7 @@ export class HaRelatedItems extends LitElement {
graphic="icon"
>
<ha-state-icon
.hass=${this.hass}
.stateObj=${script}
slot="graphic"
></ha-state-icon>
+23 -35
View File
@@ -1,14 +1,13 @@
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 "./radio/ha-radio-group";
import type { HaRadioGroup } from "./radio/ha-radio-group";
import "./radio/ha-radio-option";
import "./ha-radio";
import type { HaRadio } from "./ha-radio";
interface SelectBoxOptionImage {
src: string;
@@ -45,14 +44,9 @@ export class HaSelectBox extends LitElement {
const columns = Math.min(maxColumns, this.options.length);
return html`
<ha-radio-group
class="list"
style=${styleMap({ "--columns": columns })}
.value=${this.value}
@change=${this._radioChanged}
>
<div class="list" style=${styleMap({ "--columns": columns })}>
${this.options.map((option) => this._renderOption(option))}
</ha-radio-group>
</div>
`;
}
@@ -63,12 +57,7 @@ export class HaSelectBox extends LitElement {
const selected = option.value === this.value;
const isDark = this.hass?.themes.darkMode || false;
const isRTL = this.hass
? computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
: false;
const isRTL = this.hass ? computeRTL(this.hass) : false;
const imageSrc =
typeof option.image === "object"
@@ -85,24 +74,20 @@ export class HaSelectBox extends LitElement {
selected: selected,
})}"
?disabled=${disabled}
@click=${this._labelClick}
>
<div class="content">
<ha-radio-option
aria-describedby=${ifDefined(
option.description ? `desc-${option.value}` : undefined
)}
aria-labelledby=${`label-${option.value}`}
<ha-radio
.checked=${option.value === this.value}
.value=${option.value}
.disabled=${disabled}
></ha-radio-option>
@change=${this._radioChanged}
@click=${stopPropagation}
></ha-radio>
<div class="text">
<span id=${`label-${option.value}`} class="label"
>${option.label}</span
>
<span class="label">${option.label}</span>
${option.description
? html`<span class="description" id="desc-${option.value}"
>${option.description}</span
>`
? html`<span class="description">${option.description}</span>`
: nothing}
</div>
</div>
@@ -115,9 +100,14 @@ 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 HaRadioGroup;
const radio = ev.currentTarget as HaRadio;
const value = radio.value;
if (this.disabled || value === undefined || value === (this.value ?? "")) {
return;
@@ -128,7 +118,7 @@ export class HaSelectBox extends LitElement {
}
static styles = css`
.list::part(form-control-input) {
.list {
display: grid;
grid-template-columns: repeat(var(--columns, 1), minmax(0, 1fr));
gap: var(--ha-space-3);
@@ -156,9 +146,8 @@ export class HaSelectBox extends LitElement {
min-width: 0;
width: 100%;
}
.option .content ha-radio-option {
--ha-radio-option-control-margin: 0;
margin: 0;
.option .content ha-radio {
margin: -12px;
flex: none;
}
.option .content .text {
@@ -167,7 +156,6 @@ 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);
+6 -10
View File
@@ -36,15 +36,7 @@ export class HaIconSelector extends LitElement {
const placeholder =
this.selector.icon?.placeholder ||
stateObj?.attributes.icon ||
(stateObj &&
until(
entityIcon(
this.hass.entities,
this.hass.config,
this.hass.connection,
stateObj
)
));
(stateObj && until(entityIcon(this.hass, stateObj)));
return html`
<ha-icon-picker
@@ -59,7 +51,11 @@ export class HaIconSelector extends LitElement {
>
${!placeholder && stateObj
? html`
<ha-state-icon slot="start" .stateObj=${stateObj}></ha-state-icon>
<ha-state-icon
slot="start"
.hass=${this.hass}
.stateObj=${stateObj}
></ha-state-icon>
`
: nothing}
</ha-icon-picker>
@@ -78,28 +78,22 @@ export class HaObjectSelector extends LitElement {
};
private _renderItem(item: any, index: number) {
const fields = this.selector.object!.fields!;
const preferredLabel = this.selector.object!.label_field;
const hasValidLabelField = preferredLabel && preferredLabel in fields;
const labelField =
this.selector.object!.label_field ||
Object.keys(this.selector.object!.fields!)[0];
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(" · ");
const labelSelector = this.selector.object!.fields![labelField].selector;
const label = labelSelector
? formatSelectorValue(this.hass, item[labelField], labelSelector)
: "";
let description = "";
const descriptionField = this.selector.object!.description_field;
if (descriptionField && descriptionField in fields) {
const descriptionSelector = fields[descriptionField]?.selector;
if (descriptionField) {
const descriptionSelector =
this.selector.object!.fields![descriptionField].selector;
description = descriptionSelector
? formatSelectorValue(
@@ -15,11 +15,10 @@ 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 {
@@ -109,23 +108,24 @@ export class HaSelectSelector extends LitElement {
) {
if (!this.selector.select?.multiple) {
return html`
<ha-radio-group
.label=${this.label}
.disabled=${this.disabled}
.value=${this.value}
@change=${this._radioChanged}
>
<div>
${this.label}
${options.map(
(item: SelectOption) => html`
<ha-radio-option
.value=${item.value}
.disabled=${!!item.disabled}
<ha-formfield
.label=${item.label}
.disabled=${item.disabled || this.disabled}
>
${item.label}
</ha-radio-option>
<ha-radio
.checked=${item.value === this.value}
.value=${item.value}
.disabled=${item.disabled || this.disabled}
@change=${this._radioChanged}
></ha-radio>
</ha-formfield>
`
)}
</ha-radio-group>
</div>
${this._renderHelper()}
`;
}
+59 -49
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-list-nav
html`<ha-md-list
class=${classMap({
"ha-scrollbar": scrollable,
[cls]: true,
})}
>${content}</ha-list-nav
>${content}</ha-md-list
>`;
if (!this._panelOrder || !this._hiddenPanels) {
@@ -430,8 +430,9 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
const iconPath = getPanelIconPath(panel);
return html`
<ha-list-item-button
<ha-md-list-item
.href=${`/${urlPath}`}
type="link"
id="sidebar-panel-${urlPath}"
class=${classMap({ selected: isSelected })}
>
@@ -439,7 +440,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
? html`<ha-svg-icon slot="start" .path=${iconPath}></ha-svg-icon>`
: html`<ha-icon slot="start" .icon=${icon}></ha-icon>`}
<span class="item-text" slot="headline">${title}</span>
</ha-list-item-button>
</ha-md-list-item>
${!this.alwaysExpand && title
? this._renderToolTip(`sidebar-panel-${urlPath}`, title)
: nothing}
@@ -456,8 +457,9 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
}
const isSelected = selectedPanel === "config";
return html`
<ha-list-item-button
<ha-md-list-item
class="configuration ${classMap({ selected: isSelected })}"
type="button"
href="/config"
id="sidebar-config"
>
@@ -479,7 +481,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
>
`
: nothing}
</ha-list-item-button>
</ha-md-list-item>
${!this.alwaysExpand
? this._renderToolTip(
"sidebar-config",
@@ -495,9 +497,10 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
: 0;
return html`
<ha-list-item-button
<ha-md-list-item
class="notifications"
@click=${this._handleShowNotificationDrawer}
type="button"
id="sidebar-notifications"
>
<ha-svg-icon slot="start" .path=${mdiBell}></ha-svg-icon>
@@ -512,7 +515,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
${notificationCount > 0
? html`<span class="badge" slot="end">${notificationCount}</span>`
: nothing}
</ha-list-item-button>
</ha-md-list-item>
${!this.alwaysExpand
? this._renderToolTip(
"sidebar-notifications",
@@ -523,15 +526,13 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
}
private _renderUserItem(selectedPanel: string) {
const isRTL = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const isRTL = computeRTL(this.hass);
const isSelected = selectedPanel === "profile";
return html`
<ha-list-item-button
<ha-md-list-item
href="/profile"
type="link"
id="sidebar-profile"
class=${classMap({
user: true,
@@ -547,7 +548,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
<span class="item-text" slot="headline"
>${this.hass.user ? this.hass.user.name : ""}</span
>
</ha-list-item-button>
</ha-md-list-item>
${!this.alwaysExpand && this.hass.user
? this._renderToolTip("sidebar-profile", this.hass.user.name)
: nothing}
@@ -559,15 +560,16 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
return nothing;
}
return html`
<ha-list-item-button
<ha-md-list-item
@click=${this._handleExternalAppConfiguration}
type="button"
id="sidebar-external-config"
>
<ha-svg-icon slot="start" .path=${mdiCellphoneCog}></ha-svg-icon>
<span class="item-text" slot="headline">
${this.hass.localize("ui.sidebar.external_app_configuration")}
</span>
</ha-list-item-button>
<span class="item-text" slot="headline"
>${this.hass.localize("ui.sidebar.external_app_configuration")}</span
>
</ha-md-list-item>
${!this.alwaysExpand
? this._renderToolTip(
"sidebar-external-config",
@@ -716,10 +718,10 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
flex: 1;
}
ha-list-nav {
ha-md-list {
overflow-x: hidden;
background: none;
margin-left: var(--safe-area-inset-left, 0px);
margin-block: var(--ha-space-2);
}
.wrapper {
@@ -729,39 +731,42 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
min-height: 0;
flex: 1;
}
ha-list-nav.before-spacer {
ha-md-list.before-spacer {
padding-bottom: 0;
}
ha-list-nav.after-spacer {
ha-md-list.after-spacer {
padding-top: 0;
min-height: fit-content;
}
ha-list-item-button {
ha-md-list-item {
flex-shrink: 0;
margin: 0 var(--ha-space-1) var(--ha-space-1);
box-sizing: border-box;
margin: var(--ha-space-1);
border-radius: var(--ha-border-radius-sm);
--ha-row-item-min-height: var(--ha-space-10);
--ha-row-item-padding-block: 0;
--ha-row-item-padding-inline: var(--ha-space-3);
--md-list-item-one-line-container-height: var(--ha-space-10);
--md-list-item-top-space: 0;
--md-list-item-bottom-space: 0;
width: var(--ha-space-12);
position: relative;
--md-list-item-label-text-color: var(--sidebar-text-color);
--md-list-item-leading-space: var(--ha-space-3);
--md-list-item-trailing-space: var(--ha-space-3);
--md-list-item-leading-icon-size: var(--ha-space-6);
transition: width var(--ha-animation-duration-normal) ease;
}
ha-list-item-button::part(headline) {
color: var(--sidebar-text-color);
}
:host([expanded]) ha-list-item-button {
:host([expanded]) ha-md-list-item {
width: 248px;
}
:host([narrow][expanded]) ha-list-item-button {
:host([narrow][expanded]) ha-md-list-item {
width: calc(240px - var(--safe-area-inset-left, 0px));
}
ha-list-item-button.selected::part(headline) {
color: var(--sidebar-selected-icon-color);
ha-md-list-item.selected {
--md-list-item-label-text-color: var(--sidebar-selected-icon-color);
--md-ripple-hover-color: var(--sidebar-selected-icon-color);
}
ha-list-item-button.selected::before {
ha-md-list-item.selected::before {
border-radius: var(--ha-border-radius-sm);
position: absolute;
top: 0;
@@ -783,12 +788,12 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
color: var(--sidebar-icon-color);
}
ha-list-item-button.selected ha-svg-icon[slot="start"],
ha-list-item-button.selected ha-icon[slot="start"] {
ha-md-list-item.selected ha-svg-icon[slot="start"],
ha-md-list-item.selected ha-icon[slot="start"] {
color: var(--sidebar-selected-icon-color);
}
ha-list-item-button .item-text {
ha-md-list-item .item-text {
display: block;
max-width: 0;
opacity: 0;
@@ -801,7 +806,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
max-width var(--ha-animation-duration-normal) ease,
opacity var(--ha-animation-duration-normal) ease;
}
:host([expanded]) ha-list-item-button .item-text {
:host([expanded]) ha-md-list-item .item-text {
max-width: 100%;
opacity: 1;
transition-delay: 0ms, 80ms;
@@ -843,13 +848,18 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
pointer-events: none;
}
ha-user-badge {
width: 40px;
height: 40px;
ha-md-list-item.user {
--md-list-item-leading-icon-size: var(--ha-space-10);
--md-list-item-leading-space: var(--ha-space-1);
}
ha-list-item-button.user {
--ha-row-item-padding-inline: var(--ha-space-1) 0;
ha-md-list-item.user.rtl {
--md-list-item-leading-space: var(--ha-space-3);
}
ha-user-badge {
flex-shrink: 0;
margin-right: calc(var(--ha-space-2) * -1);
}
.spacer {
@@ -864,8 +874,8 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
@media (prefers-reduced-motion: reduce) {
.menu,
ha-list-item-button,
ha-list-item-button .item-text,
ha-md-list-item,
ha-md-list-item .item-text,
.title {
transition: 1ms;
}
+13 -32
View File
@@ -1,46 +1,31 @@
import { consume, type ContextType } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { until } from "lit/directives/until";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import {
configContext,
connectionContext,
entitiesContext,
} from "../data/context";
import {
DEFAULT_DOMAIN_ICON,
entityIcon,
FALLBACK_DOMAIN_ICONS,
} from "../data/icons";
import type { HomeAssistant } from "../types";
import "./ha-icon";
import "./ha-svg-icon";
@customElement("ha-state-icon")
export class HaStateIcon extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
@property({ attribute: false }) public stateValue?: string;
@property() public icon?: string;
@state()
@consume({ context: configContext, subscribe: true })
protected _config?: ContextType<typeof configContext>;
@state()
@consume({ context: connectionContext, subscribe: true })
protected _connection?: ContextType<typeof connectionContext>;
@state()
@consume({ context: entitiesContext, subscribe: true })
protected _entities?: ContextType<typeof entitiesContext>;
protected render() {
const overrideIcon =
this.icon ||
(this.stateObj && this._entities?.[this.stateObj.entity_id]?.icon) ||
(this.stateObj && this.hass?.entities[this.stateObj.entity_id]?.icon) ||
this.stateObj?.attributes.icon;
if (overrideIcon) {
return html`<ha-icon .icon=${overrideIcon}></ha-icon>`;
@@ -48,21 +33,17 @@ export class HaStateIcon extends LitElement {
if (!this.stateObj) {
return nothing;
}
if (!this._config || !this._connection || !this._entities) {
if (!this.hass) {
return this._renderFallback();
}
const icon = entityIcon(
this._entities,
this._config.config,
this._connection.connection,
this.stateObj,
this.stateValue
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
const icon = entityIcon(this.hass, this.stateObj, this.stateValue).then(
(icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
}
return this._renderFallback();
});
);
return html`${until(icon)}`;
}
-4
View File
@@ -228,10 +228,6 @@ export class HaSwitch extends Switch {
outline: var(--wa-focus-ring);
outline-offset: var(--wa-focus-ring-offset);
}
:host(:empty) slot.label {
display: none;
}
`,
];
}
+1 -4
View File
@@ -1136,10 +1136,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
let rtl = false;
let showEntityId = false;
if (type === "area" || type === "floor") {
rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
rtl = computeRTL(this.hass);
hasFloor =
type === "area" && !!(item as FloorComboBoxItem).area?.floor_id;
}
+24 -23
View File
@@ -8,6 +8,7 @@ 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";
@@ -57,8 +58,15 @@ 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 {
@@ -118,14 +126,16 @@ 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}
@editor-save=${this._onEditorSave}
@blur=${this._onBlur}
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">
@@ -148,13 +158,9 @@ export class HaYamlEditor extends LitElement {
private _onChange(ev: CustomEvent): void {
ev.stopPropagation();
this._yaml = ev.detail.value;
let parsed: unknown;
let parsed;
let isValid = true;
let errorMsg: string | undefined;
let yamlError: {
mark?: { position: number; line: number; column: number };
message?: string;
} | null = null;
let errorMsg;
if (this._yaml) {
try {
@@ -162,13 +168,15 @@ 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._codeEditor?.setYamlError(yamlError);
this._error = errorMsg ?? "";
if (isValid) {
this._showingError = false;
}
this.value = parsed;
this.isValid = isValid;
@@ -180,23 +188,16 @@ 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);
+8 -2
View File
@@ -1,6 +1,8 @@
import { consume, type ContextType } from "@lit/context";
import { mdiMagnify } from "@mdi/js";
import { css, html, type PropertyValues } from "lit";
import { customElement } from "lit/decorators";
import { customElement, state } from "lit/decorators";
import { internationalizationContext } from "../../data/context";
import { HaInput } from "./ha-input";
/**
@@ -15,6 +17,10 @@ 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;
@@ -29,7 +35,7 @@ export class HaInputSearch extends HaInput {
!this.placeholder &&
(!this.hasUpdated || changedProps.has("_i18n"))
) {
this.placeholder = this.i18n?.localize?.("ui.common.search") || "Search";
this.placeholder = this._i18n.localize("ui.common.search");
}
}
+14 -37
View File
@@ -2,21 +2,19 @@ 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 {
css,
html,
LitElement,
nothing,
type PropertyValues,
type TemplateResult,
css,
html,
nothing,
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, query } 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";
@@ -127,10 +125,6 @@ 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",
@@ -239,22 +233,19 @@ export class HaInput extends WaInputMixin(LitElement) {
${this.renderStartDefault()}
</slot>
<slot name="end" slot="end"> ${this.renderEndDefault()} </slot>
<slot name="clear-button" slot="clear-button">
<ha-icon-button
@click=${this._handleClearClick}
.label=${this.i18n?.localize?.("ui.components.input.clear") ||
"Clear"}
.path=${mdiClose}
></ha-icon-button>
<slot name="clear-icon" slot="clear-icon">
<ha-icon-button .path=${mdiClose}></ha-icon-button>
</slot>
<slot name="password-toggle-button" slot="password-toggle-button">
<slot name="show-password-icon" slot="show-password-icon">
<ha-icon-button
@keydown=${stopPropagation}
@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}
.path=${mdiEye}
></ha-icon-button>
</slot>
<slot name="hide-password-icon" slot="hide-password-icon">
<ha-icon-button
@keydown=${stopPropagation}
.path=${mdiEyeOff}
></ha-icon-button>
</slot>
<div
@@ -302,14 +293,6 @@ export class HaInput extends WaInputMixin(LitElement) {
}
};
private _handleClearClick() {
this._input?.clear();
}
private _handlePasswordToggle() {
this.passwordVisible = !this.passwordVisible;
}
static styles = [
waInputStyles,
css`
@@ -431,12 +414,6 @@ 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;
-109
View File
@@ -1,109 +0,0 @@
import type { CSSResultGroup } from "lit";
import { css } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import "../list/types";
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);
}
fireEvent(this, "ha-list-item-register", { item: this });
}
public disconnectedCallback(): void {
super.disconnectedCallback();
fireEvent(this, "ha-list-item-unregister", { item: this });
}
/**
* 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;
}
}
-109
View File
@@ -1,109 +0,0 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import "../ha-ripple";
import { HaListItemBase } from "./ha-list-item-base";
/**
* @element ha-list-item-button
* @extends {HaListItemBase}
*
* @summary
* Interactive list row. Renders an inner `<a>` when `href` is set, otherwise
* a `<button>`. The full row is the hit target. When placed in a list using
* roving tabindex, the host is the tab stop and the inner element carries
* `tabindex="-1"`. For a non-interactive row, use `ha-list-item-base`.
*
* @csspart ripple - The ripple effect element.
*
* @attr {string} href - URL. When set, renders an `<a>` instead of a `<button>`.
* @attr {string} target - Anchor `target` attribute (requires `href`).
* @attr {string} rel - Anchor `rel` attribute (requires `href`).
* @attr {string} download - Anchor `download` attribute (requires `href`).
*/
@customElement("ha-list-item-button")
export class HaListItemButton extends HaListItemBase {
public override interactive = true;
@property({ type: String }) public href?: string;
@property({ type: String }) public target?: string;
@property({ type: String }) public rel?: string;
@property({ type: String }) public download?: string;
public override activate(): void {
this.renderRoot.querySelector<HTMLElement>("#item")?.click();
}
protected _renderBase(inner: TemplateResult): TemplateResult {
if (this.href !== undefined) {
return html`<a
part="base"
class="base interactive"
id="item"
href=${ifDefined(this.disabled ? undefined : this.href)}
target=${ifDefined(this.target)}
rel=${ifDefined(this.rel)}
download=${ifDefined(this.download)}
tabindex="-1"
aria-disabled=${this.disabled ? "true" : "false"}
>
${this._renderRipple()}${inner}
</a>`;
}
return html`<button
part="base"
class="base interactive"
id="item"
type="button"
?disabled=${this.disabled}
tabindex="-1"
>
${this._renderRipple()}${inner}
</button>`;
}
private _renderRipple() {
return html`<ha-ripple
part="ripple"
for="item"
?disabled=${this.disabled}
></ha-ripple>`;
}
static styles: CSSResultGroup = [
HaListItemBase.styles,
css`
:host {
cursor: pointer;
--ha-ripple-color: var(--primary-text-color);
}
:host([disabled]) {
cursor: default;
}
.base.interactive {
width: 100%;
border: none;
background: transparent;
color: inherit;
font: inherit;
text-align: inherit;
text-decoration: none;
appearance: none;
cursor: inherit;
}
:host([disabled]) .base.interactive {
color: var(--disabled-text-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-list-item-button": HaListItemButton;
}
}
-132
View File
@@ -1,132 +0,0 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../ha-checkbox";
import "../ha-ripple";
import { HaListItemBase } from "./ha-list-item-base";
export type HaListItemOptionAppearance = "line" | "checkbox";
export type HaListItemOptionSelectionPosition = "start" | "end";
/**
* @element ha-list-item-option
* @extends {HaListItemBase}
*
* @summary
* Selectable list row (role `option`). Selection state is driven by the parent
* `ha-list-selectable`; reflects `aria-selected`. When `appearance="checkbox"`, renders
* a decorative `<ha-checkbox>` (clicks on the row are handled by the listbox).
*
* @csspart checkbox - Wrapper around the `<ha-checkbox>` when `appearance="checkbox"`.
* @csspart ripple - The ripple effect element.
*
* @cssprop --ha-list-item-selected-background - Background color when selected (`appearance="line"`).
*
* @attr {boolean} selected - Whether the option is selected. Set by the parent `ha-list-selectable`.
* @attr {string} value - Value identifying the option.
* @attr {("line"|"checkbox")} appearance - Visual style. "line" highlights the row; "checkbox" renders an `ha-checkbox`.
* @attr {("start"|"end")} selection-position - Side the checkbox sits on when `appearance="checkbox"`.
*/
@customElement("ha-list-item-option")
export class HaListItemOption extends HaListItemBase {
@property({ type: Boolean, reflect: true }) public selected = false;
@property({ type: String }) public value?: string;
@property({ type: String, reflect: true })
public appearance: HaListItemOptionAppearance = "line";
@property({ type: String, reflect: true, attribute: "selection-position" })
public selectionPosition: HaListItemOptionSelectionPosition = "start";
protected override readonly defaultRole = "option";
public override interactive = true;
public update(changed: Map<string, unknown>) {
super.update(changed);
if (changed.has("selected")) {
this.setAttribute("aria-selected", this.selected ? "true" : "false");
}
if (changed.has("disabled")) {
this.setAttribute("aria-disabled", this.disabled ? "true" : "false");
}
}
protected _renderBase(inner: TemplateResult): TemplateResult {
return html`<div part="base" class="base" id="item">
${this._renderRipple()}${this.selectionPosition === "start"
? this._renderCheckbox()
: nothing}${inner}${this.selectionPosition === "end"
? this._renderCheckbox()
: nothing}
</div>`;
}
private _renderRipple() {
return html`<ha-ripple
part="ripple"
for="item"
?disabled=${this.disabled}
></ha-ripple>`;
}
private _renderCheckbox() {
if (this.appearance !== "checkbox") {
return nothing;
}
return html`<div part="checkbox" class="checkbox" inert>
<ha-checkbox
.checked=${this.selected}
.disabled=${this.disabled}
></ha-checkbox>
</div>`;
}
static styles: CSSResultGroup = [
HaListItemBase.styles,
css`
:host {
cursor: pointer;
--ha-ripple-color: var(--primary-text-color);
--ha-list-item-selected-background: var(
--ha-color-fill-primary-quiet-resting,
rgba(var(--rgb-primary-color), 0.08)
);
}
:host([disabled]) {
cursor: default;
}
.base {
cursor: inherit;
}
:host([appearance="line"][selected]:not([disabled])) .base,
:host([appearance="line"][active]:not([disabled])) .base {
background-color: var(--ha-list-item-selected-background);
}
:host([appearance="line"][selected]:not([disabled])) {
color: var(--primary-color);
}
.checkbox {
display: flex;
align-items: center;
flex: 0 0 auto;
pointer-events: none;
}
.checkbox ha-checkbox {
pointer-events: none;
}
ha-checkbox::part(base) {
gap: 0;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-list-item-option": HaListItemOption;
}
}
-189
View File
@@ -1,189 +0,0 @@
import { HasSlotController } from "@home-assistant/webawesome/dist/internal/slot";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } 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,
"headline",
"supporting-text",
"content"
);
@state() private _hasStart = false;
@state() private _hasEnd = false;
private _onSlotChange(name: "start" | "end") {
return (ev: Event) => {
const slot = ev.target as HTMLSlotElement;
const hasContent = slot
.assignedNodes({ flatten: true })
.some(
(node) =>
node.nodeType === Node.ELEMENT_NODE ||
(node.nodeType === Node.TEXT_NODE &&
(node as Text).textContent?.trim() !== "")
);
if (name === "start") {
this._hasStart = hasContent;
} else {
this._hasEnd = hasContent;
}
};
}
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 hasContent = this._slotController.test("content");
return html`
<div part="start" class="start" ?hidden=${!this._hasStart}>
<slot name="start" @slotchange=${this._onSlotChange("start")}></slot>
</div>
<div part="content" class="content">
${hasContent
? html`<slot name="content"></slot>`
: this._renderDefaultContent()}
</div>
<div part="end" class="end" ?hidden=${!this._hasEnd}>
<slot name="end" @slotchange=${this._onSlotChange("end")}></slot>
</div>
`;
}
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;
}
.start[hidden],
.end[hidden] {
display: none;
}
.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;
}
}
-313
View File
@@ -1,313 +0,0 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { tinykeys } from "tinykeys";
import { compareNodeOrder } from "../../common/dom/compare-node-order";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import type { HaListItemBase } from "../item/ha-list-item-base";
import "./types";
import type { HaListItemRegistrationDetail } from "./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). Tracks
* `HaListItemBase` descendants via the `ha-list-item-register` /
* `ha-list-item-unregister` events they fire on connect/disconnect — works
* across any nesting depth and shadow boundaries. 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);
this.addEventListener(
"ha-list-item-register",
this._onItemRegister as EventListener
);
this.addEventListener(
"ha-list-item-unregister",
this._onItemUnregister as EventListener
);
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unbindKeys?.();
this._unbindKeys = undefined;
this.removeEventListener("focusin", this._onFocusIn);
this.removeEventListener(
"ha-list-item-register",
this._onItemRegister as EventListener
);
this.removeEventListener(
"ha-list-item-unregister",
this._onItemUnregister as EventListener
);
}
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);
}
/**
* Hook called whenever the items array has changed. Subclasses can override
* to layer in extra bookkeeping (e.g. selection state sync).
*/
public updateListItems() {
this._recomputeFocusableIndexes();
if (
this._activeItemIndex >= this.items.length ||
!this._hasFocusableItem ||
this._activeItemIndex < 0
) {
this._activeItemIndex = this._firstFocusableIndex;
}
this._applyActive(false);
}
private _onItemRegister = (
ev: HASSDomEvent<HaListItemRegistrationDetail>
) => {
ev.stopPropagation();
const item = ev.detail.item;
if (this.items.includes(item)) {
return;
}
const next = [...this.items, item];
next.sort(compareNodeOrder);
this.items = next;
this.updateListItems();
};
private _onItemUnregister = (
ev: HASSDomEvent<HaListItemRegistrationDetail>
) => {
ev.stopPropagation();
const item = ev.detail.item;
if (!this.items.includes(item)) {
return;
}
this.items = this.items.filter((it) => it !== item);
this.updateListItems();
};
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;
}
protected render(): TemplateResult {
return html`<div part="base" class="base">
<slot></slot>
</div>`;
}
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;
}
}
-44
View File
@@ -1,44 +0,0 @@
import type { TemplateResult } from "lit";
import { html } from "lit";
import { customElement, property } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { HaListBase } from "./ha-list-base";
/**
* @element ha-list-nav
* @extends {HaListBase}
*
* @summary
* Navigation list. Wraps the list in a `<nav>` landmark. Items should be
* `<ha-list-item-button>` with an `href`. Use `aria-label` to name the landmark.
*
* @csspart nav - The `<nav>` wrapper.
* @csspart base - The inner `<div role="list">`.
*/
@customElement("ha-list-nav")
export class HaListNav extends HaListBase {
// No host role — the inner <nav> carries the landmark semantics, and the
// inner <div role="list"> preserves the list semantics for screen readers.
protected override readonly hostRole = "";
// The label is forwarded to the inner <nav>
@property({ type: String, attribute: "aria-label", reflect: false })
public override ariaLabel: string | null = null;
protected render(): TemplateResult {
return html`<nav
part="nav"
aria-label=${ifDefined(this.ariaLabel ?? undefined)}
>
<div part="base" class="base" role="list">
<slot></slot>
</div>
</nav>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-list-nav": HaListNav;
}
}
-212
View File
@@ -1,212 +0,0 @@
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import { HaListItemOption } from "../item/ha-list-item-option";
import { HaListBase } from "./ha-list-base";
import type { HaListSelectedDetail } from "./types";
/**
* @element ha-list-selectable
* @extends {HaListBase}
*
* @summary
* Selection list (role `listbox`). Items must be `<ha-list-item-option>`.
* Toggle single vs multi selection via the `multi` attribute.
*
* @attr {boolean} multi - Whether multiple options can be selected at once.
*
* @fires ha-list-selected - Fired when the selection changes. `detail: HaListSelectedDetail`.
*/
@customElement("ha-list-selectable")
export class HaListSelectable extends HaListBase {
@property({ type: Boolean, reflect: true }) public multi = false;
protected override readonly hostRole = "listbox";
private _selectedIndices?: Set<number>;
public connectedCallback(): void {
super.connectedCallback();
this.addEventListener("click", this._onOptionClick);
this.setAttribute("aria-multiselectable", this.multi ? "true" : "false");
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener("click", this._onOptionClick);
}
public updated(changed: Map<string, unknown>) {
super.updated(changed);
if (changed.has("multi")) {
this.setAttribute("aria-multiselectable", this.multi ? "true" : "false");
if (!this.multi && (this._selectedIndices?.size ?? 0) > 1) {
const first = Math.min(...this._selectedIndices!);
this._setSelection(new Set([first]));
}
}
}
/**
* Returns the current selection. `number` (or `-1` if nothing) when single,
* `Set<number>` when multi.
*/
public get selected(): number | Set<number> {
if (this.multi) {
return new Set(this._selectedIndices);
}
return (this._selectedIndices?.size ?? 0) === 0
? -1
: this._selectedIndices!.values().next().value!;
}
public get selectedItems(): HaListItemOption[] {
return this._sortedSelectedIndices()
.map((i) => this.items[i] as HaListItemOption | undefined)
.filter((it): it is HaListItemOption => !!it);
}
/** Replace the entire selection. */
public setSelected(indices: number | number[] | Set<number>): void {
const next =
typeof indices === "number"
? indices < 0
? new Set<number>()
: new Set([indices])
: new Set(indices);
if (!this.multi && next.size > 1) {
const first = Math.min(...next);
this._setSelection(new Set([first]));
return;
}
this._setSelection(next);
}
public select(index: number): void {
if (index < 0) {
return;
}
if (this.multi) {
const next = new Set(this._selectedIndices);
next.add(index);
this._setSelection(next);
} else {
this._setSelection(new Set([index]));
}
}
public toggle(index: number, force?: boolean): void {
if (index < 0) {
return;
}
if (this.multi) {
const next = new Set(this._selectedIndices);
const isSelected = next.has(index);
const shouldSelect = force !== undefined ? force : !isSelected;
if (shouldSelect) {
next.add(index);
} else {
next.delete(index);
}
this._setSelection(next);
} else {
const isSelected = this._selectedIndices!.has(index);
const shouldSelect = force !== undefined ? force : !isSelected;
this._setSelection(shouldSelect ? new Set([index]) : new Set());
}
}
public clearSelection(): void {
this._setSelection(new Set());
}
public updateListItems() {
super.updateListItems();
this._syncItemSelectedState();
}
private _sortedSelectedIndices(): number[] {
return [...this._selectedIndices!].sort((a, b) => a - b);
}
private _syncItemSelectedState() {
if (!this._selectedIndices) {
this._selectedIndices = new Set<number>();
this.items.forEach((item, i) => {
const opt = item as HaListItemOption;
if (opt.selected) {
this._selectedIndices!.add(i);
}
});
return;
}
this.items.forEach((item, i) => {
const opt = item as HaListItemOption;
const shouldBe = this._selectedIndices!.has(i);
if (opt.selected !== shouldBe) {
opt.selected = shouldBe;
}
});
}
private _setSelection(next: Set<number>): void {
const prev = this._selectedIndices!;
const added = new Set<number>();
const removed = new Set<number>();
next.forEach((i) => {
if (!prev.has(i)) {
added.add(i);
}
});
prev.forEach((i) => {
if (!next.has(i)) {
removed.add(i);
}
});
if (!added.size && !removed.size) {
return;
}
this._selectedIndices = next;
this._syncItemSelectedState();
const detail: HaListSelectedDetail = this.multi
? { index: new Set(next), diff: { added, removed } }
: {
index: next.size === 0 ? -1 : next.values().next().value!,
diff: { added, removed },
};
fireEvent(this, "ha-list-selected", detail);
}
private _onOptionClick = (ev: Event) => {
const path = ev.composedPath();
for (const el of path) {
if (el === this) {
return;
}
if (el instanceof HaListItemOption) {
const index = this.items.indexOf(el);
if (index < 0) {
return;
}
const item = this.items[index];
if (item.disabled) {
return;
}
if (this.multi) {
this.toggle(index);
} else {
this.select(index);
}
return;
}
}
};
}
declare global {
interface HTMLElementTagNameMap {
"ha-list-selectable": HaListSelectable;
}
}
-25
View File
@@ -1,25 +0,0 @@
import type { HaListItemBase } from "../item/ha-list-item-base";
export interface HaListSelectedDetail {
index: number | Set<number>;
diff?: { added: Set<number>; removed: Set<number> };
value?: string | string[];
}
export interface HaListActivatedDetail {
index: number;
item: HaListItemBase;
}
export interface HaListItemRegistrationDetail {
item: HaListItemBase;
}
declare global {
interface HASSDomEvents {
"ha-list-selected": HaListSelectedDetail;
"ha-list-activated": HaListActivatedDetail;
"ha-list-item-register": HaListItemRegistrationDetail;
"ha-list-item-unregister": HaListItemRegistrationDetail;
}
}
+1
View File
@@ -37,6 +37,7 @@ class HaEntityMarker extends LitElement {
></div>`
: this.showIcon && this.entityId
? html`<ha-state-icon
.hass=${this.hass}
.stateObj=${this.hass?.states[this.entityId]}
></ha-state-icon>`
: !this.entityUnit
@@ -76,7 +76,12 @@ class DialogJoinMediaPlayers extends LitElement {
const entityId = this._entityId;
return html`
<ha-dialog .open=${this._open} flexcontent @closed=${this._dialogClosed}>
<ha-dialog
.hass=${this.hass}
.open=${this._open}
flexcontent
@closed=${this._dialogClosed}
>
<ha-dialog-header show-border slot="header">
<ha-icon-button
.label=${this.hass.localize("ui.common.close")}
@@ -100,6 +100,7 @@ class DialogMediaManage extends LitElement {
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
?prevent-scrim-close=${this._uploading || this._deleting}
@closed=${this._dialogClosed}
@@ -77,6 +77,7 @@ class DialogMediaPlayerBrowse extends LitElement {
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
width="large"
flexcontent
@@ -59,10 +59,7 @@ class HaMediaPlayerToggle extends LitElement {
icon = mdiSpeakerPause;
}
const isRTL = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const isRTL = computeRTL(this.hass);
const { primary, secondary } = this._computeDisplayData(
this.entityId,
-70
View File
@@ -1,70 +0,0 @@
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;
}
}
-122
View File
@@ -1,122 +0,0 @@
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;
}
}
@@ -1,23 +1,8 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../../common/array/ensure-array";
import { fireEvent } from "../../../common/dom/fire_event";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import { getDeviceIntegrationLookup } from "../../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../../data/entity/entity";
import type { EntitySources } from "../../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../../data/entity/entity_sources";
import type { TargetSelector } from "../../../data/selector";
import {
filterSelectorDevices,
filterSelectorEntities,
} from "../../../data/selector";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import type { HomeAssistant } from "../../../types";
import type { HaDevicePickerDeviceFilterFunc } from "../../device/ha-device-picker";
import "../../ha-dialog";
import "../../ha-dialog-header";
import "../../ha-icon-button";
@@ -25,7 +10,6 @@ import "../../ha-icon-next";
import "../../ha-md-list";
import "../../ha-md-list-item";
import "../../ha-svg-icon";
import "../../list/ha-list-base";
import "../ha-target-picker-item-row";
import type { TargetDetailsDialogParams } from "./show-dialog-target-details";
@@ -37,12 +21,6 @@ class DialogTargetDetails extends LitElement implements HassDialog {
@state() private _opened = false;
@state() private _entitySources?: EntitySources;
@state() private _entitySourcesLoaded = false;
private _deviceIntegrationLookup = memoizeOne(getDeviceIntegrationLookup);
public showDialog(params: TargetDetailsDialogParams): void {
this._params = params;
this._opened = true;
@@ -56,72 +34,6 @@ class DialogTargetDetails extends LitElement implements HassDialog {
private _dialogClosed() {
fireEvent(this, "dialog-closed", { dialog: this.localName });
this._params = undefined;
this._entitySources = undefined;
this._entitySourcesLoaded = false;
}
private _hasIntegration(selector: TargetSelector) {
return (
(selector.target?.entity &&
ensureArray(selector.target.entity).some((e) => e.integration)) ||
(selector.target?.device &&
ensureArray(selector.target.device).some((d) => d.integration))
);
}
protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (!changedProperties.has("_params")) {
return;
}
if (
this._params?.selector &&
this._hasIntegration(this._params.selector) &&
!this._entitySourcesLoaded
) {
this._loadEntitySources();
}
}
private async _loadEntitySources(): Promise<void> {
try {
this._entitySources = await fetchEntitySourcesWithCache(this.hass);
} catch (err) {
// eslint-disable-next-line no-console
console.error("Failed to load entity sources for target details", err);
} finally {
this._entitySourcesLoaded = true;
}
}
private _filterEntities = (entity: HassEntity): boolean => {
const target = this._selectorTarget();
if (!target?.entity) {
return true;
}
return ensureArray(target.entity).some((e) =>
filterSelectorEntities(e, entity, this._entitySources)
);
};
private _filterDevices = (device: DeviceRegistryEntry): boolean => {
const target = this._selectorTarget();
if (!target?.device) {
return true;
}
const deviceIntegrations = this._entitySources
? this._deviceIntegrationLookup(
this._entitySources,
Object.values(this.hass.entities)
)
: undefined;
return ensureArray(target.device).some((d) =>
filterSelectorDevices(d, device, deviceIntegrations)
);
};
private _selectorTarget() {
return this._params?.selector?.target || null;
}
protected render() {
@@ -129,86 +41,33 @@ class DialogTargetDetails extends LitElement implements HassDialog {
return nothing;
}
let deviceFilter: HaDevicePickerDeviceFilterFunc | undefined;
let entityFilter: HaEntityPickerEntityFilterFunc | undefined;
let includeDomains: string[] | undefined;
let includeDeviceClasses: string[] | undefined;
let primaryEntitiesOnly: boolean | undefined;
if (this._params.selector) {
deviceFilter = this._filterDevices;
entityFilter = this._filterEntities;
primaryEntitiesOnly = this._params.selector.target?.primary_entities_only;
} else {
deviceFilter = this._params.deviceFilter;
entityFilter = this._params.entityFilter;
includeDomains = this._params.includeDomains;
includeDeviceClasses = this._params.includeDeviceClasses;
primaryEntitiesOnly = this._params.primaryEntitiesOnly;
}
const waitingForSources =
this._params.selector &&
this._hasIntegration(this._params.selector) &&
!this._entitySourcesLoaded;
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._opened}
header-title=${this.hass.localize(
"ui.components.target-picker.target_details"
)}
header-subtitle=${`${this.hass.localize(
`ui.components.target-picker.type.${this._params.type}`
)}:
${this._params.title}`}
@closed=${this._dialogClosed}
>
<div class="type-wrapper">
<div class="type-label">
${this.hass.localize(
`ui.components.target-picker.type.${this._params.type}`
)}
</div>
<ha-list-base
.ariaLabel=${`${this.hass.localize(`ui.components.target-picker.type.${this._params.type}`)}: ${this._params.title}`}
wrap-focus
>
${waitingForSources
? nothing
: html`
<ha-target-picker-item-row
.hass=${this.hass}
.type=${this._params.type}
.itemId=${this._params.itemId}
.deviceFilter=${deviceFilter}
.entityFilter=${entityFilter}
.includeDomains=${includeDomains}
.includeDeviceClasses=${includeDeviceClasses}
.primaryEntitiesOnly=${primaryEntitiesOnly}
expand
></ha-target-picker-item-row>
`}
</ha-list-base>
</div>
<ha-target-picker-item-row
.hass=${this.hass}
.type=${this._params.type}
.itemId=${this._params.itemId}
.deviceFilter=${this._params.deviceFilter}
.entityFilter=${this._params.entityFilter}
.includeDomains=${this._params.includeDomains}
.includeDeviceClasses=${this._params.includeDeviceClasses}
.primaryEntitiesOnly=${this._params.primaryEntitiesOnly}
expand
></ha-target-picker-item-row>
</ha-dialog>
`;
}
static styles = css`
.type-wrapper {
display: flex;
flex-direction: column;
border-radius: var(--ha-border-radius-xl);
border: var(--ha-border-width-sm) solid
var(--ha-color-border-neutral-normal);
overflow: hidden;
}
.type-label {
background-color: var(--ha-color-surface-low);
padding: var(--ha-space-1) var(--ha-space-3);
font-weight: var(--ha-font-weight-bold);
display: flex;
align-items: center;
height: 20px;
}
`;
}
declare global {
@@ -1,14 +1,14 @@
import { fireEvent } from "../../../common/dom/fire_event";
import type { HaEntityPickerEntityFilterFunc } from "../../../data/entity/entity";
import type { TargetSelector } from "../../../data/selector";
import type { TargetType } from "../../../data/target";
import type { HaDevicePickerDeviceFilterFunc } from "../../device/ha-device-picker";
export type NewBackupType = "automatic" | "manual";
export interface TargetDetailsDialogParams {
title: string;
type: TargetType;
itemId: string;
selector?: TargetSelector;
deviceFilter?: HaDevicePickerDeviceFilterFunc;
entityFilter?: HaEntityPickerEntityFilterFunc;
includeDomains?: string[];
@@ -5,7 +5,7 @@ import type { TargetType, TargetTypeFloorless } from "../../data/target";
import type { HomeAssistant } from "../../types";
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
import "../ha-expansion-panel";
import "../list/ha-list-base";
import "../ha-md-list";
import "./ha-target-picker-item-row";
@customElement("ha-target-picker-item-group")
@@ -66,25 +66,23 @@ export class HaTargetPickerItemGroup extends LitElement {
}
)}
</div>
<ha-list-base>
${Object.entries(this.items).map(([type, items]) =>
items
? items.map(
(item) =>
html`<ha-target-picker-item-row
.hass=${this.hass}
.type=${type as TargetTypeFloorless}
.itemId=${item}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
></ha-target-picker-item-row>`
)
: nothing
)}
</ha-list-base>
${Object.entries(this.items).map(([type, items]) =>
items
? items.map(
(item) =>
html`<ha-target-picker-item-row
.hass=${this.hass}
.type=${type as TargetTypeFloorless}
.itemId=${item}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
></ha-target-picker-item-row>`
)
: nothing
)}
</ha-expansion-panel>`;
}
@@ -98,7 +96,7 @@ export class HaTargetPickerItemGroup extends LitElement {
--expansion-panel-content-padding: 0;
}
ha-expansion-panel::part(summary) {
background-color: var(--ha-color-surface-low);
background-color: var(--ha-color-fill-neutral-quiet-resting);
padding: var(--ha-space-1) var(--ha-space-2);
font-weight: var(--ha-font-weight-bold);
color: var(--secondary-text-color);
@@ -106,6 +104,9 @@ export class HaTargetPickerItemGroup extends LitElement {
justify-content: space-between;
min-height: unset;
}
ha-md-list {
padding: 0;
}
`;
}
@@ -1,24 +1,14 @@
import { consume } from "@lit/context";
import {
mdiChevronLeft,
mdiChevronRight,
mdiClose,
mdiDevices,
mdiHome,
mdiLabel,
mdiMinusBox,
mdiTextureBox,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import {
css,
html,
LitElement,
nothing,
type PropertyValues,
type TemplateResult,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
@@ -48,17 +38,18 @@ import {
type ExtractFromTargetResultReferenced,
type TargetType,
} from "../../data/target";
import { showMoreInfoDialog } from "../../dialogs/more-info/show-ha-more-info-dialog";
import { buttonLinkStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
import { floorDefaultIconPath } from "../ha-floor-icon";
import "../ha-icon-button";
import "../ha-md-list";
import type { HaMdList } from "../ha-md-list";
import "../ha-md-list-item";
import type { HaMdListItem } from "../ha-md-list-item";
import "../ha-state-icon";
import "../ha-svg-icon";
import "../item/ha-list-item-base";
import "../item/ha-list-item-button";
import { showTargetDetailsDialog } from "./dialog/show-dialog-target-details";
@customElement("ha-target-picker-item-row")
@@ -74,9 +65,6 @@ export class HaTargetPickerItemRow extends LitElement {
@property({ type: Boolean, attribute: "sub-entry", reflect: true })
public subEntry = false;
@property({ attribute: false })
public subLevel = 0;
@property({ type: Boolean, attribute: "hide-context" })
public hideContext = false;
@@ -118,6 +106,12 @@ export class HaTargetPickerItemRow extends LitElement {
@consume({ context: labelsContext, subscribe: true })
_labelRegistry!: LabelRegistryEntry[];
@query("ha-md-list-item") public item?: HaMdListItem;
@query("ha-md-list") public list?: HaMdList;
@query("ha-target-picker-item-row") public itemRow?: HaTargetPickerItemRow;
protected willUpdate(changedProps: PropertyValues<this>) {
if (!this.subEntry && changedProps.has("itemId")) {
this._updateItemData();
@@ -143,130 +137,101 @@ export class HaTargetPickerItemRow extends LitElement {
const replaceable = !this.subEntry && !this.expand;
const content = html`
<div class="icon" slot="start">
${iconPath
? html`<ha-icon .icon=${iconPath}></ha-icon>`
: this._iconImg
? html`<img
alt=${this._domainName || ""}
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
: this.type === "entity"
? html`
<ha-state-icon
.stateObj=${stateObject ||
({
entity_id: this.itemId,
attributes: {},
} as HassEntity)}
>
</ha-state-icon>
`
: nothing}
</div>
return html`
<ha-md-list-item
type=${replaceable ? "button" : "text"}
class=${classMap({
error: notFound,
replaceable,
})}
@click=${replaceable ? this._replaceItem : undefined}
>
<div class="icon" slot="start">
${this.subEntry
? html`
<div class="horizontal-line-wrapper">
<div class="horizontal-line"></div>
</div>
`
: nothing}
${iconPath
? html`<ha-icon .icon=${iconPath}></ha-icon>`
: this._iconImg
? html`<img
alt=${this._domainName || ""}
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
: this.type === "entity"
? html`
<ha-state-icon
.hass=${this.hass}
.stateObj=${stateObject ||
({
entity_id: this.itemId,
attributes: {},
} as HassEntity)}
>
</ha-state-icon>
`
: nothing}
</div>
<div slot="headline">${name}</div>
${notFound || (context && !this.hideContext)
? html`<span slot="supporting-text"
>${notFound
? this.hass.localize(
`ui.components.target-picker.${this.type}_not_found`
)
: context}</span
>`
: nothing}
${stateObject && this.subEntry
? html`<span slot="supporting-text" class="state"
>${this.hass.formatEntityState(stateObject)}</span
>`
: nothing}
${!this.subEntry && entries && showEntities
? html`
<div slot="end" class="summary">
${showEntities &&
!this.expand &&
entries?.referenced_entities.length
? html`<button class="main link" @click=${this._openDetails}>
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries?.referenced_entities.length,
}
)}
</button>`
: showEntities
? html`<span class="main">
<div slot="headline">${name}</div>
${notFound || (context && !this.hideContext)
? html`<span slot="supporting-text"
>${notFound
? this.hass.localize(
`ui.components.target-picker.${this.type}_not_found`
)
: context}</span
>`
: nothing}
${this._domainName && this.subEntry
? html`<span slot="supporting-text" class="domain"
>${this._domainName}</span
>`
: nothing}
${!this.subEntry && entries && showEntities
? html`
<div slot="end" class="summary">
${showEntities &&
!this.expand &&
entries?.referenced_entities.length
? html`<button class="main link" @click=${this._openDetails}>
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries?.referenced_entities.length,
}
)}
</span>`
: nothing}
</div>
`
: nothing}
${!this.expand && !this.subEntry
? html`
<ha-icon-button
.path=${mdiClose}
slot="end"
@click=${this._removeItem}
></ha-icon-button>
`
: this.subEntry && this.type === "entity"
? html`
<ha-svg-icon
.path=${computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? mdiChevronLeft
: mdiChevronRight}
slot="end"
></ha-svg-icon>
</button>`
: showEntities
? html`<span class="main">
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries?.referenced_entities.length,
}
)}
</span>`
: nothing}
</div>
`
: nothing}
`;
let item: TemplateResult;
if (replaceable || (this.subEntry && this.type === "entity")) {
item = html`
<ha-list-item-button
class=${classMap({
error: notFound,
replaceable,
})}
@click=${replaceable
? this._replaceItem
: this.subEntry && this.type === "entity"
? this._openMoreInfo
: undefined}
>
${content}
</ha-list-item-button>
`;
} else {
item = html`
<ha-list-item-base
class=${classMap({
error: notFound,
})}
>
${content}
</ha-list-item-base>
`;
}
return html`
${item}
${!this.expand && !this.subEntry
? html`
<ha-icon-button
.path=${mdiClose}
slot="end"
@click=${this._removeItem}
></ha-icon-button>
`
: nothing}
</ha-md-list-item>
${this.expand && entries && entries.referenced_entities
? this._renderEntries()
: nothing}
@@ -276,10 +241,6 @@ export class HaTargetPickerItemRow extends LitElement {
private _renderEntries() {
const entries = this.parentEntries || this._entries;
if (!entries || entries.referenced_entities.length === 0) {
return this._renderEmptyEntries();
}
let nextType: TargetType =
this.type === "floor"
? "area"
@@ -389,64 +350,54 @@ export class HaTargetPickerItemRow extends LitElement {
) || ([] as string[]),
}));
const nextSubLevel = this.subLevel + 1;
return html`
${rows1.map(
(itemId, index) => html`
<ha-target-picker-item-row
sub-entry
.subLevel=${nextSubLevel}
style=${`--sub-entry-indent: calc(${nextSubLevel} * var(--ha-space-10));`}
.hass=${this.hass}
.type=${nextType}
.itemId=${itemId}
.parentEntries=${rows1Entries?.[index]}
.hideContext=${this.hideContext || this.type !== "label"}
expand
></ha-target-picker-item-row>
`
)}
${deviceRows.map(
(itemId, index) => html`
<ha-target-picker-item-row
sub-entry
.subLevel=${nextSubLevel}
style=${`--sub-entry-indent: calc(${nextSubLevel} * var(--ha-space-10));`}
.hass=${this.hass}
type="device"
.itemId=${itemId}
.parentEntries=${deviceRowsEntries?.[index]}
.hideContext=${this.hideContext || this.type !== "label"}
expand
></ha-target-picker-item-row>
`
)}
${entityRows.map(
(itemId) => html`
<ha-target-picker-item-row
sub-entry
.subLevel=${nextSubLevel}
style=${`--sub-entry-indent: calc(${nextSubLevel} * var(--ha-space-10));`}
.hass=${this.hass}
type="entity"
.itemId=${itemId}
.hideContext=${this.hideContext || this.type !== "label"}
></ha-target-picker-item-row>
`
)}
<div class="entries-tree">
<div class="line-wrapper">
<div class="line"></div>
</div>
<ha-md-list class="entries">
${rows1.map(
(itemId, index) => html`
<ha-target-picker-item-row
sub-entry
.hass=${this.hass}
.type=${nextType}
.itemId=${itemId}
.parentEntries=${rows1Entries?.[index]}
.hideContext=${this.hideContext || this.type !== "label"}
expand
></ha-target-picker-item-row>
`
)}
${deviceRows.map(
(itemId, index) => html`
<ha-target-picker-item-row
sub-entry
.hass=${this.hass}
type="device"
.itemId=${itemId}
.parentEntries=${deviceRowsEntries?.[index]}
.hideContext=${this.hideContext || this.type !== "label"}
expand
></ha-target-picker-item-row>
`
)}
${entityRows.map(
(itemId) => html`
<ha-target-picker-item-row
sub-entry
.hass=${this.hass}
type="entity"
.itemId=${itemId}
.hideContext=${this.hideContext || this.type !== "label"}
></ha-target-picker-item-row>
`
)}
</ha-md-list>
</div>
`;
}
private _renderEmptyEntries() {
return html`<ha-list-item-base>
<ha-svg-icon .path=${mdiMinusBox} slot="start" class="icon"></ha-svg-icon>
<span slot="headline"
>${this.hass.localize("ui.components.target-picker.no_targets")}</span
>
</ha-list-item-base>`;
}
private async _updateItemData() {
if (this.type === "entity") {
this._entries = undefined;
@@ -615,14 +566,7 @@ export class HaTargetPickerItemRow extends LitElement {
const areaName = area ? computeAreaName(area) : undefined;
const context = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(
computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? " ◂ "
: " ▸ "
);
.join(computeRTL(this.hass) ? " ◂ " : " ▸ ");
return {
name: entityName || deviceName || item,
context,
@@ -696,12 +640,6 @@ export class HaTargetPickerItemRow extends LitElement {
});
}
private _openMoreInfo = () => {
showMoreInfoDialog(this, {
entityId: this.itemId,
});
};
static styles = [
buttonLinkStyle,
css`
@@ -713,6 +651,12 @@ export class HaTargetPickerItemRow extends LitElement {
--md-list-item-two-line-container-height: 56px;
}
:host([expand]:not([sub-entry])) ha-md-list-item {
border: 2px solid var(--ha-color-border-neutral-loud);
background-color: var(--ha-color-fill-neutral-quiet-resting);
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
}
.error {
background: var(--ha-color-fill-warning-quiet-resting);
}
@@ -736,7 +680,6 @@ export class HaTargetPickerItemRow extends LitElement {
.icon {
width: 24px;
display: flex;
color: var(--ha-color-on-neutral-normal);
}
img {
@@ -754,21 +697,53 @@ export class HaTargetPickerItemRow extends LitElement {
line-height: var(--ha-line-height-condensed);
}
:host([sub-entry]) .summary {
margin-inline-start: var(--ha-space-12);
margin-right: var(--ha-space-12);
}
.summary .main {
font-weight: var(--ha-font-weight-medium);
}
:host([expand]) .summary .main {
color: var(--ha-color-text-secondary);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-normal);
}
.summary .secondary {
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
}
.entries-tree {
display: flex;
position: relative;
}
.entries-tree .line-wrapper {
padding: var(--ha-space-5);
}
.entries-tree .line-wrapper .line {
border-left: 2px dashed var(--divider-color);
height: calc(100% - 28px);
position: absolute;
top: 0;
}
:host([sub-entry]) .entries-tree .line-wrapper .line {
height: calc(100% - 12px);
top: -18px;
}
.entries {
padding: 0;
--md-item-overflow: visible;
}
.horizontal-line-wrapper {
position: relative;
}
.horizontal-line-wrapper .horizontal-line {
position: absolute;
top: 11px;
margin-inline-start: -28px;
width: 29px;
border-top: 2px dashed var(--divider-color);
}
button.link {
text-decoration: none;
color: var(--primary-color);
@@ -779,19 +754,12 @@ export class HaTargetPickerItemRow extends LitElement {
text-decoration: underline;
}
.state {
.domain {
width: fit-content;
font-size: var(--ha-font-size-s);
color: var(--ha-color-text-secondary);
}
ha-list-item-button::part(end) {
gap: var(--ha-space-2);
}
:host([sub-entry]) ha-list-item-button::part(base),
:host([sub-entry]) ha-list-item-base::part(base) {
padding-inline-start: var(--sub-entry-indent);
border-radius: var(--ha-border-radius-md);
background-color: var(--ha-color-fill-neutral-quiet-resting);
padding: var(--ha-space-1);
font-family: var(--ha-font-family-code);
}
`,
];
@@ -76,6 +76,7 @@ export class HaTargetPickerValueChip extends LitElement {
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
: stateObject
? html`<ha-state-icon
.hass=${this.hass}
.stateObj=${stateObject}
></ha-state-icon>`
: nothing}
+1 -2
View File
@@ -99,8 +99,7 @@ export class HaTileContainer extends LitElement {
display: flex;
flex-direction: row;
align-items: center;
padding: 0 10px;
min-height: var(--row-height, 56px);
padding: 10px;
flex: 1;
min-width: 0;
box-sizing: border-box;
+13 -27
View File
@@ -3,6 +3,7 @@ import { dump } from "js-yaml";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import type { Trigger } from "../../data/automation";
import { migrateAutomationTrigger } from "../../data/automation";
@@ -22,10 +23,9 @@ import "../../panels/logbook/ha-logbook-renderer";
import type { HomeAssistant } from "../../types";
import "../ha-code-editor";
import "../ha-icon-button";
import "../ha-tab-group";
import "../ha-tab-group-tab";
import "./hat-logbook-note";
import type { NodeInfo } from "./hat-script-graph";
import { traceTabStyles } from "./trace-tab-styles";
const TRACE_PATH_TABS = [
"step_config",
@@ -66,21 +66,21 @@ export class HaTracePathDetails extends LitElement {
${this._renderSelectedTraceInfo()}
</div>
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
<div class="tabs top">
${TRACE_PATH_TABS.map(
(view) => html`
<ha-tab-group-tab
slot="nav"
.active=${this._view === view}
.panel=${view}
<button
.view=${view}
class=${classMap({ active: this._view === view })}
@click=${this._showTab}
>
${this.hass!.localize(
`ui.panel.config.automation.trace.tabs.${view}`
)}
</ha-tab-group-tab>
</button>
`
)}
</ha-tab-group>
</div>
${this._view === "step_config"
? this._renderSelectedConfig()
: this._view === "changed_variables"
@@ -308,12 +308,7 @@ export class HaTracePathDetails extends LitElement {
? this.hass!.localize(
"ui.panel.config.automation.trace.path.no_variables_changed"
)
: html`<ha-code-editor
read-only
dir="ltr"
.hass=${this.hass}
.value=${dump(trace.changed_variables).trimEnd()}
></ha-code-editor>`}
: html`<pre>${dump(trace.changed_variables).trimEnd()}</pre>`}
`
)}
</div>
@@ -388,12 +383,13 @@ export class HaTracePathDetails extends LitElement {
</div>`;
}
private _handleTabChanged(ev: CustomEvent) {
this._view = ev.detail.name as typeof this._view;
private _showTab(ev) {
this._view = ev.target.view;
}
static get styles(): CSSResultGroup {
return [
traceTabStyles,
css`
.padded-box {
margin: 16px;
@@ -410,16 +406,6 @@ export class HaTracePathDetails extends LitElement {
.error {
color: var(--error-color);
}
ha-tab-group {
background-color: var(--primary-background-color);
border-top: 1px solid var(--divider-color);
border-bottom: 1px solid var(--divider-color);
}
ha-tab-group-tab::part(base) {
padding: 2px 16px;
}
`,
];
}
+1 -5
View File
@@ -18,7 +18,6 @@ import { toggleAttribute } from "../../common/dom/toggle_attribute";
import { fullEntitiesContext } from "../../data/context";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import type { LogbookEntry } from "../../data/logbook";
import { localizeTriggerDescription } from "../../data/logbook";
import type {
ChooseAction,
IfAction,
@@ -333,10 +332,7 @@ class ActionRenderer {
: "other",
alias: triggerStep.changed_variables.trigger?.alias,
triggeredPath: triggerStep.path === "trigger" ? "manual" : "trigger",
trigger: localizeTriggerDescription(
this.hass.localize,
this.trace.trigger
),
trigger: this.trace.trigger,
time: formatDateTimeWithSeconds(
new Date(triggerStep.timestamp),
this.hass.locale,
+40
View File
@@ -0,0 +1,40 @@
import { css } from "lit";
export const traceTabStyles = css`
.tabs {
background-color: var(--primary-background-color);
border-top: 1px solid var(--divider-color);
border-bottom: 1px solid var(--divider-color);
display: flex;
padding-left: 4px;
padding-inline-start: 4px;
padding-inline-end: initial;
}
.tabs.top {
border-top: none;
}
.tabs > * {
padding: 2px 16px;
cursor: pointer;
position: relative;
bottom: -1px;
border: none;
border-bottom: 2px solid transparent;
user-select: none;
background: none;
color: var(--primary-text-color);
outline: none;
transition: background 15ms linear;
}
.tabs > *.active {
border-bottom-color: var(--primary-color);
}
.tabs > *:focus,
.tabs > *:hover {
background: var(--secondary-background-color);
}
`;
-14
View File
@@ -189,20 +189,6 @@ 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,
+2 -2
View File
@@ -1,11 +1,11 @@
import type { HomeAssistant } from "../types";
export interface ValidConfig {
interface ValidConfig {
valid: true;
error: null;
}
export interface InvalidConfig {
interface InvalidConfig {
valid: false;
error: string;
}
-1
View File
@@ -164,7 +164,6 @@ export interface BatterySourceTypeEnergyPreference {
stat_energy_to: string;
stat_rate?: string; // always available if power_config is set
power_config?: PowerConfig;
stat_soc?: string;
}
export interface GasSourceTypeEnergyPreference {
type: "gas";
+1 -4
View File
@@ -96,10 +96,7 @@ export const getEntities = (
const domainName = domainToName(hass.localize, computeDomain(entityId));
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const isRTL = computeRTL(hass);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
+9 -26
View File
@@ -456,13 +456,11 @@ const getIconFromTranslations = (
};
export const entityIcon = async (
entities: HomeAssistant["entities"],
hassConfig: HomeAssistant["config"],
hassConnection: Connection,
hass: HomeAssistant,
stateObj: HassEntity,
state?: string
) => {
const entry = entities?.[stateObj.entity_id] as
const entry = hass.entities?.[stateObj.entity_id] as
| EntityRegistryDisplayEntry
| undefined;
if (entry?.icon) {
@@ -470,14 +468,7 @@ export const entityIcon = async (
}
const domain = computeStateDomain(stateObj);
return getEntityIcon(
hassConfig,
hassConnection,
domain,
stateObj,
state,
entry
);
return getEntityIcon(hass, domain, stateObj, state, entry);
};
export const entryIcon = async (
@@ -489,19 +480,11 @@ export const entryIcon = async (
}
const stateObj = hass.states[entry.entity_id] as HassEntity | undefined;
const domain = computeDomain(entry.entity_id);
return getEntityIcon(
hass.config,
hass.connection,
domain,
stateObj,
undefined,
entry
);
return getEntityIcon(hass, domain, stateObj, undefined, entry);
};
const getEntityIcon = async (
hassConfig: HomeAssistant["config"],
hassConnection: Connection,
hass: HomeAssistant,
domain: string,
stateObj?: HassEntity,
stateValue?: string,
@@ -515,8 +498,8 @@ const getEntityIcon = async (
let icon: string | undefined;
if (translation_key && platform) {
const platformIcons = await getPlatformIcons(
hassConfig,
hassConnection,
hass.config,
hass.connection,
platform
);
if (platformIcons) {
@@ -532,8 +515,8 @@ const getEntityIcon = async (
if (!icon) {
const entityComponentIcons = await getComponentIcons(
hassConnection,
hassConfig,
hass.connection,
hass.config,
domain
);
if (entityComponentIcons) {
-43
View File
@@ -195,49 +195,6 @@ export const localizeTriggerSource = (
return source;
};
// Mapping from a phrase key to the bare-phrase translation key (without the
// "triggered by" prefix), used by localizeTriggerDescription below.
const triggerDescriptionKeys: Record<
TriggerPhraseKeys,
| "numeric_state_of"
| "state_of"
| "event"
| "time"
| "time_pattern"
| "homeassistant_stopping"
| "homeassistant_starting"
> = {
triggered_by_numeric_state_of: "numeric_state_of",
triggered_by_state_of: "state_of",
triggered_by_event: "event",
triggered_by_time_pattern: "time_pattern",
triggered_by_time: "time",
triggered_by_homeassistant_stopping: "homeassistant_stopping",
triggered_by_homeassistant_starting: "homeassistant_starting",
};
// Like localizeTriggerSource, but returns just the bare localized trigger
// description (without the "triggered by" prefix). Used where the surrounding
// template already supplies its own "triggered by" wording.
export const localizeTriggerDescription = (
localize: LocalizeFunc,
source: string
) => {
for (const triggerPhraseKey of Object.keys(
triggerPhrases
) as TriggerPhraseKeys[]) {
const phrase = triggerPhrases[triggerPhraseKey];
if (source.startsWith(phrase)) {
const bareKey = triggerDescriptionKeys[triggerPhraseKey];
return source.replace(
phrase,
`${localize(`ui.components.logbook.${bareKey}`)}`
);
}
}
return source;
};
export const localizeStateMessage = (
hass: HomeAssistant,
localize: LocalizeFunc,
+4 -7
View File
@@ -154,7 +154,7 @@ export const getRecorderInfo = (conn: Connection) =>
});
export const getStatisticIds = (
hass: Pick<HomeAssistant, "callWS">,
hass: HomeAssistant,
statistic_type?: "mean" | "sum"
) =>
hass.callWS<StatisticsMetaData[]>({
@@ -227,7 +227,7 @@ export const fetchStatistic = (
rolling_window: period.rolling_window,
});
export const validateStatistics = (hass: Pick<HomeAssistant, "callWS">) =>
export const validateStatistics = (hass: HomeAssistant) =>
hass.callWS<StatisticsValidationResults>({
type: "recorder/validate_statistics",
});
@@ -245,10 +245,7 @@ export const updateStatisticsMetadata = (
unit_class,
});
export const clearStatistics = (
hass: Pick<HomeAssistant, "callWS">,
statistic_ids: string[]
) =>
export const clearStatistics = (hass: HomeAssistant, statistic_ids: string[]) =>
hass.callWS<undefined>({
type: "recorder/clear_statistics",
statistic_ids,
@@ -372,5 +369,5 @@ export const getDisplayUnit = (
export const isExternalStatistic = (statisticsId: string): boolean =>
statisticsId.includes(":");
export const updateStatisticsIssues = (hass: Pick<HomeAssistant, "callWS">) =>
export const updateStatisticsIssues = (hass: HomeAssistant) =>
hass.callWS<undefined>({ type: "recorder/update_statistics_issues" });
+1 -26
View File
@@ -98,30 +98,5 @@ export const formatSelectorValue = (
.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(", ");
return ensureArray(value).join(", ");
};
@@ -58,6 +58,7 @@ class DialogConfigEntrySystemOptions extends LitElement {
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${this.hass.localize(
"ui.dialogs.config_entry_system_options.title",
@@ -3,13 +3,10 @@ import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
import "../../components/ha-dialog";
import "../../components/ha-dialog-footer";
import "../../components/ha-icon-button";
import type { DataEntryFlowStep } from "../../data/data_entry_flow";
import {
@@ -43,33 +40,14 @@ interface FlowUpdateEvent {
stepPromise?: Promise<DataEntryFlowStep>;
}
interface FlowStepFooterStateChangedEvent {
loading?: boolean;
hasPendingUpdates?: boolean;
}
interface FormStepElement extends HTMLElement {
submit(): Promise<void>;
}
interface AbortStepElement extends HTMLElement {
close(): void;
}
interface CreateEntryStepElement extends HTMLElement {
finish(): Promise<void>;
}
declare global {
// for fire event
interface HASSDomEvents {
"flow-update": FlowUpdateEvent;
"flow-step-footer-state-changed": FlowStepFooterStateChangedEvent;
}
// for add event listener
interface HTMLElementEventMap {
"flow-update": HASSDomEvent<FlowUpdateEvent>;
"flow-step-footer-state-changed": HASSDomEvent<FlowStepFooterStateChangedEvent>;
}
}
@@ -95,16 +73,6 @@ class DataEntryFlowDialog extends LitElement {
@state() private _handler?: string;
@state() private _formStepLoading = false;
@state() private _createEntryHasPendingUpdates = false;
private _formStepRef = createRef<FormStepElement>();
private _abortStepRef = createRef<AbortStepElement>();
private _createEntryStepRef = createRef<CreateEntryStepElement>();
private _unsubDataEntryFlowProgress?: UnsubscribeFunc;
public async showDialog(params: DataEntryFlowDialogParams): Promise<void> {
@@ -333,6 +301,7 @@ class DataEntryFlowDialog extends LitElement {
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
prevent-scrim-close
@after-show=${this._focusFormStep}
@@ -397,14 +366,11 @@ class DataEntryFlowDialog extends LitElement {
${this._step.type === "form"
? html`
<step-flow-form
${ref(this._formStepRef)}
autofocus
narrow
.flowConfig=${this._params.flowConfig}
.step=${this._step}
.hass=${this.hass}
@flow-step-footer-state-changed=${this
._handleFooterStateChanged}
></step-flow-form>
`
: this._step.type === "external"
@@ -418,7 +384,6 @@ class DataEntryFlowDialog extends LitElement {
: this._step.type === "abort"
? html`
<step-flow-abort
${ref(this._abortStepRef)}
.params=${this._params}
.step=${this._step}
.hass=${this.hass}
@@ -446,14 +411,11 @@ class DataEntryFlowDialog extends LitElement {
`
: html`
<step-flow-create-entry
${ref(this._createEntryStepRef)}
.flowConfig=${this._params.flowConfig}
.step=${this._step}
.hass=${this.hass}
.navigateToResult=${this._params
.navigateToResult ?? false}
@flow-step-footer-state-changed=${this
._handleFooterStateChanged}
.devices=${this._devices(
this._params.flowConfig.showDevices,
Object.values(this.hass.devices),
@@ -464,95 +426,10 @@ class DataEntryFlowDialog extends LitElement {
`}
`}
</div>
${this._renderFooter()}
</ha-dialog>
`;
}
private _renderFooter() {
if (!this._step || this._loading) {
return nothing;
}
switch (this._step.type) {
case "form":
return html`
<ha-dialog-footer slot="footer">
<ha-button
slot="primaryAction"
.loading=${this._formStepLoading}
@click=${this._submitFormStep}
>
${this._params!.flowConfig.renderShowFormStepSubmitButton(
this.hass,
this._step
)}
</ha-button>
</ha-dialog-footer>
`;
case "abort":
return this._step.reason === "missing_credentials"
? nothing
: html`
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this._closeAbortStep}
>
${this.hass.localize(
"ui.panel.config.integrations.config_flow.close"
)}
</ha-button>
</ha-dialog-footer>
`;
case "external":
return html`
<ha-dialog-footer slot="footer">
<ha-button
slot="primaryAction"
href=${this._step.url}
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.integrations.config_flow.external_step.open_site"
)}
</ha-button>
</ha-dialog-footer>
`;
case "create_entry": {
const devices = this._devices(
this._params!.flowConfig.showDevices,
Object.values(this.hass.devices),
this._step.result?.entry_id,
this._params!.carryOverDevices
);
return html`
<ha-dialog-footer slot="footer">
<ha-button
slot="primaryAction"
@click=${this._finishCreateEntryStep}
>
${this.hass.localize(
`ui.panel.config.integrations.config_flow.${
!devices.length ||
this._createEntryHasPendingUpdates ||
devices.some((device) => device.area_id)
? "finish"
: "finish_skip"
}`
)}
</ha-button>
</ha-dialog-footer>
`;
}
default:
return nothing;
}
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.addEventListener("flow-update", (ev) => {
@@ -602,8 +479,6 @@ class DataEntryFlowDialog extends LitElement {
}
this._step = undefined;
this._formStepLoading = false;
this._createEntryHasPendingUpdates = false;
await this.updateComplete;
this._step = _step;
if (
@@ -687,36 +562,20 @@ class DataEntryFlowDialog extends LitElement {
}
await this.updateComplete;
this._formStepRef.value?.focus();
};
private _handleFooterStateChanged = (
ev: HASSDomEvent<FlowStepFooterStateChangedEvent>
) => {
if (ev.detail.loading !== undefined) {
this._formStepLoading = ev.detail.loading;
}
if (ev.detail.hasPendingUpdates !== undefined) {
this._createEntryHasPendingUpdates = ev.detail.hasPendingUpdates;
}
};
private _submitFormStep = () => {
this._formStepRef.value?.submit();
};
private _closeAbortStep = () => {
this._abortStepRef.value?.close();
};
private _finishCreateEntryStep = () => {
this._createEntryStepRef.value?.finish();
(
this.renderRoot.querySelector(
"step-flow-form[autofocus]"
) as HTMLElement | null
)?.focus();
};
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-dialog {
--dialog-content-padding: 0;
}
.dialog-title {
overflow: hidden;
text-overflow: ellipsis;
@@ -18,7 +18,7 @@ import "../../../components/ha-slider";
import "../../../components/ha-time-input";
import "../../../components/input/ha-input";
import { isTiltOnly } from "../../../data/cover";
import { isUnavailableState, UNAVAILABLE } from "../../../data/entity/entity";
import { isUnavailableState } from "../../../data/entity/entity";
import type { ImageEntity } from "../../../data/image";
import { computeImageUrl } from "../../../data/image";
import "../../../panels/lovelace/components/hui-timestamp-display";
@@ -266,7 +266,7 @@ class EntityPreviewRow extends LitElement {
<div class="numberflex">
<ha-slider
labeled
.disabled=${stateObj.state === UNAVAILABLE}
.disabled=${isUnavailableState(stateObj.state)}
.step=${Number(stateObj.attributes.step)}
.min=${Number(stateObj.attributes.min)}
.max=${Number(stateObj.attributes.max)}
@@ -280,7 +280,7 @@ class EntityPreviewRow extends LitElement {
: html`<div class="numberflex numberstate">
<ha-input
auto-validate
.disabled=${stateObj.state === UNAVAILABLE}
.disabled=${isUnavailableState(stateObj.state)}
pattern="[0-9]+([\\.][0-9]+)?"
.step=${Number(stateObj.attributes.step)}
.min=${Number(stateObj.attributes.min)}
@@ -303,7 +303,7 @@ class EntityPreviewRow extends LitElement {
<ha-select
.label=${computeStateName(stateObj)}
.value=${stateObj.state}
.disabled=${stateObj.state === UNAVAILABLE}
.disabled=${isUnavailableState(stateObj.state)}
.options=${stateObj.attributes.options?.map((option) => ({
value: option,
label: this.hass!.formatEntityState(stateObj, option),
+8 -4
View File
@@ -8,6 +8,7 @@ import type { HomeAssistant } from "../../types";
import { showConfigFlowDialog } from "./show-dialog-config-flow";
import type { DataEntryFlowDialogParams } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles";
import "../../components/ha-button";
@customElement("step-flow-abort")
class StepFlowAbort extends LitElement {
@@ -36,6 +37,13 @@ class StepFlowAbort extends LitElement {
<div class="content">
${this.params.flowConfig.renderAbortDescription(this.hass, this.step)}
</div>
<div class="buttons">
<ha-button appearance="plain" @click=${this._flowDone}
>${this.hass.localize(
"ui.panel.config.integrations.config_flow.close"
)}</ha-button
>
</div>
`;
}
@@ -60,10 +68,6 @@ class StepFlowAbort extends LitElement {
fireEvent(this, "flow-update", { step: undefined });
}
public close(): void {
this._flowDone();
}
static get styles(): CSSResultGroup {
return configFlowContentStyles;
}
@@ -10,6 +10,7 @@ import {
import { computeDomain } from "../../common/entity/compute_domain";
import { navigate } from "../../common/navigate";
import "../../components/ha-area-picker";
import "../../components/ha-button";
import "../../components/input/ha-input";
import type { HaInput } from "../../components/input/ha-input";
import { assistSatelliteSupportsSetupFlow } from "../../data/assist_satellite";
@@ -30,10 +31,6 @@ import { showVoiceAssistantSetupDialog } from "../voice-assistant-setup/show-voi
import type { FlowConfig } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles";
interface DeviceTarget {
device: string;
}
@customElement("step-flow-create-entry")
class StepFlowCreateEntry extends LitElement {
@property({ attribute: false }) public flowConfig!: FlowConfig;
@@ -195,18 +192,20 @@ class StepFlowCreateEntry extends LitElement {
</div>
`}
</div>
<div class="buttons">
<ha-button @click=${this._flowDone}
>${localize(
`ui.panel.config.integrations.config_flow.${
!this.devices.length || Object.keys(this._deviceUpdate).length
? "finish"
: "finish_skip"
}`
)}</ha-button
>
</div>
`;
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (changedProps.has("_deviceUpdate")) {
fireEvent(this, "flow-step-footer-state-changed", {
hasPendingUpdates: Object.keys(this._deviceUpdate).length > 0,
});
}
}
private async _loadDomains() {
const entries = await getConfigEntries(this.hass);
this._domains = Object.fromEntries(
@@ -225,20 +224,18 @@ class StepFlowCreateEntry extends LitElement {
return updateDeviceRegistryEntry(this.hass, deviceId, {
name_by_user: update.name,
area_id: update.area,
}).catch((err: unknown) => {
const message =
err instanceof Error ? err.message : "Unknown error";
}).catch((err: any) => {
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_flow.error_saving_device",
{ error: message }
{ error: err.message }
),
});
});
}
);
await Promise.allSettled(deviceUpdates);
const entityUpdates: Promise<unknown>[] = [];
const entityUpdates: Promise<any>[] = [];
const entityIds: string[] = [];
renamedDevices.forEach((deviceId) => {
const entities = this._deviceEntities(
@@ -284,15 +281,8 @@ class StepFlowCreateEntry extends LitElement {
}
}
public finish(): Promise<void> {
return this._flowDone();
}
private async _areaPicked(ev: ValueChangedEvent<string>) {
const picker = ev.currentTarget as DeviceTarget | null;
if (!picker) {
return;
}
const picker = ev.currentTarget as any;
const device = picker.device;
const area = ev.detail.value;
@@ -304,11 +294,8 @@ class StepFlowCreateEntry extends LitElement {
}
private _deviceNameChanged(ev: InputEvent): void {
const picker = ev.currentTarget as (HaInput & DeviceTarget) | null;
if (!picker) {
return;
}
const device = picker.device;
const picker = ev.currentTarget as HaInput;
const device = (picker as any).device;
const name = picker.value;
if (!(device in this._deviceUpdate)) {
@@ -324,13 +311,22 @@ class StepFlowCreateEntry extends LitElement {
css`
.devices {
display: flex;
gap: var(--ha-space-2);
margin: -4px;
max-height: 600px;
overflow-y: auto;
flex-direction: column;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
.devices {
/* header - margin content - footer */
max-height: calc(100vh - 52px - 20px - 52px);
}
}
.device {
border: 1px solid var(--divider-color);
padding: 6px;
border-radius: var(--ha-border-radius-sm);
margin: 4px;
display: inline-block;
}
.device-info {
@@ -356,6 +352,11 @@ class StepFlowCreateEntry extends LitElement {
ha-input {
margin: var(--ha-space-2) 0;
}
.buttons > *:last-child {
margin-left: auto;
margin-inline-start: auto;
margin-inline-end: initial;
}
.error {
color: var(--error-color);
}
+23 -2
View File
@@ -1,10 +1,11 @@
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { DataEntryFlowStepExternal } from "../../data/data_entry_flow";
import type { HomeAssistant } from "../../types";
import type { FlowConfig } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles";
import "../../components/ha-button";
@customElement("step-flow-external")
class StepFlowExternal extends LitElement {
@@ -15,9 +16,18 @@ class StepFlowExternal extends LitElement {
@property({ attribute: false }) public step!: DataEntryFlowStepExternal;
protected render(): TemplateResult {
const localize = this.hass.localize;
return html`
<div class="content">
${this.flowConfig.renderExternalStepDescription(this.hass, this.step)}
<div class="open-button">
<ha-button href=${this.step.url} target="_blank" rel="noreferrer">
${localize(
"ui.panel.config.integrations.config_flow.external_step.open_site"
)}
</ha-button>
</div>
</div>
`;
}
@@ -28,7 +38,18 @@ class StepFlowExternal extends LitElement {
}
static get styles(): CSSResultGroup {
return [configFlowContentStyles];
return [
configFlowContentStyles,
css`
.open-button {
text-align: center;
padding: 24px 0;
}
.open-button a {
text-decoration: none;
}
`,
];
}
}
+37 -47
View File
@@ -1,11 +1,11 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../common/dom/fire_event";
import { isNavigationClick } from "../../common/dom/is-navigation-click";
import "../../components/ha-button";
import "../../components/ha-alert";
import { computeInitialHaFormData } from "../../components/ha-form/compute-initial-ha-form-data";
import "../../components/ha-form/ha-form";
@@ -19,7 +19,7 @@ import { autocompleteLoginFields } from "../../data/auth";
import type { DataEntryFlowStepForm } from "../../data/data_entry_flow";
import { previewModule } from "../../data/preview";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import type { HomeAssistant } from "../../types";
import type { FlowConfig } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles";
@@ -47,8 +47,6 @@ class StepFlowForm extends LitElement {
private _errors?: Record<string, string>;
private _formRef = createRef<HTMLElementTagNameMap["ha-form"]>();
static shadowRootOptions: ShadowRootInit = {
...LitElement.shadowRootOptions,
delegatesFocus: true,
@@ -90,27 +88,24 @@ class StepFlowForm extends LitElement {
${this.flowConfig.renderShowFormStepDescription(this.hass, this.step)}
${this._errorMsg
? html`<ha-alert alert-type="error">${this._errorMsg}</ha-alert>`
: nothing}
${step.data_schema.length
? html`<ha-form
${ref(this._formRef)}
?autofocus=${this.autoFocus}
.hass=${this.hass}
.narrow=${this.narrow}
.data=${stepData}
.disabled=${this._loading}
@value-changed=${this._stepDataChanged}
.schema=${autocompleteLoginFields(
this.handleReadOnlyFields(step.data_schema)
)}
.error=${this._errors}
.computeLabel=${this._labelCallback}
.computeHelper=${this._helperCallback}
.computeError=${this._errorCallback}
.localizeValue=${this._localizeValueCallback}
.context=${{ handler: step.handler }}
></ha-form>`
: nothing}
: ""}
<ha-form
?autofocus=${this.autoFocus}
.hass=${this.hass}
.narrow=${this.narrow}
.data=${stepData}
.disabled=${this._loading}
@value-changed=${this._stepDataChanged}
.schema=${autocompleteLoginFields(
this.handleReadOnlyFields(step.data_schema)
)}
.error=${this._errors}
.computeLabel=${this._labelCallback}
.computeHelper=${this._helperCallback}
.computeError=${this._errorCallback}
.localizeValue=${this._localizeValueCallback}
.context=${{ handler: step.handler }}
></ha-form>
</div>
${step.preview
? html`<div class="preview" @set-flow-errors=${this._setError}>
@@ -130,6 +125,14 @@ class StepFlowForm extends LitElement {
})}
</div>`
: nothing}
<div class="buttons">
<ha-button @click=${this._submitStep} .loading=${this._loading}>
${this.flowConfig.renderShowFormStepSubmitButton(
this.hass,
this.step
)}
</ha-button>
</div>
`;
}
@@ -142,17 +145,8 @@ class StepFlowForm extends LitElement {
this.addEventListener("keydown", this._handleKeyDown);
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (changedProps.has("_loading")) {
fireEvent(this, "flow-step-footer-state-changed", {
loading: this._loading,
});
}
}
public override focus(_options?: FocusOptions): void {
this._formRef.value?.focus();
this.renderRoot.querySelector("ha-form")?.focus();
}
protected willUpdate(changedProps: PropertyValues): void {
@@ -235,15 +229,13 @@ class StepFlowForm extends LitElement {
const flowId = this.step.flow_id;
const toSendData: Record<string, unknown> = {};
const toSendData = {};
Object.keys(stepData).forEach((key) => {
const value = stepData[key];
const isEmpty = [undefined, ""].includes(value);
const field = this.step.data_schema?.find((f) => f.name === key);
const selector = (field as HaFormSelector)?.selector ?? {};
const read_only = (
Object.values(selector)[0] as { read_only?: boolean } | null | undefined
)?.read_only;
const read_only = (Object.values(selector)[0] as any)?.read_only;
if (!isEmpty && !read_only) {
toSendData[key] = value;
}
@@ -285,13 +277,7 @@ class StepFlowForm extends LitElement {
}
}
public submit(): Promise<void> {
return this._submitStep();
}
private _stepDataChanged(
ev: ValueChangedEvent<Record<string, unknown>>
): void {
private _stepDataChanged(ev: CustomEvent): void {
this._stepData = ev.detail.value;
}
@@ -335,9 +321,13 @@ class StepFlowForm extends LitElement {
ha-alert,
ha-form {
margin-top: var(--ha-space-6);
margin-top: 24px;
display: block;
}
.buttons {
padding: 16px;
}
`,
];
}
+3 -3
View File
@@ -1,5 +1,5 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../components/ha-spinner";
import type { DataEntryFlowStep } from "../../data/data_entry_flow";
@@ -28,7 +28,7 @@ class StepFlowLoading extends LitElement {
return html`
<div class="content">
<ha-spinner size="large"></ha-spinner>
${description ? html`<div>${description}</div>` : nothing}
${description ? html`<div>${description}</div>` : ""}
</div>
`;
}
@@ -40,7 +40,7 @@ class StepFlowLoading extends LitElement {
text-align: center;
}
ha-spinner {
margin-bottom: var(--ha-space-4);
margin-bottom: 16px;
}
`;
}
+6 -6
View File
@@ -78,7 +78,7 @@ class StepFlowMenu extends LitElement {
);
return html`
${description ? html`<div class="content">${description}</div>` : nothing}
${description ? html`<div class="content">${description}</div>` : ""}
<div class="options">
${options.map(
(option) => html`
@@ -119,17 +119,17 @@ class StepFlowMenu extends LitElement {
configFlowContentStyles,
css`
.options {
margin-top: var(--ha-space-5);
margin-bottom: var(--ha-space-4);
margin-top: 20px;
margin-bottom: 16px;
}
.content {
padding-bottom: var(--ha-space-4);
padding-bottom: 16px;
}
.content + .options {
margin-top: var(--ha-space-2);
margin-top: 8px;
}
ha-list-item {
--mdc-list-side-padding: var(--ha-space-6);
--mdc-list-side-padding: 24px;
}
`,
];
@@ -53,7 +53,7 @@ class StepFlowProgress extends LitElement {
text-align: center;
}
ha-spinner {
margin-bottom: var(--ha-space-4);
margin-bottom: 16px;
}
`,
];
+17 -3
View File
@@ -2,9 +2,12 @@ import { css } from "lit";
export const configFlowContentStyles = css`
h2 {
margin: var(--ha-space-6) var(--ha-space-10) 0 0;
margin: 24px 38px 0 0;
margin-inline-start: 0px;
margin-inline-end: var(--ha-space-10);
margin-inline-end: 38px;
padding: 0 24px;
padding-inline-start: 24px;
padding-inline-end: 24px;
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: var(--ha-font-smoothing);
font-family: var(
@@ -25,8 +28,19 @@ export const configFlowContentStyles = css`
.content,
.preview {
margin-top: var(--ha-space-5);
margin-top: 20px;
padding: 0 24px;
}
.buttons {
position: relative;
padding: 16px;
margin: 8px 0 0;
color: var(--primary-color);
display: flex;
justify-content: flex-end;
}
ha-markdown {
overflow-wrap: break-word;
}
@@ -103,6 +103,7 @@ export class ListItemsDialog
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${this._params.title ?? " "}
@closed=${this._dialogClosed}
@@ -112,6 +112,7 @@ export class DialogEnterCode
if (isText) {
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${this._dialogParams.title ??
this.hass.localize("ui.dialogs.enter_code.title")}
@@ -149,6 +150,7 @@ export class DialogEnterCode
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${this._dialogParams.title ?? "Enter code"}
width="small"
+6 -76
View File
@@ -1,7 +1,5 @@
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";
@@ -9,15 +7,9 @@ import "../../components/ha-dialog-footer";
import "../../components/ha-dialog";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import type { HassDialog, ShowDialogParams } from "../make-dialog-manager";
import type { HassDialog } 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
@@ -33,8 +25,6 @@ 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 || {};
@@ -46,41 +36,11 @@ 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;
@@ -89,44 +49,14 @@ export class DialogForm
private _submit(): void {
this._closeState = "submitted";
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 });
this._params?.submit?.(this._data);
this.closeDialog();
}
private _cancel(): void {
this._closeState = "canceled";
const cancel = this._params?.cancel;
const nestedField = this._popStack();
cancel?.();
if (!nestedField) {
this.closeDialog();
}
this._params?.cancel?.();
this.closeDialog();
}
private _valueChanged(ev: CustomEvent): void {
@@ -140,6 +70,7 @@ export class DialogForm
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${this._params.title}
prevent-scrim-close
@@ -153,7 +84,6 @@ 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">
@@ -96,6 +96,7 @@ export class HaImagecropperDialog
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${this.hass.localize(
"ui.dialogs.image_cropper.crop_image"
@@ -148,6 +148,7 @@ class DialogLightColorFavorite extends LitElement {
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
.headerTitle=${this._dialogParams?.title}
@closed=${this._dialogClosed}

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