Compare commits

..

5 Commits

Author SHA1 Message Date
Wendelin
0ae2dc28d2 fix filter radius 2025-10-16 11:53:17 +02:00
Wendelin
5000a207a1 Single select filter 2025-10-16 11:52:59 +02:00
Wendelin
402dd5c71b Use filter-chips for target picker 2025-10-15 17:20:35 +02:00
karwosts
e35b155c66 Delete image selector (#27519) 2025-10-15 13:32:10 +00:00
Paul Bottein
437d02c12f Group dashboards by type (#27517)
Group dashboard by type
2025-10-15 16:11:37 +03:00
8 changed files with 114 additions and 226 deletions

View File

@@ -1,9 +1,9 @@
import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles";
import { FilterChip } from "@material/web/chips/internal/filter-chip";
import { styles } from "@material/web/chips/internal/filter-styles";
import { styles as selectableStyles } from "@material/web/chips/internal/selectable-styles";
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles";
import { styles as trailingIconStyles } from "@material/web/chips/internal/trailing-icon-styles";
import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles";
import { css, html } from "lit";
import { customElement, property } from "lit/decorators";
@@ -30,6 +30,7 @@ export class HaFilterChip extends FilterChip {
var(--rgb-primary-text-color),
0.15
);
border-radius: var(--ha-border-radius-md);
}
`,
];

View File

@@ -1,152 +0,0 @@
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { ImageSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-icon-button";
import "../ha-textarea";
import "../ha-textfield";
import "../ha-picture-upload";
import "../ha-radio";
import "../ha-formfield";
import type { HaPictureUpload } from "../ha-picture-upload";
import { URL_PREFIX } from "../../data/image_upload";
@customElement("ha-selector-image")
export class HaImageSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public value?: any;
@property() public name?: string;
@property() public label?: string;
@property() public placeholder?: string;
@property() public helper?: string;
@property({ attribute: false }) public selector!: ImageSelector;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
@state() private showUpload = false;
protected firstUpdated(changedProps): void {
super.firstUpdated(changedProps);
if (!this.value || this.value.startsWith(URL_PREFIX)) {
this.showUpload = true;
}
}
protected render() {
return html`
<div>
<label>
${this.hass.localize(
"ui.components.selectors.image.select_image_with_label",
{
label:
this.label ||
this.hass.localize("ui.components.selectors.image.image"),
}
)}
<ha-formfield
.label=${this.hass.localize("ui.components.selectors.image.upload")}
>
<ha-radio
name="mode"
value="upload"
.checked=${this.showUpload}
@change=${this._radioGroupPicked}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize("ui.components.selectors.image.url")}
>
<ha-radio
name="mode"
value="url"
.checked=${!this.showUpload}
@change=${this._radioGroupPicked}
></ha-radio>
</ha-formfield>
</label>
${!this.showUpload
? html`
<ha-textfield
.name=${this.name}
.value=${this.value || ""}
.placeholder=${this.placeholder || ""}
.helper=${this.helper}
helperPersistent
.disabled=${this.disabled}
@input=${this._handleChange}
.label=${this.label || ""}
.required=${this.required}
></ha-textfield>
`
: html`
<ha-picture-upload
.hass=${this.hass}
.value=${this.value?.startsWith(URL_PREFIX) ? this.value : null}
.original=${this.selector.image?.original}
.cropOptions=${this.selector.image?.crop}
select-media
@change=${this._pictureChanged}
></ha-picture-upload>
`}
</div>
`;
}
private _radioGroupPicked(ev): void {
this.showUpload = ev.target.value === "upload";
}
private _pictureChanged(ev) {
const value = (ev.target as HaPictureUpload).value;
fireEvent(this, "value-changed", { value: value ?? undefined });
}
private _handleChange(ev) {
let value = ev.target.value;
if (this.value === value) {
return;
}
if (value === "" && !this.required) {
value = undefined;
}
fireEvent(this, "value-changed", { value });
}
static styles = css`
:host {
display: block;
position: relative;
}
div {
display: flex;
flex-direction: column;
}
label {
display: flex;
flex-direction: column;
}
ha-textarea,
ha-textfield {
width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-image": HaImageSelector;
}
}

