Compare commits

...
Author SHA1 Message Date
Aidan Timson 0932f068bc Describe spinner delays as context-specific 2026-10-29 14:28:08 +00:00
Aidan Timson 589c5281e6 Add loading state guidance to the components skill 2026-10-29 13:27:28 +00:00
WendelinandClaude Opus 5.5 6134056776 Migrate the device, area and integration lists to ha-list-base (#55445)
* Migrate the config device, area and integration lists from ha-list

Moves the device child, via and linked devices cards, the device
entities card, the area page, the Matter fabrics and ping dialogs, the
Thread border routers, the Z-Wave remove node dialog and the Z-Wave
config entry picker from the mwc-based ha-list and ha-list-item to
ha-list-base, ha-list-nav, ha-list-item-button and ha-list-item-base.

- Render links as ha-list-item-button with an href instead of an anchor
  around a list item, so the rows take part in keyboard navigation
- Render automations and scenes that are not editable as
  ha-list-item-base with the existing tooltip, as on the device page
- Wrap the area scripts and the Thread border routers in a list, as
  the other sections already were
- Only render the remove fabric button where mwc showed it, and give it
  a label
- Keep the list padding and row alignment of the previous lists

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

* Add aria labels and adjust padding for ha-list components in device and integration dialogs

* Refactor aria labels for related lists in ha-config-area-page and update translations

---------

Co-authored-by: Claude <[email protected]>
2026-10-29 14:09:58 +01:00
13 changed files with 478 additions and 365 deletions

No files matched your search

+31 -1
View File
@@ -1,6 +1,6 @@
---
name: ha-frontend-components
description: Home Assistant frontend component patterns. Use when implementing or reviewing dialogs, ha-form, ha-alert, keyboard shortcuts, tooltips, panels, Lovelace cards, or ha-button usage.
description: Home Assistant frontend component patterns. Use when implementing or reviewing dialogs, ha-form, ha-alert, loading states, keyboard shortcuts, tooltips, panels, Lovelace cards, or ha-button usage.
---
# HA Frontend Components
@@ -91,6 +91,36 @@ html`
`;
```
## Loading States
Pick the indicator by what is loading, following the [Loading pattern](https://contributors.home-assistant.io/design/patterns/loading/):
- Use a skeleton from `src/components/skeleton/` (`ha-skeleton`, `ha-skeleton-text`, `ha-skeleton-icon`) when content loads into a layout you already know, such as table rows, tile text, or a log. It holds the layout, so nothing jumps when the content arrives.
- Use `ha-spinner` only when a whole area loads and its shape is unknown.
- Use `ha-button` with `loading`, or `ha-progress-button`, for an action started from a button. Don't put a separate spinner next to it.
Wrap the indicator in `ha-fade-in` from `src/components/animation/ha-fade-in.ts`:
- Fade a skeleton in straight away over about 300ms (`.duration=${300}`), as the data table does. It stands in for the content, so a delay would leave an empty gap.
- Delay a spinner or progress bar, usually by `.delay=${500}`, so a load that finishes within that time never flashes it. Some slower areas, such as backups and updates, use `${1000}`.
- Don't fade in the loaded content itself.
Give a skeleton group the `progressbar` role and a translated label, as `ha-spinner` has, rather than visible "Loading" text. A loading state always ends in the content, an empty state, or an error.
```ts
html`
<ha-fade-in .duration=${300} easing="ease-in">
<div
role="progressbar"
aria-label=${this._i18n.localize("ui.common.loading")}
>
<ha-skeleton-text></ha-skeleton-text>
</div>
</ha-fade-in>
<ha-fade-in .delay=${500}><ha-spinner></ha-spinner></ha-fade-in>
`;
```
## Shortcuts And Tooltips
Use `ShortcutManager` from `src/common/keyboard/shortcuts.ts` for keyboard shortcuts. It blocks shortcuts in input fields, can prevent shortcuts during text selection, and supports character and KeyCode shortcuts for non-latin keyboards. See `src/state/quick-bar-mixin.ts` for global shortcut examples.
+1 -1
View File
@@ -40,7 +40,7 @@ Never run `tsc` or `pnpm lint:types` with file arguments. When `tsc` receives fi
Detailed guidance lives in `.agents/skills/<name>/SKILL.md`. Load every matching skill before detailed implementation or review. For reviews, load `ha-frontend-review` alongside all companions that apply to the changed code or behavior:
- `ha-frontend-contexts`: Lit contexts, `hass` migration, and rerender-sensitive state access.
- `ha-frontend-components`: dialogs, forms, alerts, shortcuts, tooltips, panels, and Lovelace cards.
- `ha-frontend-components`: dialogs, forms, alerts, loading states, shortcuts, tooltips, panels, and Lovelace cards.
- `ha-frontend-events`: event handler typing, custom event dispatch, and event-map declarations.
- `ha-frontend-types`: backend data contracts, optional schemas, shared types, assertions, and lifecycle types.
- `ha-frontend-lit`: reactive fields, DOM queries, lifecycle behavior, and render-derived state.
+121 -83
View File
@@ -16,7 +16,6 @@ import type { HassEntity } from "home-assistant-js-websocket/dist/types";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { consume } from "../../../common/decorators/consume";
@@ -38,10 +37,12 @@ import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-button-next";
import "../../../components/ha-icon-next";
import "../../../components/ha-list";
import "../../../components/ha-list-item";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
import "../../../components/item/ha-list-item-base";
import "../../../components/item/ha-list-item-button";
import "../../../components/list/ha-list-base";
import "../../../components/list/ha-list-nav";
import type { AreaRegistryEntry } from "../../../data/area/area_registry";
import {
deleteAreaRegistryEntry,
@@ -375,18 +376,22 @@ class HaConfigAreaPage extends LitElement {
.header=${this.hass.localize("ui.panel.config.devices.caption")}
>${
devices.length
? html`<ha-list>
? html`<ha-list-nav
.ariaLabel=${this.hass.localize(
"ui.panel.config.devices.caption"
)}
>
${devices.map(
(device) => html`
<a href="/config/devices/device/${device.id}">
<ha-list-item hasMeta>
<span>${device.name}</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>
<ha-list-item-button
.href=${`/config/devices/device/${device.id}`}
.headline=${device.name}
>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`
)}
</ha-list>`
</ha-list-nav>`
: html`
<div class="no-entries">
${this.hass.localize("ui.panel.config.devices.no_devices")}
@@ -402,20 +407,23 @@ class HaConfigAreaPage extends LitElement {
>
${
nonAutomatedEntities.length
? html`<ha-list>
? html`<ha-list-base
aria-label=${this.hass.localize(
"ui.panel.config.areas.editor.linked_entities_caption"
)}
>
${nonAutomatedEntities.map(
(entity) => html`
<ha-list-item
<ha-list-item-button
@click=${this._openEntity}
.entity=${entity}
hasMeta
.headline=${entity.name ?? undefined}
>
<span>${entity.name}</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`
)}</ha-list
>`
)}
</ha-list-base>`
: html`
<div class="no-entries">
${this.hass.localize(
@@ -444,14 +452,19 @@ class HaConfigAreaPage extends LitElement {
"ui.panel.config.areas.assigned_to_area"
)}:
</h3>
<ha-list>
<ha-list-nav
.ariaLabel=${this.hass.localize(
"ui.panel.config.areas.related_lists.automations_assigned",
{ area: area.name }
)}
>
${groupedAutomations.map((automation) =>
this._renderAutomation(
automation.name,
automation.entity
)
)}</ha-list
>`
)}
</ha-list-nav>`
: ""
}
${
@@ -461,14 +474,19 @@ class HaConfigAreaPage extends LitElement {
"ui.panel.config.areas.targeting_area"
)}:
</h3>
<ha-list>
<ha-list-nav
.ariaLabel=${this.hass.localize(
"ui.panel.config.areas.related_lists.automations_targeting",
{ area: area.name }
)}
>
${relatedAutomations.map((automation) =>
this._renderAutomation(
automation.name,
automation.entity
)
)}</ha-list
>`
)}
</ha-list-nav>`
: ""
}
${
@@ -502,11 +520,16 @@ class HaConfigAreaPage extends LitElement {
"ui.panel.config.areas.assigned_to_area"
)}:
</h3>
<ha-list>
<ha-list-nav
.ariaLabel=${this.hass.localize(
"ui.panel.config.areas.related_lists.scenes_assigned",
{ area: area.name }
)}
>
${groupedScenes.map((scene) =>
this._renderScene(scene.name, scene.entity)
)}</ha-list
>`
)}
</ha-list-nav>`
: ""
}
${
@@ -516,11 +539,16 @@ class HaConfigAreaPage extends LitElement {
"ui.panel.config.areas.targeting_area"
)}:
</h3>
<ha-list>
<ha-list-nav
.ariaLabel=${this.hass.localize(
"ui.panel.config.areas.related_lists.scenes_targeting",
{ area: area.name }
)}
>
${relatedScenes.map((scene) =>
this._renderScene(scene.name, scene.entity)
)}</ha-list
>`
)}
</ha-list-nav>`
: ""
}
${
@@ -554,9 +582,16 @@ class HaConfigAreaPage extends LitElement {
"ui.panel.config.areas.assigned_to_area"
)}:
</h3>
${groupedScripts.map((script) =>
this._renderScript(script.name, script.entity)
)}`
<ha-list-nav
.ariaLabel=${this.hass.localize(
"ui.panel.config.areas.related_lists.scripts_assigned",
{ area: area.name }
)}
>
${groupedScripts.map((script) =>
this._renderScript(script.name, script.entity)
)}
</ha-list-nav>`
: ""
}
${
@@ -566,9 +601,16 @@ class HaConfigAreaPage extends LitElement {
"ui.panel.config.areas.targeting_area"
)}:
</h3>
${relatedScripts.map((script) =>
this._renderScript(script.name, script.entity)
)}`
<ha-list-nav
.ariaLabel=${this.hass.localize(
"ui.panel.config.areas.related_lists.scripts_targeting",
{ area: area.name }
)}
>
${relatedScripts.map((script) =>
this._renderScript(script.name, script.entity)
)}
</ha-list-nav>`
: ""
}
${
@@ -753,47 +795,41 @@ class HaConfigAreaPage extends LitElement {
}
private _renderScene(name: string, entityState: SceneEntity) {
return html`<ha-tooltip
.for="scene-${slugify(entityState.entity_id)}"
.distance=${-4}
.disabled=${!!entityState.attributes.id}
>
${this.hass.localize("ui.panel.config.devices.cant_edit")}
</ha-tooltip>
<a
.id="scene-${slugify(entityState.entity_id)}"
href=${ifDefined(
entityState.attributes.id
? `/config/scene/edit/${entityState.attributes.id}`
: undefined
)}
>
<ha-list-item .disabled=${!entityState.attributes.id} hasMeta>
<span>${name}</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a> `;
return entityState.attributes.id
? this._renderLink(
name,
`/config/scene/edit/${entityState.attributes.id}`
)
: this._renderNotEditable(
name,
`scene-${slugify(entityState.entity_id)}`
);
}
private _renderAutomation(name: string, entityState: AutomationEntity) {
return html`<a
id="automation-${slugify(entityState.entity_id)}"
href=${ifDefined(
entityState.attributes.id
? `/config/automation/edit/${encodeURIComponent(entityState.attributes.id)}`
: undefined
)}
>
<ha-list-item .disabled=${!entityState.attributes.id} hasMeta>
<span>${name}</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>
<ha-tooltip
for="automation-${slugify(entityState.entity_id)}"
.disabled=${!!entityState.attributes.id}
.distance=${-4}
>${this.hass.localize("ui.panel.config.devices.cant_edit")}
return entityState.attributes.id
? this._renderLink(
name,
`/config/automation/edit/${encodeURIComponent(entityState.attributes.id)}`
)
: this._renderNotEditable(
name,
`automation-${slugify(entityState.entity_id)}`
);
}
private _renderLink(name: string, href: string) {
return html`<ha-list-item-button .href=${href} .headline=${name}>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>`;
}
private _renderNotEditable(name: string, id: string) {
return html`<ha-list-item-base id=${id} .headline=${name}>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-base>
<ha-tooltip .for=${id} .distance=${-4}>
${this.hass.localize("ui.panel.config.devices.cant_edit")}
</ha-tooltip>`;
}
@@ -805,12 +841,7 @@ class HaConfigAreaPage extends LitElement {
if (entry) {
url = `/config/script/edit/${entry.unique_id}`;
}
return html`<a href=${url}>
<ha-list-item hasMeta>
<span>${name}</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>`;
return this._renderLink(name, url);
}
private async _findRelated() {
@@ -977,8 +1008,15 @@ class HaConfigAreaPage extends LitElement {
color: var(--primary-text-color);
}
ha-card > a:first-child {
display: block;
ha-list-base,
ha-list-nav {
--ha-list-padding: var(--ha-space-2) 0;
}
ha-list-item-base {
color: var(--disabled-text-color);
}
ha-list-item-button ha-icon-next {
color: var(--secondary-text-color);
}
ha-card > *:first-child {
margin-top: -16px;
@@ -7,7 +7,8 @@ import { getDeviceArea } from "../../../../common/entity/context/get_device_cont
import { caseInsensitiveStringCompare } from "../../../../common/string/compare";
import "../../../../components/ha-card";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-list-item";
import "../../../../components/item/ha-list-item-button";
import "../../../../components/list/ha-list-nav";
import { fullEntitiesContext } from "../../../../data/context";
import type { DeviceRegistryEntry } from "../../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
@@ -69,41 +70,43 @@ export class HaDeviceChildDevicesCard extends LitElement {
<h1 class="card-header">
${this.hass.localize("ui.panel.config.devices.child_devices.heading")}
</h1>
${(this._showAll
? childDevices
: childDevices.slice(0, MAX_VISIBLE_CHILD_DEVICES)
).map((childDevice) => {
const area = getDeviceArea(
childDevice,
this.hass.areas,
this.hass.devices
);
const entityCount = entityCounts[childDevice.id] ?? 0;
const secondary = [
area?.name,
entityCount
? this.hass.localize(
"ui.panel.config.common.quick_links.entities",
{ count: entityCount }
)
: undefined,
]
.filter(Boolean)
.join(" • ");
return html`
<a href=${`/config/devices/device/${childDevice.id}`}>
<ha-list-item hasMeta .twoline=${!!secondary}>
${computeDeviceNameDisplay(childDevice, this.hass.localize)}
${
secondary
? html`<span slot="secondary">${secondary}</span>`
: nothing
}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>
`;
})}
<ha-list-nav
.ariaLabel=${this.hass.localize(
"ui.panel.config.devices.child_devices.heading"
)}
>
${(this._showAll
? childDevices
: childDevices.slice(0, MAX_VISIBLE_CHILD_DEVICES)
).map((childDevice) => {
const area = getDeviceArea(
childDevice,
this.hass.areas,
this.hass.devices
);
const entityCount = entityCounts[childDevice.id] ?? 0;
const secondary = [
area?.name,
entityCount
? this.hass.localize(
"ui.panel.config.common.quick_links.entities",
{ count: entityCount }
)
: undefined,
]
.filter(Boolean)
.join(" • ");
return html`
<ha-list-item-button
.href=${`/config/devices/device/${childDevice.id}`}
.headline=${computeDeviceNameDisplay(childDevice, this.hass.localize)}
.supportingText=${secondary || undefined}
>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`;
})}
</ha-list-nav>
${
!this._showAll && childDevices.length > MAX_VISIBLE_CHILD_DEVICES
? html`
@@ -133,9 +136,8 @@ export class HaDeviceChildDevicesCard extends LitElement {
padding-bottom: 0;
}
a {
text-decoration: none;
color: var(--primary-text-color);
ha-list-item-button ha-icon-next {
color: var(--secondary-text-color);
}
button.show-more {
@@ -6,8 +6,9 @@ import { repeat } from "lit/directives/repeat";
import { until } from "lit/directives/until";
import "../../../../components/ha-card";
import "../../../../components/ha-icon";
import "../../../../components/ha-list";
import "../../../../components/ha-list-item";
import "../../../../components/item/ha-list-item-button";
import "../../../../components/list/ha-list-base";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { computeEntityEntryName } from "../../../../common/entity/compute_entity_name_display";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import { entryIcon } from "../../../../data/icons";
@@ -70,15 +71,15 @@ export class HaDeviceEntitiesCard extends LitElement {
enabledEntities.length
? html`
<div id="entities" class="move-up">
<ha-list>
<ha-list-base .ariaLabel=${this.header}>
${this._renderEntities(mainEntities)}
${
mainEntities.length && additionalEntities.length
? html`<div class="divider" role="separator"></div>`
? html`<div class="divider" aria-hidden="true"></div>`
: nothing
}
${this._renderEntities(additionalEntities)}
</ha-list>
</ha-list-base>
</div>
`
: nothing
@@ -102,11 +103,15 @@ export class HaDeviceEntitiesCard extends LitElement {
</button>
`
: html`
<ha-list>
<ha-list-base
.ariaLabel=${this.hass.localize(
"ui.panel.config.devices.entities.disabled_entities_list"
)}
>
${disabledEntities.map((entry) =>
this._renderUnavailableEntity(entry)
)}
</ha-list>
</ha-list-base>
<button
class="show-more"
@click=${this._toggleShowHidden}
@@ -146,7 +151,7 @@ export class HaDeviceEntitiesCard extends LitElement {
"ui.panel.config.devices.entities.hidden"
)})`;
}
return html`<div>
return html`<div role="listitem" @keydown=${stopPropagation}>
${entityRowElement(entry.entity_id, name, this.hass)}
</div>`;
}
@@ -159,15 +164,14 @@ export class HaDeviceEntitiesCard extends LitElement {
const icon = until(entryIcon(this.hass, entry));
return html`
<ha-list-item
graphic="icon"
<ha-list-item-button
class="disabled-entry"
.entry=${entry}
@click=${this._openEditEntry}
>
<ha-icon slot="graphic" .icon=${icon}></ha-icon>
<div class="name">${name}</div>
</ha-list-item>
<ha-icon slot="start" .icon=${icon}></ha-icon>
<span slot="headline" class="name">${name}</span>
</ha-list-item-button>
`;
}
@@ -180,9 +184,6 @@ export class HaDeviceEntitiesCard extends LitElement {
:host {
display: block;
}
ha-icon {
margin-left: -8px;
}
.entity-id {
color: var(--secondary-text-color);
}
@@ -201,21 +202,25 @@ export class HaDeviceEntitiesCard extends LitElement {
.move-up {
margin-top: -13px;
}
.move-up:has(> ha-list) {
.move-up:has(> ha-list-base) {
margin-top: -24px;
}
:not(.move-up) > ha-list {
:not(.move-up) > ha-list-base {
margin-top: -24px;
}
ha-list + button.show-more,
.move-up + :not(:has(ha-list)) > button.show-more {
ha-list-base + button.show-more,
.move-up + :not(:has(ha-list-base)) > button.show-more {
margin-top: -12px;
}
#entities > ha-list {
ha-list-base {
--ha-list-padding: var(--ha-space-4) 0;
--ha-list-gap: var(--ha-space-2);
}
#entities > ha-list-base {
margin: 0 16px 0 8px;
}
#entities > ha-list > ha-list-item {
padding: 0 16px 0 12px;
#entities ha-list-item-button {
--ha-row-item-padding-inline: var(--ha-space-2) var(--ha-space-4);
}
.name {
font-size: var(--ha-font-size-m);
@@ -242,12 +247,10 @@ export class HaDeviceEntitiesCard extends LitElement {
outline: none;
text-decoration: underline;
}
ha-list > * {
margin: 8px 0px;
}
ha-list-item {
height: 40px;
--mdc-ripple-color: transparent;
ha-list-item-button {
--ha-row-item-min-height: 40px;
--ha-row-item-padding-block: 0;
--ha-row-item-gap: var(--ha-space-6);
}
`;
}
@@ -6,7 +6,8 @@ import { computeDeviceNameDisplay } from "../../../../common/entity/compute_devi
import { caseInsensitiveStringCompare } from "../../../../common/string/compare";
import "../../../../components/ha-card";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-list-item";
import "../../../../components/item/ha-list-item-button";
import "../../../../components/list/ha-list-nav";
import type { ConfigEntry } from "../../../../data/config_entries";
import type { DeviceRegistryEntry } from "../../../../data/device/device_registry";
import { fetchLinkedDevices } from "../../../../data/device/device_registry";
@@ -80,22 +81,26 @@ export class HaDeviceLinkedDevicesCard extends LitElement {
"ui.panel.config.devices.linked_devices.description"
)}
</div>
${linkedDevices.map((linkedDevice) => {
const domain = this._deviceDomain(linkedDevice, entryLookup);
const integrationName = domain
? domainToName(this.hass.localize, domain)
: undefined;
return html`
<a href=${`/config/devices/device/${linkedDevice.id}`}>
<ha-list-item
graphic=${domain ? "icon" : nothing}
hasMeta
.twoline=${!!integrationName}
<ha-list-nav
.ariaLabel=${this.hass.localize(
"ui.panel.config.devices.linked_devices.heading"
)}
>
${linkedDevices.map((linkedDevice) => {
const domain = this._deviceDomain(linkedDevice, entryLookup);
const integrationName = domain
? domainToName(this.hass.localize, domain)
: undefined;
return html`
<ha-list-item-button
.href=${`/config/devices/device/${linkedDevice.id}`}
.headline=${computeDeviceNameDisplay(linkedDevice, this.hass.localize)}
.supportingText=${integrationName}
>
${
domain
? html`<img
slot="graphic"
slot="start"
loading="lazy"
alt=""
src=${brandsUrl(
@@ -111,17 +116,11 @@ export class HaDeviceLinkedDevicesCard extends LitElement {
/>`
: nothing
}
${computeDeviceNameDisplay(linkedDevice, this.hass.localize)}
${
integrationName
? html`<span slot="secondary">${integrationName}</span>`
: nothing
}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>
`;
})}
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`;
})}
</ha-list-nav>
</ha-card>
`;
}
@@ -169,12 +168,11 @@ export class HaDeviceLinkedDevicesCard extends LitElement {
padding-bottom: var(--ha-space-2);
}
a {
text-decoration: none;
color: var(--primary-text-color);
ha-list-item-button ha-icon-next {
color: var(--secondary-text-color);
}
ha-list-item img {
ha-list-item-button img {
width: 24px;
height: 24px;
}
@@ -7,7 +7,8 @@ import { getDeviceArea } from "../../../../common/entity/context/get_device_cont
import { caseInsensitiveStringCompare } from "../../../../common/string/compare";
import "../../../../components/ha-card";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-list-item";
import "../../../../components/item/ha-list-item-button";
import "../../../../components/list/ha-list-nav";
import { fullEntitiesContext } from "../../../../data/context";
import type { DeviceRegistryEntry } from "../../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
@@ -71,41 +72,43 @@ export class HaDeviceViaDevicesCard extends LitElement {
"ui.panel.config.devices.connected_devices.heading"
)}
</h1>
${(this._showAll
? viaDevices
: viaDevices.slice(0, MAX_VISIBLE_VIA_DEVICES)
).map((viaDevice) => {
const area = getDeviceArea(
viaDevice,
this.hass.areas,
this.hass.devices
);
const entityCount = entityCounts[viaDevice.id] ?? 0;
const secondary = [
area?.name,
entityCount
? this.hass.localize(
"ui.panel.config.common.quick_links.entities",
{ count: entityCount }
)
: undefined,
]
.filter(Boolean)
.join(" • ");
return html`
<a href=${`/config/devices/device/${viaDevice.id}`}>
<ha-list-item hasMeta .twoline=${!!secondary}>
${computeDeviceNameDisplay(viaDevice, this.hass.localize)}
${
secondary
? html`<span slot="secondary">${secondary}</span>`
: nothing
}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>
`;
})}
<ha-list-nav
.ariaLabel=${this.hass.localize(
"ui.panel.config.devices.connected_devices.heading"
)}
>
${(this._showAll
? viaDevices
: viaDevices.slice(0, MAX_VISIBLE_VIA_DEVICES)
).map((viaDevice) => {
const area = getDeviceArea(
viaDevice,
this.hass.areas,
this.hass.devices
);
const entityCount = entityCounts[viaDevice.id] ?? 0;
const secondary = [
area?.name,
entityCount
? this.hass.localize(
"ui.panel.config.common.quick_links.entities",
{ count: entityCount }
)
: undefined,
]
.filter(Boolean)
.join(" • ");
return html`
<ha-list-item-button
.href=${`/config/devices/device/${viaDevice.id}`}
.headline=${computeDeviceNameDisplay(viaDevice, this.hass.localize)}
.supportingText=${secondary || undefined}
>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`;
})}
</ha-list-nav>
${
!this._showAll && viaDevices.length > MAX_VISIBLE_VIA_DEVICES
? html`
@@ -135,9 +138,8 @@ export class HaDeviceViaDevicesCard extends LitElement {
padding-bottom: 0;
}
a {
text-decoration: none;
color: var(--primary-text-color);
ha-list-item-button ha-icon-next {
color: var(--secondary-text-color);
}
button.show-more {
@@ -3,10 +3,11 @@ import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-list";
import "../../../../../components/ha-list-item";
import "../../../../../components/ha-spinner";
import "../../../../../components/ha-dialog";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-spinner";
import "../../../../../components/item/ha-list-item-base";
import "../../../../../components/list/ha-list-base";
import type {
MatterFabricData,
MatterNodeDiagnostics,
@@ -59,30 +60,36 @@ class DialogMatterManageFabrics extends LitElement {
</p>
${
this._nodeDiagnostics
? html`<ha-list>
? html`<ha-list-base
aria-label=${this.hass.localize(
"ui.panel.config.matter.manage_fabrics.title"
)}
>
${this._nodeDiagnostics.active_fabrics.map(
(fabric) =>
html`<ha-list-item
noninteractive
.hasMeta=${
html`<ha-list-item-base
.headline=${String(
fabric.vendor_name ||
fabric.fabric_label ||
fabric.vendor_id
)}
>
${
this._nodeDiagnostics!.available &&
fabric.fabric_index !==
this._nodeDiagnostics!.active_fabric_index
? html`<ha-icon-button
slot="end"
.label=${this.hass.localize("ui.common.remove")}
.path=${mdiDelete}
.fabric=${fabric}
@click=${this._removeFabric}
></ha-icon-button>`
: nothing
}
>${
fabric.vendor_name ||
fabric.fabric_label ||
fabric.vendor_id
}
<ha-icon-button
@click=${this._removeFabric}
slot="meta"
.fabric=${fabric}
.path=${mdiDelete}
></ha-icon-button>
</ha-list-item>`
</ha-list-item-base>`
)}
</ha-list>`
</ha-list-base>`
: html`<div class="center">
<ha-spinner></ha-spinner>
</div>`
@@ -161,9 +168,10 @@ class DialogMatterManageFabrics extends LitElement {
css`
ha-dialog {
--dialog-content-padding: 0;
--mdc-list-side-padding: 24px;
--mdc-list-side-padding-right: 16px;
--mdc-list-item-meta-size: 48px;
}
ha-list-item-base {
--ha-row-item-padding-inline: var(--ha-space-6) var(--ha-space-4);
--ha-row-item-padding-block: 0;
}
p {
margin: 8px 24px;
@@ -1,15 +1,21 @@
import { mdiAlertCircle, mdiCheckCircle, mdiCloseCircle } from "@mdi/js";
import {
mdiAlertCircle,
mdiCheckCircle,
mdiCloseCircle,
mdiContentCopy,
} from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { copyToClipboard } from "../../../../../common/util/copy-clipboard";
import "../../../../../components/ha-list";
import "../../../../../components/ha-button";
import "../../../../../components/ha-dialog-footer";
import "../../../../../components/ha-list-item";
import "../../../../../components/ha-spinner";
import "../../../../../components/ha-dialog";
import "../../../../../components/ha-icon-button";
import "../../../../../components/item/ha-list-item-base";
import "../../../../../components/list/ha-list-base";
import { pingMatterNode } from "../../../../../data/matter";
import { haStyle, haStyleDialog } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
@@ -83,22 +89,29 @@ class DialogMatterPingNode extends LitElement {
"ui.panel.config.matter.ping_node.ping_complete"
)}
</h2>
<ha-list>
<ha-list-base
aria-label=${this.hass.localize(
"ui.panel.config.matter.ping_node.ping_complete"
)}
>
${this._pingResultEntries.map(
([ip, success]) =>
html`<ha-list-item
hasMeta
.ip=${ip}
@click=${this._copyIpToClipboard}
>${ip}
html`<ha-list-item-base .headline=${ip}>
<ha-svg-icon
slot="meta"
slot="end"
.path=${success ? mdiCheckCircle : mdiAlertCircle}
class=${success ? "success" : "failed"}
></ha-svg-icon>
</ha-list-item>`
<ha-icon-button
slot="end"
.label=${this.hass.localize("ui.common.copy")}
.path=${mdiContentCopy}
.ip=${ip}
@click=${this._copyIpToClipboard}
></ha-icon-button>
</ha-list-item-base>`
)}
</ha-list>
</ha-list-base>
`
: this._status === "started"
? html`
@@ -208,8 +221,14 @@ class DialogMatterPingNode extends LitElement {
padding: 8px;
}
ha-list {
--mdc-list-side-padding: 0;
ha-list-base {
--ha-list-padding: var(--ha-space-2) 0;
}
ha-list-item-base {
--ha-row-item-padding-inline: 0;
--ha-row-item-padding-block: 0;
--ha-row-item-gap: var(--ha-space-2);
}
.flex-container ha-spinner,
@@ -8,7 +8,6 @@ import {
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../../../common/config/is_component_loaded";
import { stringCompare } from "../../../../../common/string/compare";
@@ -20,9 +19,10 @@ import type { HaDropdownSelectEvent } from "../../../../../components/ha-dropdow
import "../../../../../components/ha-dropdown-item";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-list-item";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/item/ha-list-item-base";
import "../../../../../components/item/ha-list-item-button";
import "../../../../../components/list/ha-list-base";
import "../../../../../components/list/ha-list-nav";
import { getSignedPath } from "../../../../../data/auth";
import { getConfigEntryDiagnosticsDownloadUrl } from "../../../../../data/diagnostics";
@@ -222,62 +222,67 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) {
)}
</h4>
</div>
${network.routers.map((router) => {
const otbr =
this._otbrInfo && this._otbrInfo[router.extended_address];
const showDefaultRouter = !!network.dataset;
const isDefaultRouter =
showDefaultRouter &&
router.extended_address ===
network.dataset!.preferred_extended_address;
const showOverflow = showDefaultRouter || otbr;
return html`<ha-list-item
class="router"
twoline
graphic=${ifDefined(router.brand ? "avatar" : undefined)}
.hasMeta=${showOverflow}
>
${
router.brand
? html`<img
slot="graphic"
.src=${brandsUrl(
{
domain: router.brand,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
alt=${router.brand}
crossorigin="anonymous"
referrerpolicy="no-referrer"
@error=${this._onImageError}
@load=${this._onImageLoad}
/>`
: nothing
}
${
router.instance_name ||
router.model_name ||
router.server?.replace(".local.", "") ||
""
}
<span slot="secondary">${router.server}</span>
${
showOverflow
? html`${
isDefaultRouter
? html`<ha-svg-icon
.path=${mdiCellphoneKey}
.title=${this.hass.localize(
"ui.panel.config.thread.default_router"
)}
></ha-svg-icon>`
: ""
}
<ha-dropdown
slot="meta"
<ha-list-base
aria-label=${this.hass.localize(
"ui.panel.config.thread.border_routers",
{ count: network.routers.length }
)}
>
${network.routers.map((router) => {
const otbr =
this._otbrInfo && this._otbrInfo[router.extended_address];
const showDefaultRouter = !!network.dataset;
const isDefaultRouter =
showDefaultRouter &&
router.extended_address ===
network.dataset!.preferred_extended_address;
const showOverflow = showDefaultRouter || otbr;
return html`<ha-list-item-base
class="router"
.supportingText=${router.server ?? undefined}
>
<span slot="headline">
${
router.instance_name ||
router.model_name ||
router.server?.replace(".local.", "") ||
""
}
${
isDefaultRouter
? html`<ha-svg-icon
.path=${mdiCellphoneKey}
.title=${this.hass.localize(
"ui.panel.config.thread.default_router"
)}
></ha-svg-icon>`
: nothing
}
</span>
${
router.brand
? html`<img
slot="start"
.src=${brandsUrl(
{
domain: router.brand,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
alt=${router.brand}
crossorigin="anonymous"
referrerpolicy="no-referrer"
@error=${this._onImageError}
@load=${this._onImageLoad}
/>`
: nothing
}
${
showOverflow
? html`<ha-dropdown
slot="end"
.network=${network}
.router=${router}
.otbr=${otbr}
@@ -334,10 +339,11 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) {
: ""
}
</ha-dropdown>`
: ""
}
</ha-list-item>`;
})}`
: ""
}
</ha-list-item-base>`;
})}
</ha-list-base>`
: html`<div class="card-content no-routers">
<ha-svg-icon .path=${mdiDevices}></ha-svg-icon>
${
@@ -789,19 +795,11 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) {
margin: 0 auto;
direction: ltr;
}
ha-list-item.router {
--mdc-list-item-meta-size: auto;
--mdc-list-item-meta-display: flex;
--mdc-list-side-padding: 16px;
cursor: default;
overflow: visible;
}
ha-list-item img {
.router img {
width: 40px;
height: 40px;
border-radius: var(--ha-border-radius-square);
}
ha-svg-icon[slot="meta"] {
width: 24px;
}
ha-dropdown a {
text-decoration: none;
}
@@ -7,11 +7,12 @@ import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-button";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-list-item";
import "../../../../../components/ha-markdown";
import "../../../../../components/ha-spinner";
import "../../../../../components/ha-dialog-footer";
import "../../../../../components/ha-dialog";
import "../../../../../components/item/ha-list-item-button";
import "../../../../../components/list/ha-list-base";
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
import {
fetchZwaveNodeMetadata,
@@ -148,24 +149,24 @@ class DialogZWaveJSRemoveNode extends LitElement {
"ui.panel.config.zwave_js.remove_node.introduction"
)}
</p>
<div class="menu-options">
<ha-list-item hasMeta @click=${this._startExclusion}>
<span
>${this.hass.localize(
"ui.panel.config.zwave_js.remove_node.menu_exclude_device"
)}</span
>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
<ha-list-item hasMeta @click=${this._startRemoval}>
<span
>${this.hass.localize(
"ui.panel.config.zwave_js.remove_node.menu_remove_device"
)}</span
>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</div>
<ha-list-base class="menu-options">
<ha-list-item-button
.headline=${this.hass.localize(
"ui.panel.config.zwave_js.remove_node.menu_exclude_device"
)}
@click=${this._startExclusion}
>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
<ha-list-item-button
.headline=${this.hass.localize(
"ui.panel.config.zwave_js.remove_node.menu_remove_device"
)}
@click=${this._startRemoval}
>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
</ha-list-base>
`;
}
@@ -434,8 +435,8 @@ class DialogZWaveJSRemoveNode extends LitElement {
align-self: stretch;
}
ha-list-item {
--mdc-list-side-padding: var(--ha-space-2);
ha-list-item-button {
--ha-row-item-padding-inline: var(--ha-space-2);
}
`,
];
@@ -3,8 +3,8 @@ import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-list";
import "../../../../../components/ha-list-item";
import "../../../../../components/item/ha-list-item-button";
import "../../../../../components/list/ha-list-nav";
import "../../../../../layouts/hass-loading-screen";
import "../../../../../layouts/hass-subpage";
import type { ConfigEntry } from "../../../../../data/config_entries";
@@ -68,20 +68,22 @@ class ZWaveJSConfigEntryPicker extends LitElement {
"ui.panel.config.zwave_js.picker.title"
)}
>
<ha-list>
<ha-list-nav
.ariaLabel=${this.hass.localize(
"ui.panel.config.zwave_js.picker.title"
)}
>
${this._configEntries.map(
(entry) => html`
<a
href="/config/zwave_js/dashboard?config_entry=${entry.entry_id}"
<ha-list-item-button
.href=${`/config/zwave_js/dashboard?config_entry=${entry.entry_id}`}
.headline=${entry.title}
>
<ha-list-item hasMeta>
<span>${entry.title}</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`
)}
</ha-list>
</ha-list-nav>
</ha-card>
</div>
</hass-subpage>
@@ -127,9 +129,12 @@ class ZWaveJSConfigEntryPicker extends LitElement {
padding-bottom: 0;
}
a {
text-decoration: none;
color: inherit;
ha-list-nav {
--ha-list-padding: var(--ha-space-2) 0;
}
ha-list-item-button ha-icon-next {
color: var(--secondary-text-color);
}
`,
];
+9
View File
@@ -3547,6 +3547,14 @@
"add_picture": "Add a picture",
"assigned_to_area": "Assigned to this area",
"targeting_area": "Targeting this area",
"related_lists": {
"automations_assigned": "Automations assigned to {area}",
"automations_targeting": "Automations targeting {area}",
"scenes_assigned": "Scenes assigned to {area}",
"scenes_targeting": "Scenes targeting {area}",
"scripts_assigned": "Scripts assigned to {area}",
"scripts_targeting": "Scripts targeting {area}"
},
"area_not_found": "[%key:ui::card::area::area_not_found%]",
"data_table": {
"area": "Area",
@@ -7478,6 +7486,7 @@
"none": "This device has no entities",
"show_less": "Show less",
"disabled_entities": "+{count} disabled {count, plural,\n one {entity}\n other {entities}\n}",
"disabled_entities_list": "Disabled entities",
"hidden": "Hidden"
},
"confirm_disable_config_entry_title": "Disable config entry?",