Compare commits

..

1 Commits

Author SHA1 Message Date
Aidan Timson 3f9538890a Implement sections for gallery sidebar 2026-06-15 09:57:27 +01:00
7 changed files with 703 additions and 531 deletions
+18 -1
View File
@@ -103,12 +103,29 @@ gulp.task("gather-gallery-pages", async function gatherPages() {
if (!toProcess) {
console.error("Unknown category", group.category);
if (!group.pages) {
if (!group.subsections && !group.pages) {
group.pages = [];
}
continue;
}
if (group.subsections) {
// Listed pages keep their per-subsection order.
for (const subsection of group.subsections) {
for (const page of subsection.pages) {
if (!toProcess.delete(page)) {
console.error("Found unreferenced demo", page);
}
}
}
// Any remaining pages land in a trailing "Other" subsection.
const leftover = Array.from(toProcess).sort();
if (leftover.length) {
group.subsections.push({ header: "Other", pages: leftover });
}
continue;
}
// Any pre-defined groups will not be sorted.
if (group.pages) {
for (const page of group.pages) {
+11
View File
@@ -62,6 +62,17 @@ Use `sidebar.js` when a page needs a visible section, section header, or determi
- New categories without a sidebar entry are appended by the generator with their category name as the header.
- If a listed page does not exist, the generator logs an error during `gather-gallery-pages`.
### Subsections
A section can group its pages under named subsections instead of one flat list. Use this for large categories where related pages should sit together.
- `subsections` is an array of `{ header, pages }`. It is mutually exclusive with a flat `pages` array on the same group.
- Each subsection `header` is a non-collapsible label rendered inside the section's expansion panel; the section stays the only collapsible level.
- Listed pages keep their per-subsection order.
- Any pages found in the category but not listed in a subsection are collected into a generated `Other` subsection, appended alphabetically. The `Other` subsection is omitted when there are no leftovers.
- A listed page that does not exist still logs an error during `gather-gallery-pages`.
- Use sentence case for subsection headers and follow the content standards below.
## Markdown Pages
Use markdown pages for explanations, design guidance, API notes, and copy standards.
+164 -9
View File
@@ -10,6 +10,10 @@ import {
mdiViewDashboard,
} from "@mdi/js";
// A group may list its pages flat in `pages`, or group them under named
// `subsections`. The two are mutually exclusive. Listed pages keep their order;
// any pages found in the category but not listed are appended alphabetically
// (to a generated "Other" subsection when the group uses subsections).
export default [
{
// This section has no header and so all page links are shown directly in the sidebar
@@ -27,31 +31,162 @@ export default [
category: "components",
icon: mdiPuzzle,
header: "Components",
subsections: [
{
header: "Form and selectors",
pages: [
"ha-form",
"ha-selector",
"ha-select-box",
"ha-input",
"ha-textarea",
],
},
{
header: "Controls and sliders",
pages: [
"ha-button",
"ha-control-button",
"ha-progress-button",
"ha-switch",
"ha-control-switch",
"ha-slider",
"ha-control-slider",
"ha-control-circular-slider",
"ha-control-number-buttons",
"ha-control-select",
"ha-control-select-menu",
"ha-hs-color-picker",
],
},
{
header: "Overlays",
pages: [
"ha-dialog",
"ha-dialogs",
"ha-adaptive-dialog",
"ha-adaptive-popover",
"ha-dropdown",
"ha-tooltip",
],
},
{
header: "Lists and disclosure",
pages: ["ha-list", "ha-expansion-panel", "ha-faded"],
},
{
header: "Feedback and status",
pages: ["ha-alert", "ha-spinner", "ha-tip", "ha-bar", "ha-gauge"],
},
{
header: "Labels and text",
pages: ["ha-badge", "ha-label-badge", "ha-chips", "ha-marquee-text"],
},
],
},
{
category: "lovelace",
icon: mdiViewDashboard,
// Label for in the sidebar
header: "Dashboards",
// Specify order of pages. Any pages in the category folder but not listed here will
// automatically be added after the pages listed here.
pages: ["introduction"],
subsections: [
{
header: "Introduction",
pages: ["introduction"],
},
{
header: "Entity cards",
pages: [
"entities-card",
"entity-button-card",
"entity-filter-card",
"glance-card",
"tile-card",
"area-card",
],
},
{
header: "Picture cards",
pages: [
"picture-card",
"picture-elements-card",
"picture-entity-card",
"picture-glance-card",
],
},
{
header: "Domain cards",
pages: [
"light-card",
"thermostat-card",
"alarm-panel-card",
"gauge-card",
"plant-card",
"map-card",
"media-control-card",
"media-player-row",
],
},
{
header: "Layout and utility",
pages: [
"grid-and-stack-card",
"conditional-card",
"iframe-card",
"markdown-card",
"todo-list-card",
],
},
],
},
{
category: "more-info",
icon: mdiInformationOutline,
header: "More Info dialogs",
subsections: [
{
header: "Climate and water",
pages: ["climate", "humidifier", "water-heater", "fan"],
},
{
header: "Covers and access",
pages: ["cover", "lock", "lawn-mower", "vacuum"],
},
{
header: "Lighting",
pages: ["light", "scene"],
},
{
header: "Media",
pages: ["media-player"],
},
{
header: "Inputs and values",
pages: ["input-number", "input-text", "number", "timer"],
},
{
header: "System",
pages: ["update"],
},
],
},
{
category: "automation",
icon: mdiRobot,
header: "Automation",
pages: [
"editor-trigger",
"editor-condition",
"editor-action",
"trace",
"trace-timeline",
subsections: [
{
header: "Editors",
pages: ["editor-trigger", "editor-condition", "editor-action"],
},
{
header: "Descriptions",
pages: ["describe-trigger", "describe-condition", "describe-action"],
},
{
header: "Traces",
pages: ["trace", "trace-timeline"],
},
],
},
{
@@ -64,6 +199,26 @@ export default [
category: "date-time",
icon: mdiCalendarClock,
header: "Date and Time",
subsections: [
{
header: "Date",
pages: ["date"],
},
{
header: "Time",
pages: ["time", "time-seconds", "time-weekday"],
},
{
header: "Combined",
pages: [
"date-time",
"date-time-numeric",
"date-time-seconds",
"date-time-short",
"date-time-short-year",
],
},
],
},
{
category: "misc",
+60 -20
View File
@@ -40,15 +40,26 @@ interface GalleryPage {
demo?: unknown;
}
interface GallerySidebarSubsection {
header: string;
pages: string[];
}
interface GallerySidebarGroup {
category: string;
header?: string;
icon?: string;
pages: string[];
pages?: string[];
subsections?: GallerySidebarSubsection[];
}
const groupPages = (group: GallerySidebarGroup): string[] =>
group.subsections
? group.subsections.flatMap((subsection) => subsection.pages)
: (group.pages ?? []);
const GALLERY_SIDEBAR = SIDEBAR as GallerySidebarGroup[];
const DEFAULT_PAGE = `${GALLERY_SIDEBAR[0].category}/${GALLERY_SIDEBAR[0].pages[0]}`;
const DEFAULT_PAGE = `${GALLERY_SIDEBAR[0].category}/${groupPages(GALLERY_SIDEBAR[0])[0]}`;
const mql = matchMedia("(prefers-color-scheme: dark)");
@@ -284,26 +295,15 @@ class HaGallery extends LitElement {
const sidebar: unknown[] = [];
for (const group of GALLERY_SIDEBAR) {
const links: unknown[] = [];
const expanded = group.pages.some(
const expanded = groupPages(group).some(
(page) => this._page === `${group.category}/${page}`
);
for (const page of group.pages) {
const key = `${group.category}/${page}`;
if (!(key in PAGES)) {
console.error("Undefined page referenced in sidebar.js:", key);
continue;
}
links.push(
this._renderPageLink(
key,
PAGES[key].metadata.title || page,
group.header ? undefined : "main-navigation",
group.header ? undefined : group.icon
const content = group.subsections
? group.subsections.map((subsection) =>
this._renderSidebarSubsection(group, subsection)
)
);
}
: this._renderPageLinks(group, group.pages ?? []);
sidebar.push(
group.header
@@ -321,16 +321,46 @@ class HaGallery extends LitElement {
.path=${group.icon}
></ha-svg-icon>`
: nothing}
${links}
${content}
</ha-expansion-panel>
`
: links
: content
);
}
return sidebar;
}
private _renderSidebarSubsection(
group: GallerySidebarGroup,
subsection: GallerySidebarSubsection
) {
return html`
<div class="gallery-sidebar-subheader">${subsection.header}</div>
${this._renderPageLinks(group, subsection.pages)}
`;
}
private _renderPageLinks(group: GallerySidebarGroup, pages: string[]) {
const links: unknown[] = [];
for (const page of pages) {
const key = `${group.category}/${page}`;
if (!(key in PAGES)) {
console.error("Undefined page referenced in sidebar.js:", key);
continue;
}
links.push(
this._renderPageLink(
key,
PAGES[key].metadata.title || page,
group.header ? undefined : "main-navigation",
group.header ? undefined : group.icon
)
);
}
return links;
}
private _renderPageLink(
page: string,
title: string,
@@ -585,6 +615,16 @@ class HaGallery extends LitElement {
width: var(--ha-sidebar-expanded-section-item-width, 248px);
}
.gallery-sidebar-subheader {
margin: var(--ha-space-2) var(--ha-space-4) var(--ha-space-1);
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.gallery-sidebar-icon,
.gallery-nav-item ha-svg-icon[slot="start"] {
color: var(--sidebar-icon-color);
-30
View File
@@ -1,30 +0,0 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { ReactiveControllerHost } from "lit";
import { clamp } from "../number/clamp";
// Count columns from the container's real width (not the viewport) so a
// docked sidebar is accounted for, like the dashboard sections view.
const MIN_COLUMN_WIDTH = 320;
const DEFAULT_COLUMN_GAP = 16;
const parsePx = (value: string) => parseInt(value, 10) || 0;
export const createColumnsController = (
host: ReactiveControllerHost & Element,
maxColumns: number
) =>
new ResizeController<number>(host, {
target: null,
skipInitial: true,
callback: (entries) => {
const entry = entries[0];
if (!entry) {
return maxColumns;
}
const width = entry.contentRect.width;
const gap =
parsePx(getComputedStyle(entry.target).columnGap) || DEFAULT_COLUMN_GAP;
const columns = Math.floor((width + gap) / (MIN_COLUMN_WIDTH + gap));
return clamp(columns, 1, maxColumns);
},
});
+272 -283
View File
@@ -34,7 +34,6 @@ import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import { slugify } from "../../../common/string/slugify";
import { groupBy } from "../../../common/util/group-by";
import { afterNextRender } from "../../../common/util/render-status";
import { createColumnsController } from "../../../common/util/responsive-columns";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-dropdown";
@@ -140,8 +139,6 @@ const NAVIGATION_ACTIONS: {
},
] as const;
const MAX_COLUMNS = 3;
@customElement("ha-config-area-page")
class HaConfigAreaPage extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -162,8 +159,6 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
private _logbookTime = { recent: 86400 };
private _columnsController = createColumnsController(this, MAX_COLUMNS);
private _memberships = memoizeOne(
(
areaId: string,
@@ -362,267 +357,6 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
)
);
const infoColumn = html`
${area.picture
? html`<div class="img-container">
<img alt=${area.name} src=${area.picture} />
<ha-icon-button
.path=${mdiPencil}
.entry=${area}
@click=${this._showSettings}
.label=${this.hass.localize(
"ui.panel.config.areas.edit_settings"
)}
class="img-edit-btn"
></ha-icon-button>
</div>`
: nothing}
${area.picture && !this._newTriggersConditions
? nothing
: html`<div class="action-buttons">
${area.picture
? nothing
: html`<ha-button
appearance="filled"
.entry=${area}
@click=${this._showSettings}
>
<ha-svg-icon .path=${mdiImagePlus} slot="start"></ha-svg-icon>
${this.hass.localize("ui.panel.config.areas.add_picture")}
</ha-button>`}
${this._newTriggersConditions
? html`<ha-button
appearance="filled"
variant="brand"
@click=${this._showAddToDialog}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.add_to.item"
)}
</ha-button>`
: nothing}
</div>`}
<ha-card
outlined
.header=${this.hass.localize("ui.panel.config.devices.caption")}
>${devices.length
? html`<ha-list>
${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>`
: html`
<div class="no-entries">
${this.hass.localize("ui.panel.config.devices.no_devices")}
</div>
`}
</ha-card>
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.areas.editor.linked_entities_caption"
)}
>
${nonAutomatedEntities.length
? html`<ha-list>
${nonAutomatedEntities.map(
(entity) => html`
<ha-list-item
@click=${this._openEntity}
.entity=${entity}
hasMeta
>
<span>${entity.name}</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
`
)}</ha-list
>`
: html`
<div class="no-entries">
${this.hass.localize(
"ui.panel.config.areas.editor.no_linked_entities"
)}
</div>
`}
</ha-card>
`;
const relatedColumn = html`
${isComponentLoaded(this.hass.config, "automation")
? html`
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.devices.automation.automations_heading"
)}
>
${groupedAutomations?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.assigned_to_area"
)}:
</h3>
<ha-list>
${groupedAutomations.map((automation) =>
this._renderAutomation(
automation.name,
automation.entity
)
)}</ha-list
>`
: ""}
${relatedAutomations?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.targeting_area"
)}:
</h3>
<ha-list>
${relatedAutomations.map((automation) =>
this._renderAutomation(
automation.name,
automation.entity
)
)}</ha-list
>`
: ""}
${!groupedAutomations?.length && !relatedAutomations?.length
? html`
<div class="no-entries">
${this.hass.localize(
"ui.panel.config.devices.automation.no_automations"
)}
</div>
`
: ""}
</ha-card>
`
: ""}
${isComponentLoaded(this.hass.config, "scene")
? html`
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.devices.scene.scenes_heading"
)}
>
${groupedScenes?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.assigned_to_area"
)}:
</h3>
<ha-list>
${groupedScenes.map((scene) =>
this._renderScene(scene.name, scene.entity)
)}</ha-list
>`
: ""}
${relatedScenes?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.targeting_area"
)}:
</h3>
<ha-list>
${relatedScenes.map((scene) =>
this._renderScene(scene.name, scene.entity)
)}</ha-list
>`
: ""}
${!groupedScenes?.length && !relatedScenes?.length
? html`
<div class="no-entries">
${this.hass.localize(
"ui.panel.config.devices.scene.no_scenes"
)}
</div>
`
: ""}
</ha-card>
`
: ""}
${isComponentLoaded(this.hass.config, "script")
? html`
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.devices.script.scripts_heading"
)}
>
${groupedScripts?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.assigned_to_area"
)}:
</h3>
${groupedScripts.map((script) =>
this._renderScript(script.name, script.entity)
)}`
: ""}
${relatedScripts?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.targeting_area"
)}:
</h3>
${relatedScripts.map((script) =>
this._renderScript(script.name, script.entity)
)}`
: ""}
${!groupedScripts?.length && !relatedScripts?.length
? html`
<div class="no-entries">
${this.hass.localize(
"ui.panel.config.devices.script.no_scripts"
)}
</div>
`
: ""}
</ha-card>
`
: ""}
`;
const logbookColumn = html`
${isComponentLoaded(this.hass.config, "logbook")
? html`
<ha-card outlined .header=${this.hass.localize("panel.logbook")}>
<ha-logbook
.hass=${this.hass}
.time=${this._logbookTime}
.entityIds=${this._allEntities(memberships)}
.deviceIds=${this._allDeviceIds(memberships.devices)}
virtualize
narrow
no-icon
></ha-logbook>
</ha-card>
`
: ""}
`;
// In 2 columns the logbook goes on the right, under the shorter
// automations/scenes/scripts column, to balance the column heights.
const columns =
this._columnsController.value ?? (this.narrow ? 1 : MAX_COLUMNS);
const columnContents =
columns >= 3
? [[infoColumn], [relatedColumn], [logbookColumn]]
: columns === 2
? [[infoColumn], [relatedColumn, logbookColumn]]
: [[infoColumn, relatedColumn, logbookColumn]];
return html`
<hass-subpage
.hass=${this.hass}
@@ -667,10 +401,266 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
</ha-dropdown-item>
</ha-dropdown>
<div class="container" ${this._columnsController.target()}>
${columnContents.map(
(contents) => html`<div class="column">${contents}</div>`
)}
<div class="container">
<div class="column">
${area.picture
? html`<div class="img-container">
<img alt=${area.name} src=${area.picture} />
<ha-icon-button
.path=${mdiPencil}
.entry=${area}
@click=${this._showSettings}
.label=${this.hass.localize(
"ui.panel.config.areas.edit_settings"
)}
class="img-edit-btn"
></ha-icon-button>
</div>`
: nothing}
${area.picture && !this._newTriggersConditions
? nothing
: html`<div class="action-buttons">
${area.picture
? nothing
: html`<ha-button
appearance="filled"
.entry=${area}
@click=${this._showSettings}
>
<ha-svg-icon
.path=${mdiImagePlus}
slot="start"
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.areas.add_picture"
)}
</ha-button>`}
${this._newTriggersConditions
? html`<ha-button
appearance="filled"
variant="brand"
@click=${this._showAddToDialog}
>
<ha-svg-icon
slot="start"
.path=${mdiPlus}
></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.add_to.item"
)}
</ha-button>`
: nothing}
</div>`}
<ha-card
outlined
.header=${this.hass.localize("ui.panel.config.devices.caption")}
>${devices.length
? html`<ha-list>
${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>`
: html`
<div class="no-entries">
${this.hass.localize(
"ui.panel.config.devices.no_devices"
)}
</div>
`}
</ha-card>
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.areas.editor.linked_entities_caption"
)}
>
${nonAutomatedEntities.length
? html`<ha-list>
${nonAutomatedEntities.map(
(entity) => html`
<ha-list-item
@click=${this._openEntity}
.entity=${entity}
hasMeta
>
<span>${entity.name}</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
`
)}</ha-list
>`
: html`
<div class="no-entries">
${this.hass.localize(
"ui.panel.config.areas.editor.no_linked_entities"
)}
</div>
`}
</ha-card>
</div>
<div class="column">
${isComponentLoaded(this.hass.config, "automation")
? html`
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.devices.automation.automations_heading"
)}
>
${groupedAutomations?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.assigned_to_area"
)}:
</h3>
<ha-list>
${groupedAutomations.map((automation) =>
this._renderAutomation(
automation.name,
automation.entity
)
)}</ha-list
>`
: ""}
${relatedAutomations?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.targeting_area"
)}:
</h3>
<ha-list>
${relatedAutomations.map((automation) =>
this._renderAutomation(
automation.name,
automation.entity
)
)}</ha-list
>`
: ""}
${!groupedAutomations?.length && !relatedAutomations?.length
? html`
<div class="no-entries">
${this.hass.localize(
"ui.panel.config.devices.automation.no_automations"
)}
</div>
`
: ""}
</ha-card>
`
: ""}
${isComponentLoaded(this.hass.config, "scene")
? html`
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.devices.scene.scenes_heading"
)}
>
${groupedScenes?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.assigned_to_area"
)}:
</h3>
<ha-list>
${groupedScenes.map((scene) =>
this._renderScene(scene.name, scene.entity)
)}</ha-list
>`
: ""}
${relatedScenes?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.targeting_area"
)}:
</h3>
<ha-list>
${relatedScenes.map((scene) =>
this._renderScene(scene.name, scene.entity)
)}</ha-list
>`
: ""}
${!groupedScenes?.length && !relatedScenes?.length
? html`
<div class="no-entries">
${this.hass.localize(
"ui.panel.config.devices.scene.no_scenes"
)}
</div>
`
: ""}
</ha-card>
`
: ""}
${isComponentLoaded(this.hass.config, "script")
? html`
<ha-card
outlined
.header=${this.hass.localize(
"ui.panel.config.devices.script.scripts_heading"
)}
>
${groupedScripts?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.assigned_to_area"
)}:
</h3>
${groupedScripts.map((script) =>
this._renderScript(script.name, script.entity)
)}`
: ""}
${relatedScripts?.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.areas.targeting_area"
)}:
</h3>
${relatedScripts.map((script) =>
this._renderScript(script.name, script.entity)
)}`
: ""}
${!groupedScripts?.length && !relatedScripts?.length
? html`
<div class="no-entries">
${this.hass.localize(
"ui.panel.config.devices.script.no_scripts"
)}
</div>
`
: ""}
</ha-card>
`
: ""}
</div>
<div class="column">
${isComponentLoaded(this.hass.config, "logbook")
? html`
<ha-card
outlined
.header=${this.hass.localize("panel.logbook")}
>
<ha-logbook
.hass=${this.hass}
.time=${this._logbookTime}
.entityIds=${this._allEntities(memberships)}
.deviceIds=${this._allDeviceIds(memberships.devices)}
virtualize
narrow
no-icon
></ha-logbook>
</ha-card>
`
: ""}
</div>
</div>
</hass-subpage>
`;
@@ -914,31 +904,30 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
width: 100%;
}
:host {
display: block;
}
.container {
display: flex;
flex-wrap: wrap;
gap: var(--ha-space-4);
margin: auto;
max-width: 1280px;
box-sizing: border-box;
padding: var(--ha-space-2) var(--ha-space-4);
margin-top: var(--ha-space-8);
margin-bottom: var(--ha-space-8);
max-width: 1000px;
margin-top: 32px;
margin-bottom: 32px;
}
.column {
padding: 8px;
box-sizing: border-box;
flex: 1 1 0;
min-width: 0;
width: 33%;
flex-grow: 1;
}
.fullwidth {
padding: var(--ha-space-2);
padding: 8px;
width: 100%;
}
.column > *:not(:first-child) {
margin-top: var(--ha-space-4);
margin-top: 16px;
}
:host([narrow]) .column {
width: 100%;
}
:host([narrow]) .container {
+178 -188
View File
@@ -38,7 +38,6 @@ import { stringCompare } from "../../../common/string/compare";
import { slugify } from "../../../common/string/slugify";
import { computeRTL } from "../../../common/util/compute_rtl";
import { groupBy } from "../../../common/util/group-by";
import { createColumnsController } from "../../../common/util/responsive-columns";
import "../../../components/entity/ha-battery-icon";
import "../../../components/ha-alert";
import "../../../components/ha-button";
@@ -176,8 +175,6 @@ export interface DeviceAlert {
const DEVICE_ALERTS_INTERVAL = 30000;
const MAX_COLUMNS = 3;
@customElement("ha-config-device-page")
export class HaConfigDevicePage extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -214,8 +211,6 @@ export class HaConfigDevicePage extends LitElement {
private _logbookTime = { recent: 86400 };
private _columnsController = createColumnsController(this, MAX_COLUMNS);
private _integrations = memoizeOne(
(
device: DeviceRegistryEntry,
@@ -754,176 +749,6 @@ export class HaConfigDevicePage extends LitElement {
`
: "";
const infoColumn = html`
${this._deviceAlerts?.length
? html`
<div>
${this._deviceAlerts.map(
(alert) => html`
<ha-alert .alertType=${alert.level}> ${alert.text} </ha-alert>
`
)}
</div>
`
: ""}
<ha-device-info-card .hass=${this.hass} .device=${device}>
${deviceInfo}
${firstDeviceAction || actions.length
? html`
<div class="card-actions" slot="actions">
<ha-button
href=${ifDefined(firstDeviceAction!.href)}
rel=${ifDefined(
firstDeviceAction!.target ? "noreferrer" : undefined
)}
appearance="plain"
target=${ifDefined(firstDeviceAction!.target)}
class=${ifDefined(firstDeviceAction!.classes)}
.variant=${firstDeviceAction!.classes?.includes("warning")
? "danger"
: "brand"}
.action=${firstDeviceAction!.action}
@click=${this._deviceActionClicked}
>
${firstDeviceAction!.label}
${firstDeviceAction!.icon
? html`
<ha-svg-icon
class=${ifDefined(firstDeviceAction!.classes)}
.path=${firstDeviceAction!.icon}
slot="start"
></ha-svg-icon>
`
: nothing}
${firstDeviceAction!.trailingIcon
? html`
<ha-svg-icon
.path=${firstDeviceAction!.trailingIcon}
slot="end"
></ha-svg-icon>
`
: nothing}
</ha-button>
${actions.length
? html`
<ha-dropdown
@wa-select=${this._deviceActionSelected}
placement="bottom-end"
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
${actions.map((deviceAction, idx) => {
const dropdownItem = html`<ha-dropdown-item
.value=${idx}
.data=${deviceAction}
.variant=${deviceAction.classes?.includes("warning")
? "danger"
: "default"}
>
${deviceAction.icon
? html`
<ha-svg-icon
.path=${deviceAction.icon}
slot="icon"
></ha-svg-icon>
`
: ""}
${deviceAction.label}
${deviceAction.trailingIcon
? html`
<ha-svg-icon
slot="details"
.path=${deviceAction.trailingIcon}
></ha-svg-icon>
`
: ""}
</ha-dropdown-item>`;
return deviceAction.href
? html`<a
href=${deviceAction.href}
target=${ifDefined(deviceAction.target)}
rel=${ifDefined(
deviceAction.target ? "noreferrer" : undefined
)}
>${dropdownItem}
</a>`
: dropdownItem;
})}
</ha-dropdown>
`
: ""}
</div>
`
: ""}
</ha-device-info-card>
`;
const entitiesColumn = html`
${(
[
"control",
"sensor",
"notify",
"event",
"assist",
"config",
"diagnostic",
] as const
).map((category) =>
// Make sure we render controls if no other cards will be rendered
entitiesByCategory[category].length > 0 ||
(entities.length === 0 && category === "control")
? html`
<ha-device-entities-card
.hass=${this.hass}
.header=${this.hass.localize(
`ui.panel.config.devices.entities.${category}`
)}
.deviceName=${deviceName}
.entities=${entitiesByCategory[category]}
.showHidden=${device.disabled_by !== null}
>
</ha-device-entities-card>
`
: ""
)}
<ha-device-via-devices-card
.hass=${this.hass}
.deviceId=${this.deviceId}
></ha-device-via-devices-card>
`;
const logbookColumn = isComponentLoaded(this.hass.config, "logbook")
? html`
<ha-card outlined>
<h1 class="card-header">${this.hass.localize("panel.logbook")}</h1>
<ha-logbook
.hass=${this.hass}
.time=${this._logbookTime}
.entityIds=${this._entityIds(entities)}
.deviceIds=${this._deviceIdInList(this.deviceId)}
virtualize
narrow
no-icon
></ha-logbook>
</ha-card>
`
: nothing;
const columns =
this._columnsController.value ?? (this.narrow ? 1 : MAX_COLUMNS);
const columnContents =
columns >= 3
? [[infoColumn, relatedCard], [entitiesColumn], [logbookColumn]]
: columns === 2
? [[infoColumn, relatedCard, logbookColumn], [entitiesColumn]]
: [[infoColumn, entitiesColumn, relatedCard, logbookColumn]];
return html`<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
@@ -971,7 +796,7 @@ export class HaConfigDevicePage extends LitElement {
</ha-dropdown-item>
</ha-dropdown>
<div class="container" ${this._columnsController.target()}>
<div class="container">
<div class="header fullwidth">
${area
? html`<div class="header-name">
@@ -1024,9 +849,175 @@ export class HaConfigDevicePage extends LitElement {
: ""}
</div>
</div>
${columnContents.map(
(contents) => html`<div class="column">${contents}</div>`
)}
<div class="column">
${this._deviceAlerts?.length
? html`
<div>
${this._deviceAlerts.map(
(alert) => html`
<ha-alert .alertType=${alert.level}>
${alert.text}
</ha-alert>
`
)}
</div>
`
: ""}
<ha-device-info-card .hass=${this.hass} .device=${device}>
${deviceInfo}
${firstDeviceAction || actions.length
? html`
<div class="card-actions" slot="actions">
<ha-button
href=${ifDefined(firstDeviceAction!.href)}
rel=${ifDefined(
firstDeviceAction!.target ? "noreferrer" : undefined
)}
appearance="plain"
target=${ifDefined(firstDeviceAction!.target)}
class=${ifDefined(firstDeviceAction!.classes)}
.variant=${firstDeviceAction!.classes?.includes("warning")
? "danger"
: "brand"}
.action=${firstDeviceAction!.action}
@click=${this._deviceActionClicked}
>
${firstDeviceAction!.label}
${firstDeviceAction!.icon
? html`
<ha-svg-icon
class=${ifDefined(firstDeviceAction!.classes)}
.path=${firstDeviceAction!.icon}
slot="start"
></ha-svg-icon>
`
: nothing}
${firstDeviceAction!.trailingIcon
? html`
<ha-svg-icon
.path=${firstDeviceAction!.trailingIcon}
slot="end"
></ha-svg-icon>
`
: nothing}
</ha-button>
${actions.length
? html`
<ha-dropdown
@wa-select=${this._deviceActionSelected}
placement="bottom-end"
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
${actions.map((deviceAction, idx) => {
const dropdownItem = html`<ha-dropdown-item
.value=${idx}
.data=${deviceAction}
.variant=${deviceAction.classes?.includes(
"warning"
)
? "danger"
: "default"}
>
${deviceAction.icon
? html`
<ha-svg-icon
.path=${deviceAction.icon}
slot="icon"
></ha-svg-icon>
`
: ""}
${deviceAction.label}
${deviceAction.trailingIcon
? html`
<ha-svg-icon
slot="details"
.path=${deviceAction.trailingIcon}
></ha-svg-icon>
`
: ""}
</ha-dropdown-item>`;
return deviceAction.href
? html`<a
href=${deviceAction.href}
target=${ifDefined(deviceAction.target)}
rel=${ifDefined(
deviceAction.target
? "noreferrer"
: undefined
)}
>${dropdownItem}
</a>`
: dropdownItem;
})}
</ha-dropdown>
`
: ""}
</div>
`
: ""}
</ha-device-info-card>
${!this.narrow ? relatedCard : ""}
</div>
<div class="column">
${(
[
"control",
"sensor",
"notify",
"event",
"assist",
"config",
"diagnostic",
] as const
).map((category) =>
// Make sure we render controls if no other cards will be rendered
entitiesByCategory[category].length > 0 ||
(entities.length === 0 && category === "control")
? html`
<ha-device-entities-card
.hass=${this.hass}
.header=${this.hass.localize(
`ui.panel.config.devices.entities.${category}`
)}
.deviceName=${deviceName}
.entities=${entitiesByCategory[category]}
.showHidden=${device.disabled_by !== null}
>
</ha-device-entities-card>
`
: ""
)}
<ha-device-via-devices-card
.hass=${this.hass}
.deviceId=${this.deviceId}
></ha-device-via-devices-card>
</div>
<div class="column">
${this.narrow ? relatedCard : ""}
${isComponentLoaded(this.hass.config, "logbook")
? html`
<ha-card outlined>
<h1 class="card-header">
${this.hass.localize("panel.logbook")}
</h1>
<ha-logbook
.hass=${this.hass}
.time=${this._logbookTime}
.entityIds=${this._entityIds(entities)}
.deviceIds=${this._deviceIdInList(this.deviceId)}
virtualize
narrow
no-icon
></ha-logbook>
</ha-card>
`
: ""}
</div>
</div>
</hass-subpage>`;
}
@@ -1636,17 +1627,11 @@ export class HaConfigDevicePage extends LitElement {
return [
haStyle,
css`
:host {
display: block;
}
.container {
display: flex;
flex-wrap: wrap;
gap: var(--ha-space-4);
margin: auto;
max-width: 1280px;
box-sizing: border-box;
padding: var(--ha-space-2) var(--ha-space-4);
max-width: 1000px;
margin-top: var(--ha-space-8);
margin-bottom: var(--ha-space-8);
}
@@ -1707,11 +1692,12 @@ export class HaConfigDevicePage extends LitElement {
.column,
.fullwidth {
padding: var(--ha-space-2);
box-sizing: border-box;
}
.column {
flex: 1 1 0;
min-width: 0;
width: 33%;
flex-grow: 1;
}
.fullwidth {
width: 100%;
@@ -1753,6 +1739,10 @@ export class HaConfigDevicePage extends LitElement {
margin-top: var(--ha-space-4);
}
:host([narrow]) .column {
width: 100%;
}
a {
text-decoration: none;
color: var(--primary-color);