View File

@@ -34,7 +34,6 @@ const LOAD_ELEMENTS = {
file: () => import("./ha-selector-file"),
floor: () => import("./ha-selector-floor"),
label: () => import("./ha-selector-label"),
image: () => import("./ha-selector-image"),
background: () => import("./ha-selector-background"),
language: () => import("./ha-selector-language"),
navigation: () => import("./ha-selector-navigation"),

View File

@@ -76,7 +76,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@state() private _narrow = false;
@state() private _pickerFilters: TargetTypeFloorless[] = [];
@state() private _pickerFilter?: TargetTypeFloorless;
@state() private _pickerWrapperOpen = false;
@@ -330,12 +330,16 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
});
};
private _handleUpdatePickerFilters(ev: CustomEvent<TargetTypeFloorless[]>) {
this._updatePickerFilters(ev.detail);
private _handleUpdatePickerFilter(
ev: CustomEvent<TargetTypeFloorless | undefined>
) {
this._updatePickerFilter(
typeof ev.detail === "string" ? ev.detail : undefined
);
}
private _updatePickerFilters = (filters: TargetTypeFloorless[]) => {
this._pickerFilters = filters;
private _updatePickerFilter = (filter?: TargetTypeFloorless) => {
this._pickerFilter = filter;
};
private _hidePicker() {
@@ -355,8 +359,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
return html`
<ha-target-picker-selector
.hass=${this.hass}
@filter-types-changed=${this._handleUpdatePickerFilters}
.filterTypes=${this._pickerFilters}
@filter-type-changed=${this._handleUpdatePickerFilter}
.filterType=${this._pickerFilter}
@target-picked=${this._handleTargetPicked}
@create-domain-picked=${this._handleCreateDomain}
.targetValue=${this.value}

View File

@@ -1,6 +1,6 @@
import type { LitVirtualizer } from "@lit-labs/virtualizer";
import { consume } from "@lit/context";
import { mdiCheck, mdiPlus, mdiTextureBox } from "@mdi/js";
import { mdiPlus, mdiTextureBox } from "@mdi/js";
import Fuse from "fuse.js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
@@ -43,9 +43,10 @@ import { haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import "../chips/ha-chip-set";
import "../chips/ha-filter-chip";
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
import "../entity/state-badge";
import "../ha-button";
import "../ha-combo-box-item";
import "../ha-floor-icon";
import "../ha-md-list";
@@ -63,8 +64,7 @@ const CREATE_ID = "___create-new-entity___";
export class HaTargetPickerSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public filterTypes: TargetTypeFloorless[] =
[];
@property({ attribute: false }) public filterType?: TargetTypeFloorless;
@property({ reflect: true }) public mode: "popover" | "dialog" = "popover";
@@ -159,11 +159,10 @@ export class HaTargetPickerSelector extends LitElement {
@input=${this._searchChanged}
.value=${this._searchTerm}
></ha-textfield>
<div class="filter">${this._renderFilterButtons()}</div>
<ha-chip-set class="filter">${this._renderFilterButtons()}</ha-chip-set>
<div class="filter-header-wrapper">
<div
class="filter-header ${this.filterTypes.length !== 1 &&
this._filterHeader
class="filter-header ${!this.filterType && this._filterHeader
? "show"
: ""}"
>
@@ -175,7 +174,6 @@ export class HaTargetPickerSelector extends LitElement {
scroller
.keyFunction=${this._keyFunction}
.items=${this._getItems(
this.filterTypes,
this.entityFilter,
this.deviceFilter,
this.includeDomains,
@@ -184,7 +182,8 @@ export class HaTargetPickerSelector extends LitElement {
this._searchTerm,
this.createDomains,
this._configEntryLookup,
this.mode
this.mode,
this.filterType
)}
.renderItem=${this._renderRow}
@scroll=${this._onScrollList}
@@ -428,23 +427,17 @@ export class HaTargetPickerSelector extends LitElement {
return html`<div class="separator"></div>`;
}
const selected = this.filterTypes.includes(filterType);
const selected = this.filterType === filterType;
return html`
<ha-button
<ha-filter-chip
@click=${this._toggleFilter}
.type=${filterType}
size="small"
.variant=${selected ? "brand" : "neutral"}
appearance="filled"
no-shrink
>
${selected
? html`<ha-svg-icon slot="start" .path=${mdiCheck}></ha-svg-icon>`
: nothing}
${this.hass.localize(
.selected=${selected}
.label=${this.hass.localize(
`ui.components.target-picker.type.${filterType === "entity" ? "entities" : `${filterType}s`}` as LocalizeKeys
)}
</ha-button>
>
</ha-filter-chip>
`;
});
}
@@ -672,7 +665,6 @@ export class HaTargetPickerSelector extends LitElement {
private _getItems = memoizeOne(
(
filterTypes: TargetTypeFloorless[],
entityFilter: this["entityFilter"],
deviceFilter: this["deviceFilter"],
includeDomains: this["includeDomains"],
@@ -681,7 +673,8 @@ export class HaTargetPickerSelector extends LitElement {
searchTerm: string,
createDomains: this["createDomains"],
configEntryLookup: Record<string, ConfigEntry>,
mode: this["mode"]
mode: this["mode"],
filterType?: TargetTypeFloorless
) => {
const items: (
| string
@@ -690,7 +683,7 @@ export class HaTargetPickerSelector extends LitElement {
| PickerComboBoxItem
)[] = [];
if (filterTypes.length === 0 || filterTypes.includes("entity")) {
if (!filterType || filterType === "entity") {
let entities = this._getEntitiesMemoized(
this.hass,
includeDomains,
@@ -713,7 +706,7 @@ export class HaTargetPickerSelector extends LitElement {
) as EntityComboBoxItem[];
}
if (entities.length > 0 && filterTypes.length !== 1) {
if (!filterType) {
// show group title
items.push(
this.hass.localize("ui.components.target-picker.type.entities")
@@ -723,7 +716,7 @@ export class HaTargetPickerSelector extends LitElement {
items.push(...entities);
}
if (filterTypes.length === 0 || filterTypes.includes("device")) {
if (!filterType || filterType === "device") {
let devices = this._getDevicesMemoized(
this.hass,
configEntryLookup,
@@ -741,7 +734,7 @@ export class HaTargetPickerSelector extends LitElement {
devices = this._filterGroup("device", devices);
}
if (devices.length > 0 && filterTypes.length !== 1) {
if (!filterType) {
// show group title
items.push(
this.hass.localize("ui.components.target-picker.type.devices")
@@ -751,7 +744,7 @@ export class HaTargetPickerSelector extends LitElement {
items.push(...devices);
}
if (filterTypes.length === 0 || filterTypes.includes("area")) {
if (!filterType || filterType === "area") {
let areasAndFloors = this._getAreasAndFloorsMemoized(
this.hass.states,
this.hass.floors,
@@ -777,7 +770,7 @@ export class HaTargetPickerSelector extends LitElement {
) as FloorComboBoxItem[];
}
if (areasAndFloors.length > 0 && filterTypes.length !== 1) {
if (!filterType) {
// show group title
items.push(
this.hass.localize("ui.components.target-picker.type.areas")
@@ -803,7 +796,7 @@ export class HaTargetPickerSelector extends LitElement {
);
}
if (filterTypes.length === 0 || filterTypes.includes("label")) {
if (!filterType || filterType === "label") {
let labels = this._getLabelsMemoized(
this.hass,
this._labelRegistry,
@@ -819,7 +812,7 @@ export class HaTargetPickerSelector extends LitElement {
labels = this._filterGroup("label", labels);
}
if (labels.length > 0 && filterTypes.length !== 1) {
if (!filterType) {
// show group title
items.push(
this.hass.localize("ui.components.target-picker.type.labels")
@@ -944,17 +937,17 @@ export class HaTargetPickerSelector extends LitElement {
}
private _toggleFilter(ev: any) {
ev.stopPropagation();
this._resetSelectedItem();
this._filterHeader = undefined;
const type = ev.target.type as TargetTypeFloorless;
if (!type) {
return;
}
const index = this.filterTypes.indexOf(type);
if (index === -1) {
this.filterTypes = [...this.filterTypes, type];
if (this.filterType === type) {
this.filterType = undefined;
} else {
this.filterTypes = this.filterTypes.filter((t) => t !== type);
this.filterType = type;
}
// Reset scroll position when filter changes
@@ -962,7 +955,7 @@ export class HaTargetPickerSelector extends LitElement {
this._virtualizerElement.scrollToIndex(0);
}
fireEvent(this, "filter-types-changed", this.filterTypes);
fireEvent(this, "filter-type-changed", this.filterType);
}
@eventOptions({ passive: true })
@@ -994,18 +987,22 @@ export class HaTargetPickerSelector extends LitElement {
.filter {
display: flex;
flex-wrap: nowrap;
gap: var(--ha-space-2);
padding: var(--ha-space-3) var(--ha-space-3);
overflow: auto;
--ha-button-border-radius: var(--ha-border-radius-md);
}
:host([mode="dialog"]) .filter {
padding: var(--ha-space-3) var(--ha-space-4);
}
.filter ha-button {
.filter ha-filter-chip {
flex-shrink: 0;
--md-filter-chip-selected-container-color: var(
--ha-color-fill-primary-normal-hover
);
color: var(--primary-color);
}
.filter .separator {
@@ -1097,7 +1094,7 @@ declare global {
}
interface HASSDomEvents {
"filter-types-changed": TargetTypeFloorless[];
"filter-type-changed": TargetTypeFloorless | undefined;
"target-picked": {
type: TargetType;
id: string;

View File

@@ -18,7 +18,6 @@ import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/chips/ha-assist-chip";
import "../components/chips/ha-filter-chip";
import "../components/data-table/ha-data-table";
import type {
DataTableColumnContainer,

View File

@@ -9,7 +9,6 @@ import {
import type { PropertyValues } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import memoize from "memoize-one";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import { storage } from "../../../../common/decorators/storage";
@@ -62,7 +61,7 @@ type DataTableItem = Pick<
> & {
default: boolean;
filename: string;
iconColor?: string;
type: string;
};
@customElement("ha-config-lovelace-dashboards")
@@ -107,6 +106,20 @@ export class HaConfigLovelaceDashboards extends LitElement {
})
private _activeHiddenColumns?: string[];
@storage({
key: "lovelace-dashboards-table-grouping",
state: false,
subscribe: false,
})
private _activeGrouping?: string = "type";
@storage({
key: "lovelace-dashboards-table-collapsed",
state: false,
subscribe: false,
})
private _activeCollapsed: string[] = [];
public willUpdate() {
if (!this.hasUpdated) {
this.hass.loadFragmentTranslation("lovelace");
@@ -132,15 +145,7 @@ export class HaConfigLovelaceDashboards extends LitElement {
template: (dashboard) =>
dashboard.icon
? html`
<ha-icon
slot="item-icon"
.icon=${dashboard.icon}
style=${ifDefined(
dashboard.iconColor
? `color: ${dashboard.iconColor}`
: undefined
)}
></ha-icon>
<ha-icon slot="item-icon" .icon=${dashboard.icon}></ha-icon>
`
: nothing,
},
@@ -177,6 +182,15 @@ export class HaConfigLovelaceDashboards extends LitElement {
},
};
columns.type = {
title: localize(
"ui.panel.config.lovelace.dashboards.picker.headers.type"
),
sortable: true,
groupable: true,
filterable: true,
};
columns.mode = {
title: localize(
"ui.panel.config.lovelace.dashboards.picker.headers.conf_mode"
@@ -287,7 +301,7 @@ export class HaConfigLovelaceDashboards extends LitElement {
url_path: "lovelace",
mode: defaultMode,
filename: defaultMode === "yaml" ? "ui-lovelace.yaml" : "",
iconColor: "var(--primary-color)",
type: this._localizeType("built_in"),
},
];
if (isComponentLoaded(this.hass, "energy")) {
@@ -298,9 +312,9 @@ export class HaConfigLovelaceDashboards extends LitElement {
mode: "storage",
url_path: "energy",
filename: "",
iconColor: "var(--orange-color)",
default: false,
require_admin: false,
type: this._localizeType("built_in"),
});
}
@@ -312,9 +326,9 @@ export class HaConfigLovelaceDashboards extends LitElement {
mode: "storage",
url_path: "light",
filename: "",
iconColor: "var(--amber-color)",
default: false,
require_admin: false,
type: this._localizeType("built_in"),
});
}
@@ -326,9 +340,9 @@ export class HaConfigLovelaceDashboards extends LitElement {
mode: "storage",
url_path: "safety",
filename: "",
iconColor: "var(--blue-grey-color)",
default: false,
require_admin: false,
type: this._localizeType("built_in"),
});
}
@@ -340,9 +354,9 @@ export class HaConfigLovelaceDashboards extends LitElement {
mode: "storage",
url_path: "climate",
filename: "",
iconColor: "var(--deep-orange-color)",
default: false,
require_admin: false,
type: this._localizeType("built_in"),
});
}
@@ -351,16 +365,25 @@ export class HaConfigLovelaceDashboards extends LitElement {
.sort((a, b) =>
stringCompare(a.title, b.title, this.hass.locale.language)
)
.map((dashboard) => ({
filename: "",
...dashboard,
default: defaultUrlPath === dashboard.url_path,
}))
.map(
(dashboard) =>
({
filename: "",
...dashboard,
default: defaultUrlPath === dashboard.url_path,
type: this._localizeType("user_created"),
}) satisfies DataTableItem
)
);
return result;
}
);
private _localizeType = (type: "user_created" | "built_in") =>
this.hass.localize(
`ui.panel.config.lovelace.dashboards.picker.type.${type}`
);
protected render() {
if (!this.hass || this._dashboards === undefined) {
return html` <hass-loading-screen></hass-loading-screen> `;
@@ -380,9 +403,13 @@ export class HaConfigLovelaceDashboards extends LitElement {
this.hass.localize
)}
.data=${this._getItems(this._dashboards, this.hass.defaultPanel)}
.initialGroupColumn=${this._activeGrouping}
.initialCollapsedGroups=${this._activeCollapsed}
.initialSorting=${this._activeSorting}
.columnOrder=${this._activeColumnOrder}
.hiddenColumns=${this._activeHiddenColumns}
@grouping-changed=${this._handleGroupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
@columns-changed=${this._handleColumnsChanged}
@sorting-changed=${this._handleSortingChanged}
.filter=${this._filter}
@@ -443,13 +470,13 @@ export class HaConfigLovelaceDashboards extends LitElement {
}
private _canDelete(urlPath: string) {
return !["lovelace", "energy", "light", "security", "climate"].includes(
return !["lovelace", "energy", "light", "safety", "climate"].includes(
urlPath
);
}
private _canEdit(urlPath: string) {
return !["light", "security", "climate"].includes(urlPath);
return !["light", "safety", "climate"].includes(urlPath);
}
private _handleDelete = async (item: DataTableItem) => {
@@ -571,6 +598,14 @@ export class HaConfigLovelaceDashboards extends LitElement {
this._activeColumnOrder = ev.detail.columnOrder;
this._activeHiddenColumns = ev.detail.hiddenColumns;
}
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value;
}
private _handleCollapseChanged(ev: CustomEvent) {
this._activeCollapsed = ev.detail.value;
}
}
declare global {

View File

@@ -3455,12 +3455,17 @@
"require_admin": "Admin only",
"sidebar": "In sidebar",
"filename": "Filename",
"url": "Open"
"url": "Open",
"type": "Type"
},
"open": "Open",
"edit": "Edit",
"delete": "Delete",
"add_dashboard": "Add dashboard"
"add_dashboard": "Add dashboard",
"type": {
"user_created": "User created",
"built_in": "Built-in"
}
},
"confirm_delete_title": "Delete {dashboard_title}?",
"confirm_delete_text": "This dashboard will be permanently deleted.",