Compare commits

...

9 Commits

Author SHA1 Message Date
Wendelin
c3fde7ef00 Fix list aria-label 2026-04-27 09:03:49 +02:00
Wendelin
122224ee95 Review 2026-04-27 08:52:49 +02:00
Wendelin
88b61b20c8 fix filter-floor-areas 2026-04-27 08:42:34 +02:00
Wendelin
4a79fa56a9 Merge branch 'dev' of github.com:home-assistant/frontend into ha-list-new 2026-04-24 13:54:32 +02:00
Wendelin
5fad0083cd fix types in gallery 2026-04-24 13:53:59 +02:00
Wendelin
03108b801f Refactor ha-list components to use ha-list-selectable and ha-list-item-option 2026-04-24 13:51:56 +02:00
renovate[bot]
f449c6c1c1 Update dependency @codemirror/search to v6.7.0 (#51704)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-24 14:47:13 +03:00
renovate[bot]
d1eb3fd162 Update vitest monorepo to v4.1.5 (#51703)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-24 14:46:32 +03:00
Wendelin
18c0a7e39d add new ha-list options 2026-04-23 10:51:40 +02:00
24 changed files with 1975 additions and 308 deletions

View File

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

View File

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

View File

@@ -33,7 +33,7 @@
"@codemirror/lang-jinja": "6.0.1",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.3",
"@codemirror/search": "6.6.0",
"@codemirror/search": "6.7.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.41.1",
"@date-fns/tz": "1.4.1",
@@ -162,7 +162,7 @@
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.1.4",
"@vitest/coverage-v8": "4.1.5",
"babel-loader": "10.1.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.4",
@@ -205,7 +205,7 @@
"typescript": "6.0.3",
"typescript-eslint": "8.59.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.4",
"vitest": "4.1.5",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.0#~/.yarn/patches/workbox-build-npm-7.4.0-c84561662c.patch"

View File

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

View File

@@ -39,11 +39,11 @@ import type { HomeAssistant, PanelInfo, Route } from "../types";
import "./ha-fade-in";
import "./ha-icon";
import "./ha-icon-button";
import "./ha-md-list";
import "./ha-md-list-item";
import "./ha-spinner";
import "./ha-svg-icon";
import "./ha-tooltip";
import "./item/ha-list-item-button";
import "./list/ha-list-nav";
import "./user/ha-user-badge";
const SORT_VALUE_URL_PATHS = {
@@ -352,12 +352,12 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
private _renderAllPanels(selectedPanel: string) {
const renderList = (content, cls: string, scrollable: boolean) =>
html`<ha-md-list
html`<ha-list-nav
class=${classMap({
"ha-scrollbar": scrollable,
[cls]: true,
})}
>${content}</ha-md-list
>${content}</ha-list-nav
>`;
if (!this._panelOrder || !this._hiddenPanels) {
@@ -429,9 +429,8 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
const iconPath = getPanelIconPath(panel);
return html`
<ha-md-list-item
<ha-list-item-button
.href=${`/${urlPath}`}
type="link"
id="sidebar-panel-${urlPath}"
class=${classMap({ selected: isSelected })}
>
@@ -439,7 +438,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
? html`<ha-svg-icon slot="start" .path=${iconPath}></ha-svg-icon>`
: html`<ha-icon slot="start" .icon=${icon}></ha-icon>`}
<span class="item-text" slot="headline">${title}</span>
</ha-md-list-item>
</ha-list-item-button>
${!this.alwaysExpand && title
? this._renderToolTip(`sidebar-panel-${urlPath}`, title)
: nothing}
@@ -456,9 +455,8 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
}
const isSelected = selectedPanel === "config";
return html`
<ha-md-list-item
<ha-list-item-button
class="configuration ${classMap({ selected: isSelected })}"
type="button"
href="/config"
id="sidebar-config"
>
@@ -480,7 +478,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
>
`
: nothing}
</ha-md-list-item>
</ha-list-item-button>
${!this.alwaysExpand
? this._renderToolTip(
"sidebar-config",
@@ -496,10 +494,9 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
: 0;
return html`
<ha-md-list-item
<ha-list-item-button
class="notifications"
@click=${this._handleShowNotificationDrawer}
type="button"
id="sidebar-notifications"
>
<ha-svg-icon slot="start" .path=${mdiBell}></ha-svg-icon>
@@ -514,7 +511,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
${notificationCount > 0
? html`<span class="badge" slot="end">${notificationCount}</span>`
: nothing}
</ha-md-list-item>
</ha-list-item-button>
${!this.alwaysExpand
? this._renderToolTip(
"sidebar-notifications",
@@ -529,9 +526,8 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
const isSelected = selectedPanel === "profile";
return html`
<ha-md-list-item
<ha-list-item-button
href="/profile"
type="link"
id="sidebar-profile"
class=${classMap({
user: true,
@@ -547,7 +543,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
<span class="item-text" slot="headline"
>${this.hass.user ? this.hass.user.name : ""}</span
>
</ha-md-list-item>
</ha-list-item-button>
${!this.alwaysExpand && this.hass.user
? this._renderToolTip("sidebar-profile", this.hass.user.name)
: nothing}
@@ -559,16 +555,15 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
return nothing;
}
return html`
<ha-md-list-item
<ha-list-item-button
@click=${this._handleExternalAppConfiguration}
type="button"
id="sidebar-external-config"
>
<ha-svg-icon slot="start" .path=${mdiCellphoneCog}></ha-svg-icon>
<span class="item-text" slot="headline"
>${this.hass.localize("ui.sidebar.external_app_configuration")}</span
>
</ha-md-list-item>
</ha-list-item-button>
${!this.alwaysExpand
? this._renderToolTip(
"sidebar-external-config",
@@ -713,10 +708,10 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
flex: 1;
}
ha-md-list {
ha-list-nav {
overflow-x: hidden;
background: none;
margin-left: var(--safe-area-inset-left, 0px);
margin-block: var(--ha-space-2);
}
.wrapper {
@@ -726,42 +721,38 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
min-height: 0;
flex: 1;
}
ha-md-list.before-spacer {
ha-list-nav.before-spacer {
padding-bottom: 0;
}
ha-md-list.after-spacer {
ha-list-nav.after-spacer {
padding-top: 0;
min-height: fit-content;
}
ha-md-list-item {
ha-list-item-button {
flex-shrink: 0;
box-sizing: border-box;
margin: var(--ha-space-1);
margin: 0 var(--ha-space-1) var(--ha-space-1);
border-radius: var(--ha-border-radius-sm);
--md-list-item-one-line-container-height: var(--ha-space-10);
--md-list-item-top-space: 0;
--md-list-item-bottom-space: 0;
--ha-row-item-min-height: var(--ha-space-10);
--ha-row-item-padding-block: 0;
width: var(--ha-space-12);
position: relative;
--md-list-item-label-text-color: var(--sidebar-text-color);
--md-list-item-leading-space: var(--ha-space-3);
--md-list-item-trailing-space: var(--ha-space-3);
--md-list-item-leading-icon-size: var(--ha-space-6);
transition: width var(--ha-animation-duration-normal) ease;
}
:host([expanded]) ha-md-list-item {
ha-list-item-button::part(headline) {
color: var(--sidebar-text-color);
}
:host([expanded]) ha-list-item-button {
width: 248px;
}
:host([narrow][expanded]) ha-md-list-item {
:host([narrow][expanded]) ha-list-item-button {
width: calc(240px - var(--safe-area-inset-left, 0px));
}
ha-md-list-item.selected {
--md-list-item-label-text-color: var(--sidebar-selected-icon-color);
--md-ripple-hover-color: var(--sidebar-selected-icon-color);
ha-list-item-button.selected::part(headline) {
color: var(--sidebar-selected-icon-color);
}
ha-md-list-item.selected::before {
ha-list-item-button.selected::before {
border-radius: var(--ha-border-radius-sm);
position: absolute;
top: 0;
@@ -783,12 +774,12 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
color: var(--sidebar-icon-color);
}
ha-md-list-item.selected ha-svg-icon[slot="start"],
ha-md-list-item.selected ha-icon[slot="start"] {
ha-list-item-button.selected ha-svg-icon[slot="start"],
ha-list-item-button.selected ha-icon[slot="start"] {
color: var(--sidebar-selected-icon-color);
}
ha-md-list-item .item-text {
ha-list-item-button .item-text {
display: block;
max-width: 0;
opacity: 0;
@@ -801,7 +792,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
max-width var(--ha-animation-duration-normal) ease,
opacity var(--ha-animation-duration-normal) ease;
}
:host([expanded]) ha-md-list-item .item-text {
:host([expanded]) ha-list-item-button .item-text {
max-width: 100%;
opacity: 1;
transition-delay: 0ms, 80ms;
@@ -843,13 +834,17 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
pointer-events: none;
}
ha-md-list-item.user {
--md-list-item-leading-icon-size: var(--ha-space-10);
--md-list-item-leading-space: var(--ha-space-1);
ha-user-badge {
width: var(--ha-space-10);
height: var(--ha-space-10);
}
ha-md-list-item.user.rtl {
--md-list-item-leading-space: var(--ha-space-3);
ha-list-item-button.user {
--ha-row-item-padding-inline: var(--ha-space-2) var(--ha-space-3);
}
ha-list-item-button.user.rtl {
--ha-row-item-padding-inline: var(--ha-space-4) var(--ha-space-3);
}
ha-user-badge {
@@ -869,8 +864,8 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
@media (prefers-reduced-motion: reduce) {
.menu,
ha-md-list-item,
ha-md-list-item .item-text,
ha-list-item-button,
ha-list-item-button .item-text,
.title {
transition: 1ms;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,12 +2,14 @@ import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event";
import { nextRender } from "../../common/util/render-status";
import "../../components/ha-button";
import "../../components/ha-card";
import "../../components/ha-md-list";
import "../../components/ha-md-list-item";
import "../../components/item/ha-row-item";
import { isExternal } from "../../data/external";
import type { CoreFrontendUserData } from "../../data/frontend";
import { subscribeFrontendUserData } from "../../data/frontend";
@@ -33,7 +35,6 @@ import "./ha-pick-time-zone-row";
import "./ha-push-notifications-row";
import "./ha-set-suspend-row";
import "./ha-set-vibrate-row";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
@customElement("ha-profile-section-general")
class HaProfileSectionGeneral extends LitElement {
@@ -145,29 +146,29 @@ class HaProfileSectionGeneral extends LitElement {
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-dashboard-row>
<ha-row-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.profile.customize_sidebar.header"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.profile.customize_sidebar.description"
)}</span
>
<ha-button
slot="end"
appearance="plain"
size="small"
@click=${this._customizeSidebar}
>
${this.hass.localize(
"ui.panel.profile.customize_sidebar.button"
)}
</ha-button>
</ha-row-item>
<ha-md-list>
<ha-md-list-item>
<span slot="headline"
>${this.hass.localize(
"ui.panel.profile.customize_sidebar.header"
)}</span
>
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.profile.customize_sidebar.description"
)}</span
>
<ha-button
slot="end"
appearance="plain"
size="small"
@click=${this._customizeSidebar}
>
${this.hass.localize(
"ui.panel.profile.customize_sidebar.button"
)}
</ha-button>
</ha-md-list-item>
${this.hass.user!.is_admin
? html`
<ha-advanced-mode-row

View File

@@ -7,7 +7,7 @@ import { css } from "lit";
*/
export const semanticColorStyles = css`
html {
--ha-color-focus: var(--ha-color-orange-60);
--ha-color-focus: var(--ha-color-neutral-60);
/* text */
--ha-color-text-primary: var(--ha-color-neutral-05);

View File

@@ -62,7 +62,7 @@ export const waColorStyles = css`
--wa-panel-border-width: 1px;
--wa-color-surface-border: var(--ha-color-border-neutral-quiet);
--wa-focus-ring-color: var(--ha-color-neutral-60);
--wa-focus-ring-color: var(--ha-color-focus);
--wa-shadow-l: 4px 8px 12px 0 rgba(0, 0, 0, 0.3);
--wa-form-control-background-color: var(--wa-color-surface-raised);

132
yarn.lock
View File

@@ -1342,14 +1342,14 @@ __metadata:
languageName: node
linkType: hard
"@codemirror/search@npm:6.6.0":
version: 6.6.0
resolution: "@codemirror/search@npm:6.6.0"
"@codemirror/search@npm:6.7.0":
version: 6.7.0
resolution: "@codemirror/search@npm:6.7.0"
dependencies:
"@codemirror/state": "npm:^6.0.0"
"@codemirror/view": "npm:^6.37.0"
crelt: "npm:^1.0.5"
checksum: 10/2947341cf06bde4a682250c245007f70854b7660f7aa29cec2c5cd177fa70deacaadf37c4aa323f669379d32f9912cb958448c93da3dc90b8af5c466ea1cddaf
checksum: 10/29b7d8adeb5317ea45bf3c03c6c5ab18210efe800ebc89dd11b4a77827966a8ef85dda082517fe278e470b5ead21e6b29e590d3060775a41d3179bbd74711279
languageName: node
linkType: hard
@@ -4762,12 +4762,12 @@ __metadata:
languageName: node
linkType: hard
"@vitest/coverage-v8@npm:4.1.4":
version: 4.1.4
resolution: "@vitest/coverage-v8@npm:4.1.4"
"@vitest/coverage-v8@npm:4.1.5":
version: 4.1.5
resolution: "@vitest/coverage-v8@npm:4.1.5"
dependencies:
"@bcoe/v8-coverage": "npm:^1.0.2"
"@vitest/utils": "npm:4.1.4"
"@vitest/utils": "npm:4.1.5"
ast-v8-to-istanbul: "npm:^1.0.0"
istanbul-lib-coverage: "npm:^3.2.2"
istanbul-lib-report: "npm:^3.0.1"
@@ -4777,34 +4777,34 @@ __metadata:
std-env: "npm:^4.0.0-rc.1"
tinyrainbow: "npm:^3.1.0"
peerDependencies:
"@vitest/browser": 4.1.4
vitest: 4.1.4
"@vitest/browser": 4.1.5
vitest: 4.1.5
peerDependenciesMeta:
"@vitest/browser":
optional: true
checksum: 10/75c7bfa08d4a410dce09688a7bb1c06b782f90b785a51aea424806621dc90ce21663cf2e8f6f28e3c3c1be708932c5274408cd7cfda51e39dd3e0ae523cca133
checksum: 10/378e1d85a1c4670af15a18b544995a43d320460b418c188d7000f96518859e4537e00ea5e38a563c42b6183437252f0ecc92b471ede30c6d43ae87b7c8e09ed3
languageName: node
linkType: hard
"@vitest/expect@npm:4.1.4":
version: 4.1.4
resolution: "@vitest/expect@npm:4.1.4"
"@vitest/expect@npm:4.1.5":
version: 4.1.5
resolution: "@vitest/expect@npm:4.1.5"
dependencies:
"@standard-schema/spec": "npm:^1.1.0"
"@types/chai": "npm:^5.2.2"
"@vitest/spy": "npm:4.1.4"
"@vitest/utils": "npm:4.1.4"
"@vitest/spy": "npm:4.1.5"
"@vitest/utils": "npm:4.1.5"
chai: "npm:^6.2.2"
tinyrainbow: "npm:^3.1.0"
checksum: 10/3317bc42e4ee39cfa2102a9f08f0c7975817a74d9503a14e0b1715e5b8c4ab31c5646c07ef8d2d3f71bdf6f1b3053949b175df9c8457e0c0bb3f38b9e031f259
checksum: 10/3e94d2d0cf4f7018ed6a7a9394bff971353ea0cc85bcbcff39212279156840b8c533be99e2fd52112e4904c4a5190bdaaf441db7c6b17e356c18577072a3f057
languageName: node
linkType: hard
"@vitest/mocker@npm:4.1.4":
version: 4.1.4
resolution: "@vitest/mocker@npm:4.1.4"
"@vitest/mocker@npm:4.1.5":
version: 4.1.5
resolution: "@vitest/mocker@npm:4.1.5"
dependencies:
"@vitest/spy": "npm:4.1.4"
"@vitest/spy": "npm:4.1.5"
estree-walker: "npm:^3.0.3"
magic-string: "npm:^0.30.21"
peerDependencies:
@@ -4815,56 +4815,56 @@ __metadata:
optional: true
vite:
optional: true
checksum: 10/f07f8877635eb03f63981d0d3348bb82fabe7607bbb6b259045bf0b64fae79150b1f399aa7ce42926e4769dc8cde9b7d79d1f665eae2d17b22ecc9ec54663698
checksum: 10/949784ba08996543a313459a36a730d4b0847e42ee56cfda07a3e2add67c7adf8acbd59dcf9f75b1e4bc3fe7cc487f9f260905ff9a334866d389478112e5ae82
languageName: node
linkType: hard
"@vitest/pretty-format@npm:4.1.4":
version: 4.1.4
resolution: "@vitest/pretty-format@npm:4.1.4"
"@vitest/pretty-format@npm:4.1.5":
version: 4.1.5
resolution: "@vitest/pretty-format@npm:4.1.5"
dependencies:
tinyrainbow: "npm:^3.1.0"
checksum: 10/e06d63ce4f797ad578ee19aeec996f72835a7274ee2eb75dce12d7b45debcda72d054f58b6f4e5dac4424681dc13dbad7ac023c6017fc60406cabea5a352e4c3
checksum: 10/783f8c4a0e419d1024446ae8593411c95443ea09b50c4a378986b48893998acda34429b2d1deebc065405a7ef40bb19e19c68fdeb93acd46ae98b156c42d5f39
languageName: node
linkType: hard
"@vitest/runner@npm:4.1.4":
version: 4.1.4
resolution: "@vitest/runner@npm:4.1.4"
"@vitest/runner@npm:4.1.5":
version: 4.1.5
resolution: "@vitest/runner@npm:4.1.5"
dependencies:
"@vitest/utils": "npm:4.1.4"
"@vitest/utils": "npm:4.1.5"
pathe: "npm:^2.0.3"
checksum: 10/a852477adc6254e1d304bcba9b137f98f09a7001a557e8e4f4404518e3ade58a16ab459e83cf223e38cc37dc4b04d1248a14df56b056a0ae68fc54b19a1226fb
checksum: 10/ba19d84a9f7bcc3102ae5304c23e5dae789aaf8fd283f826e3fd4aca87ea2687ed606cf89869773d15799666553fd265524f7d9a0869e2869e00ebd8fd53af5b
languageName: node
linkType: hard
"@vitest/snapshot@npm:4.1.4":
version: 4.1.4
resolution: "@vitest/snapshot@npm:4.1.4"
"@vitest/snapshot@npm:4.1.5":
version: 4.1.5
resolution: "@vitest/snapshot@npm:4.1.5"
dependencies:
"@vitest/pretty-format": "npm:4.1.4"
"@vitest/utils": "npm:4.1.4"
"@vitest/pretty-format": "npm:4.1.5"
"@vitest/utils": "npm:4.1.5"
magic-string: "npm:^0.30.21"
pathe: "npm:^2.0.3"
checksum: 10/e957cc95274a9663cd59e5b34c99b6e4e5cd989f04dadf9e3cec6c7bc64b4d167229644f31fd44c19c7acbbcb7cbbbb50e8084dbf1e0322ee411a697d80d490a
checksum: 10/cf70530d8a7320c012bdf7f6ca4f3ddbbb47c9aeb9ff5d28319e552ce64db93423d0c4facff3e112c6d711ed4228369c8fa73c88350fe6c16cf04f9ac2558caf
languageName: node
linkType: hard
"@vitest/spy@npm:4.1.4":
version: 4.1.4
resolution: "@vitest/spy@npm:4.1.4"
checksum: 10/516e465413fc6a22e0c7e99871f3b9703277c309e94e7247bbdb83a8e807e2da968cf7a30c61503afd6b565787e822786b8aad443210eba5488192a36730f3ab
"@vitest/spy@npm:4.1.5":
version: 4.1.5
resolution: "@vitest/spy@npm:4.1.5"
checksum: 10/4db4bb3aea01cd737fdb06d8f498bcd2127b8c2afeaa78ff9df4147e1474aa26dd16f42dc0512c31385824e94dbb17b17fa0f4c60b7595b7b4ab946f098220ab
languageName: node
linkType: hard
"@vitest/utils@npm:4.1.4":
version: 4.1.4
resolution: "@vitest/utils@npm:4.1.4"
"@vitest/utils@npm:4.1.5":
version: 4.1.5
resolution: "@vitest/utils@npm:4.1.5"
dependencies:
"@vitest/pretty-format": "npm:4.1.4"
"@vitest/pretty-format": "npm:4.1.5"
convert-source-map: "npm:^2.0.0"
tinyrainbow: "npm:^3.1.0"
checksum: 10/f599ae744f0ff45edda90d0c52eea9809b7367adca39fc985f85880322236d089dfdf6625f04913f03a25a160eccbbc0b16dd3201ccc0ae48087992b1ea755d5
checksum: 10/4f75a2df6f910578a361ae92eb92a2b6921f50cc748994f3b2e5900d0ae687b6683f33b090dedf9b96eaca23bac117817d9448a4a333c7a96b94ee767399f18c
languageName: node
linkType: hard
@@ -8267,7 +8267,7 @@ __metadata:
"@codemirror/lang-jinja": "npm:6.0.1"
"@codemirror/lang-yaml": "npm:6.1.3"
"@codemirror/language": "npm:6.12.3"
"@codemirror/search": "npm:6.6.0"
"@codemirror/search": "npm:6.7.0"
"@codemirror/state": "npm:6.6.0"
"@codemirror/view": "npm:6.41.1"
"@date-fns/tz": "npm:1.4.1"
@@ -8339,7 +8339,7 @@ __metadata:
"@types/tar": "npm:7.0.87"
"@types/webspeechapi": "npm:0.0.29"
"@vibrant/color": "npm:4.0.4"
"@vitest/coverage-v8": "npm:4.1.4"
"@vitest/coverage-v8": "npm:4.1.5"
"@webcomponents/scoped-custom-element-registry": "npm:0.0.10"
"@webcomponents/webcomponentsjs": "npm:2.8.0"
babel-loader: "npm:10.1.1"
@@ -8424,7 +8424,7 @@ __metadata:
typescript: "npm:6.0.3"
typescript-eslint: "npm:8.59.0"
vite-tsconfig-paths: "npm:6.1.1"
vitest: "npm:4.1.4"
vitest: "npm:4.1.5"
webpack-stats-plugin: "npm:1.1.3"
webpackbar: "npm:7.0.0"
weekstart: "npm:2.0.0"
@@ -13299,17 +13299,17 @@ __metadata:
languageName: node
linkType: hard
"vitest@npm:4.1.4":
version: 4.1.4
resolution: "vitest@npm:4.1.4"
"vitest@npm:4.1.5":
version: 4.1.5
resolution: "vitest@npm:4.1.5"
dependencies:
"@vitest/expect": "npm:4.1.4"
"@vitest/mocker": "npm:4.1.4"
"@vitest/pretty-format": "npm:4.1.4"
"@vitest/runner": "npm:4.1.4"
"@vitest/snapshot": "npm:4.1.4"
"@vitest/spy": "npm:4.1.4"
"@vitest/utils": "npm:4.1.4"
"@vitest/expect": "npm:4.1.5"
"@vitest/mocker": "npm:4.1.5"
"@vitest/pretty-format": "npm:4.1.5"
"@vitest/runner": "npm:4.1.5"
"@vitest/snapshot": "npm:4.1.5"
"@vitest/spy": "npm:4.1.5"
"@vitest/utils": "npm:4.1.5"
es-module-lexer: "npm:^2.0.0"
expect-type: "npm:^1.3.0"
magic-string: "npm:^0.30.21"
@@ -13327,12 +13327,12 @@ __metadata:
"@edge-runtime/vm": "*"
"@opentelemetry/api": ^1.9.0
"@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0
"@vitest/browser-playwright": 4.1.4
"@vitest/browser-preview": 4.1.4
"@vitest/browser-webdriverio": 4.1.4
"@vitest/coverage-istanbul": 4.1.4
"@vitest/coverage-v8": 4.1.4
"@vitest/ui": 4.1.4
"@vitest/browser-playwright": 4.1.5
"@vitest/browser-preview": 4.1.5
"@vitest/browser-webdriverio": 4.1.5
"@vitest/coverage-istanbul": 4.1.5
"@vitest/coverage-v8": 4.1.5
"@vitest/ui": 4.1.5
happy-dom: "*"
jsdom: "*"
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -13363,7 +13363,7 @@ __metadata:
optional: false
bin:
vitest: vitest.mjs
checksum: 10/c5608c506ae9ab3d0baa7445290c240941ad54a93eb853a005b2fe518efb1b28282945e0565ca16a624cca5b23af0c33ee34fbc2c38e6664ea54b08b9a22a653
checksum: 10/8b768514993d8908fc9b5f2d619943d23b81aaba9443132583bd58aeb441bf76d152961326de9ca328ff0efcddbf8a58f4568a7b66a4391202542ed772613d81
languageName: node
linkType: hard