Compare commits

...

16 Commits

Author SHA1 Message Date
Aidan Timson
10e7db1641 Fix lint 2025-12-09 14:34:47 +00:00
Aidan Timson
3d16719da7 Add support for error state 2025-12-09 14:03:20 +00:00
Aidan Timson
cddede9e83 Restore optimised parts code 2025-12-09 14:01:52 +00:00
Aidan Timson
8a18026f78 memo 2025-12-09 14:01:52 +00:00
Aidan Timson
4142a0917d Reorder 2025-12-09 14:01:52 +00:00
Aidan Timson
5bef24f755 Migrate ha-icon-picker to generic picker 2025-12-09 14:01:52 +00:00
Wendelin
9cd38c7128 Multi term search sort by search score (#28353) 2025-12-09 14:48:57 +01:00
Wendelin
6322c19a45 Generic picker warn unknown selected item (#28372)
* Add unknown item text localization to various pickers

* Review
2025-12-09 14:24:30 +02:00
karwosts
74b51b77fe Fix markdown card image sizing (#28449) 2025-12-09 13:15:12 +01:00
Wendelin
b80481b53e Generic picker: scroll to selected value on open (#28457) 2025-12-09 12:27:56 +01:00
Aidan Timson
2ce1eaf8c6 Revert "Add basic view transitions between tab UIs (#28374)" (#28451) 2025-12-09 13:18:51 +02:00
Aidan Timson
4030ce3f88 Migrate dialog-upload-backup to ha-wa-dialog (#28444)
* Migrate dialog-upload-backup.ts from ha-md-dialog to ha-wa-dialog

* Remove custom css, space tokens

* Restore
2025-12-09 09:32:28 +02:00
Aidan Timson
41cabde393 Migrate dialog-download-decrypted-backup to ha-wa-dialog (#28442)
* Migrate dialog-download-decrypted-backup.ts from ha-md-dialog to ha-wa-dialog

* Fixes from other migrations
2025-12-09 09:28:44 +02:00
Aidan Timson
47d1fdf673 Migrate change/show backup encryption key to ha-wa-dialog (#28428)
* Migrate dialog-change-backup-encryption-key.ts from ha-md-dialog to ha-wa-dialog

* Remove open render nothing, remove custom css size

* Migrate show backup key

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-12-09 07:24:43 +00:00
Aidan Timson
e59e83fffe Migrate dialog-areas-floors-order to ha-wa-dialog (#28424)
* Migrate dialog-areas-floors-order.ts from ha-md-dialog to ha-wa-dialog

* Fix saving

* Remove render nothing if dialog false

* Remove custom width
2025-12-09 09:12:29 +02:00
Aidan Timson
b896b78876 Migrate labs dialogs to ha-wa-dialog (#28429)
* Migrate dialog-labs-preview-feature-enable.ts from ha-md-dialog to ha-wa-dialog

* Migrate dialog-labs-progress.ts from ha-md-dialog to ha-wa-dialog

* Restore

* Remove use of slots

* Fix

* Remove header
2025-12-09 08:55:28 +02:00
39 changed files with 1252 additions and 1083 deletions

View File

@@ -16,8 +16,10 @@ import memoizeOne from "memoize-one";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import { fireEvent } from "../../common/dom/fire_event";
import { stringCompare } from "../../common/string/compare";
import type { LocalizeFunc } from "../../common/translations/localize";
import { debounce } from "../../common/util/debounce";
import { groupBy } from "../../common/util/group-by";
import { nextRender } from "../../common/util/render-status";
import { haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types";
@@ -26,8 +28,6 @@ import type { HaCheckbox } from "../ha-checkbox";
import "../ha-svg-icon";
import "../search-input";
import { filterData, sortData } from "./sort-filter";
import type { LocalizeFunc } from "../../common/translations/localize";
import { nextRender } from "../../common/util/render-status";
export interface RowClickedEvent {
id: string;

View File

@@ -1,9 +1,9 @@
import { expose } from "comlink";
import Fuse from "fuse.js";
import Fuse, { type FuseOptionKey } from "fuse.js";
import memoizeOne from "memoize-one";
import { ipCompare, stringCompare } from "../../common/string/compare";
import { stripDiacritics } from "../../common/string/strip-diacritics";
import { HaFuse } from "../../resources/fuse";
import { multiTermSearch } from "../../resources/fuseMultiTerm";
import type {
ClonedDataTableColumnData,
DataTableRowData,
@@ -11,9 +11,10 @@ import type {
SortingDirection,
} from "./ha-data-table";
const fuseIndex = memoizeOne(
(data: DataTableRowData[], columns: SortableColumnContainer) => {
const getSearchKeys = memoizeOne(
(columns: SortableColumnContainer): FuseOptionKey<DataTableRowData>[] => {
const searchKeys = new Set<string>();
Object.entries(columns).forEach(([key, column]) => {
if (column.filterable) {
searchKeys.add(
@@ -23,10 +24,15 @@ const fuseIndex = memoizeOne(
);
}
});
return Fuse.createIndex([...searchKeys], data);
return Array.from(searchKeys);
}
);
const fuseIndex = memoizeOne(
(data: DataTableRowData[], keys: FuseOptionKey<DataTableRowData>[]) =>
Fuse.createIndex(keys, data)
);
const filterData = (
data: DataTableRowData[],
columns: SortableColumnContainer,
@@ -38,21 +44,13 @@ const filterData = (
return data;
}
const index = fuseIndex(data, columns);
const keys = getSearchKeys(columns);
const fuse = new HaFuse(
data,
{ shouldSort: false, minMatchCharLength: 1 },
index
);
const index = fuseIndex(data, keys);
const searchResults = fuse.multiTermsSearch(filter);
if (searchResults) {
return searchResults.map((result) => result.item);
}
return data;
return multiTermSearch<DataTableRowData>(data, filter, keys, index, {
threshold: 0.2, // reduce fuzzy matches in data tables
});
};
const sortData = (

View File

@@ -9,6 +9,7 @@ import { computeDeviceName } from "../../common/entity/compute_device_name";
import { getDeviceContext } from "../../common/entity/context/get_device_context";
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
import {
deviceComboBoxKeys,
getDevices,
type DevicePickerItem,
type DeviceRegistryEntry,
@@ -216,6 +217,10 @@ export class HaDevicePicker extends LitElement {
.getItems=${this._getItems}
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${valueRenderer}
.searchKeys=${deviceComboBoxKeys}
.unknownItemText=${this.hass.localize(
"ui.components.device-picker.unknown"
)}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>

View File

@@ -9,6 +9,7 @@ import { isValidEntityId } from "../../common/entity/valid_entity_id";
import { computeRTL } from "../../common/util/compute_rtl";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity";
import {
entityComboBoxKeys,
getEntities,
type EntityComboBoxItem,
} from "../../data/entity_registry";
@@ -288,10 +289,14 @@ export class HaEntityPicker extends LitElement {
.hideClearIcon=${this.hideClearIcon}
.searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer}
@value-changed=${this._valueChanged}
.searchKeys=${entityComboBoxKeys}
.addButtonLabel=${this.addButton
? this.hass.localize("ui.components.entity.entity-picker.add")
: undefined}
.unknownItemText=${this.hass.localize(
"ui.components.entity.entity-picker.unknown"
)}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
`;

View File

@@ -38,9 +38,21 @@ type StatisticItemType = "entity" | "external" | "no_state";
interface StatisticComboBoxItem extends PickerComboBoxItem {
statistic_id?: string;
stateObj?: HassEntity;
domainName?: string;
type?: StatisticItemType;
}
const SEARCH_KEYS = [
{ name: "label", weight: 10 },
{ name: "search_labels.entityName", weight: 10 },
{ name: "search_labels.friendlyName", weight: 9 },
{ name: "search_labels.deviceName", weight: 8 },
{ name: "search_labels.areaName", weight: 6 },
{ name: "search_labels.domainName", weight: 4 },
{ name: "statisticId", weight: 3 },
{ name: "id", weight: 2 },
];
@customElement("ha-statistic-picker")
export class HaStatisticPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -233,7 +245,6 @@ export class HaStatisticPicker extends LitElement {
),
type,
sorting_label: [sortingPrefix, label].join("_"),
search_labels: [label, id],
icon_path: mdiShape,
});
} else if (type === "external") {
@@ -246,7 +257,7 @@ export class HaStatisticPicker extends LitElement {
secondary: domainName,
type,
sorting_label: [sortingPrefix, label].join("_"),
search_labels: [label, domainName, id],
search_labels: { label, domainName },
icon_path: mdiChartLine,
});
}
@@ -280,13 +291,12 @@ export class HaStatisticPicker extends LitElement {
stateObj: stateObj,
type: "entity",
sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
search_labels: [
entityName,
deviceName,
areaName,
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
areaName: areaName || null,
friendlyName,
id,
].filter(Boolean) as string[],
},
});
});
@@ -361,13 +371,13 @@ export class HaStatisticPicker extends LitElement {
stateObj: stateObj,
type: "entity",
sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
search_labels: [
entityName,
deviceName,
areaName,
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
areaName: areaName || null,
friendlyName,
statisticId,
].filter(Boolean) as string[],
},
};
}
@@ -394,7 +404,7 @@ export class HaStatisticPicker extends LitElement {
secondary: domainName,
type: "external",
sorting_label: [sortingPrefix, label].join("_"),
search_labels: [label, domainName, statisticId],
search_labels: { label, domainName, statisticId },
icon_path: mdiChartLine,
};
}
@@ -409,7 +419,7 @@ export class HaStatisticPicker extends LitElement {
secondary: this.hass.localize("ui.components.statistic-picker.no_state"),
type: "no_state",
sorting_label: [sortingPrefix, label].join("_"),
search_labels: [label, statisticId],
search_labels: { label, statisticId },
icon_path: mdiShape,
};
}
@@ -475,6 +485,10 @@ export class HaStatisticPicker extends LitElement {
.searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer}
.helper=${this.helper}
.searchKeys=${SEARCH_KEYS}
.unknownItemText=${this.hass.localize(
"ui.components.statistic-picker.unknown"
)}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>

View File

@@ -1,270 +0,0 @@
import { mdiTextureBox } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { HassEntity } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeAreaName } from "../common/entity/compute_area_name";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { computeRTL } from "../common/util/compute_rtl";
import {
getAreasAndFloors,
type AreaFloorValue,
type FloorComboBoxItem,
} from "../data/area_floor";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-combo-box-item";
import "./ha-floor-icon";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-icon-button";
import type { PickerValueRenderer } from "./ha-picker-field";
import "./ha-svg-icon";
import "./ha-tree-indicator";
const SEPARATOR = "________";
@customElement("ha-area-floor-picker")
export class HaAreaFloorPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property({ attribute: false }) public value?: AreaFloorValue;
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: String, attribute: "search-label" })
public searchLabel?: string;
/**
* Show only areas with entities from specific domains.
* @type {Array}
* @attr include-domains
*/
@property({ type: Array, attribute: "include-domains" })
public includeDomains?: string[];
/**
* Show no areas with entities of these domains.
* @type {Array}
* @attr exclude-domains
*/
@property({ type: Array, attribute: "exclude-domains" })
public excludeDomains?: string[];
/**
* Show only areas with entities of these device classes.
* @type {Array}
* @attr include-device-classes
*/
@property({ type: Array, attribute: "include-device-classes" })
public includeDeviceClasses?: string[];
/**
* List of areas to be excluded.
* @type {Array}
* @attr exclude-areas
*/
@property({ type: Array, attribute: "exclude-areas" })
public excludeAreas?: string[];
/**
* List of floors to be excluded.
* @type {Array}
* @attr exclude-floors
*/
@property({ type: Array, attribute: "exclude-floors" })
public excludeFloors?: string[];
@property({ attribute: false })
public deviceFilter?: HaDevicePickerDeviceFilterFunc;
@property({ attribute: false })
public entityFilter?: (entity: HassEntity) => boolean;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
public async open() {
await this.updateComplete;
await this._picker?.open();
}
private _valueRenderer: PickerValueRenderer = (value: string) => {
const item = this._parseValue(value);
const area = item.type === "area" && this.hass.areas[value];
if (area) {
const areaName = computeAreaName(area);
return html`
${area.icon
? html`<ha-icon slot="start" .icon=${area.icon}></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${mdiTextureBox}
></ha-svg-icon>`}
<slot name="headline">${areaName}</slot>
`;
}
const floor = item.type === "floor" && this.hass.floors[value];
if (floor) {
const floorName = computeFloorName(floor);
return html`
<ha-floor-icon slot="start" .floor=${floor}></ha-floor-icon>
<span slot="headline">${floorName}</span>
`;
}
return html`
<ha-svg-icon slot="start" .path=${mdiTextureBox}></ha-svg-icon>
<span slot="headline">${value}</span>
`;
};
private _rowRenderer: ComboBoxLitRenderer<FloorComboBoxItem> = (
item,
{ index },
combobox
) => {
const nextItem = combobox.filteredItems?.[index + 1];
const isLastArea =
!nextItem ||
nextItem.type === "floor" ||
(nextItem.type === "area" && !nextItem.area?.floor_id);
const rtl = computeRTL(this.hass);
const hasFloor = item.type === "area" && item.area?.floor_id;
return html`
<ha-combo-box-item
type="button"
style=${item.type === "area" && hasFloor
? "--md-list-item-leading-space: 48px;"
: ""}
>
${item.type === "area" && hasFloor
? html`
<ha-tree-indicator
style=${styleMap({
width: "48px",
position: "absolute",
top: "0px",
left: rtl ? undefined : "4px",
right: rtl ? "4px" : undefined,
transform: rtl ? "scaleX(-1)" : "",
})}
.end=${isLastArea}
slot="start"
></ha-tree-indicator>
`
: nothing}
${item.type === "floor" && item.floor
? html`<ha-floor-icon
slot="start"
.floor=${item.floor}
></ha-floor-icon>`
: item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${item.icon_path || mdiTextureBox}
></ha-svg-icon>`}
${item.primary}
</ha-combo-box-item>
`;
};
private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors);
private _getItems = () =>
this._getAreasAndFloorsMemoized(
this.hass.states,
this.hass.floors,
this.hass.areas,
this.hass.devices,
this.hass.entities,
this._formatValue,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.excludeAreas,
this.excludeFloors
);
private _formatValue = memoizeOne((value: AreaFloorValue): string =>
[value.type, value.id].join(SEPARATOR)
);
private _parseValue = memoizeOne((value: string): AreaFloorValue => {
const [type, id] = value.split(SEPARATOR);
return { id, type: type as "floor" | "area" };
});
protected render(): TemplateResult {
const placeholder =
this.placeholder ?? this.hass.localize("ui.components.area-picker.area");
const value = this.value ? this._formatValue(this.value) : undefined;
return html`
<ha-generic-picker
.hass=${this.hass}
.autofocus=${this.autofocus}
.label=${this.label}
.searchLabel=${this.searchLabel}
.notFoundLabel=${this.hass.localize(
"ui.components.area-picker.no_match"
)}
.placeholder=${placeholder}
.value=${value}
.getItems=${this._getItems}
.valueRenderer=${this._valueRenderer}
.rowRenderer=${this._rowRenderer}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
`;
}
private _valueChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const value = ev.detail.value;
if (!value) {
this._setValue(undefined);
return;
}
const selected = this._parseValue(value);
this._setValue(selected);
}
private _setValue(value?: AreaFloorValue) {
this.value = value;
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-area-floor-picker": HaAreaFloorPicker;
}
}

View File

@@ -30,6 +30,12 @@ import "./ha-svg-icon";
const ADD_NEW_ID = "___ADD_NEW___";
const SEARCH_KEYS = [
{ name: "areaName", weight: 10 },
{ name: "aliases", weight: 8 },
{ name: "floorName", weight: 6 },
{ name: "id", weight: 3 },
];
@customElement("ha-area-picker")
export class HaAreaPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -291,12 +297,12 @@ export class HaAreaPicker extends LitElement {
icon: area.icon || undefined,
icon_path: area.icon ? undefined : mdiTextureBox,
sorting_label: areaName,
search_labels: [
areaName,
floorName,
area.area_id,
...area.aliases,
].filter((v): v is string => Boolean(v)),
search_labels: {
areaName: areaName || null,
floorName: floorName || null,
id: area.area_id,
aliases: area.aliases.join(" "),
},
};
});
@@ -379,6 +385,10 @@ export class HaAreaPicker extends LitElement {
.getAdditionalItems=${this._getAdditionalItems}
.valueRenderer=${valueRenderer}
.addButtonLabel=${this.addButtonLabel}
.searchKeys=${SEARCH_KEYS}
.unknownItemText=${this.hass.localize(
"ui.components.area-picker.unknown"
)}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>

View File

@@ -134,6 +134,7 @@ export class HaAssistChat extends LitElement {
})}"
breaks
cache
assist
.content=${message.text}
>
</ha-markdown>

View File

@@ -35,6 +35,12 @@ import "./ha-svg-icon";
const ADD_NEW_ID = "___ADD_NEW___";
const SEARCH_KEYS = [
{ name: "floorName", weight: 10 },
{ name: "aliases", weight: 8 },
{ name: "floor_id", weight: 3 },
];
interface FloorComboBoxItem extends PickerComboBoxItem {
floor?: FloorRegistryEntry;
}
@@ -286,9 +292,11 @@ export class HaFloorPicker extends LitElement {
primary: floorName,
floor: floor,
sorting_label: floor.level?.toString() || "zzzzz",
search_labels: [floorName, floor.floor_id, ...floor.aliases].filter(
(v): v is string => Boolean(v)
),
search_labels: {
floorName,
floor_id: floor.floor_id,
aliases: floor.aliases.join(" "),
},
};
});
@@ -393,6 +401,10 @@ export class HaFloorPicker extends LitElement {
.getAdditionalItems=${this._getAdditionalItems}
.valueRenderer=${valueRenderer}
.rowRenderer=${this._rowRenderer}
.searchKeys=${SEARCH_KEYS}
.unknownItemText=${this.hass.localize(
"ui.components.floor-picker.unknown"
)}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>

View File

@@ -4,8 +4,10 @@ import { mdiPlaylistPlus } from "@mdi/js";
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type { HomeAssistant } from "../types";
import "./ha-bottom-sheet";
import "./ha-button";
@@ -46,8 +48,9 @@ export class HaGenericPicker extends LitElement {
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
/** To prevent lags, getItems needs to be memoized */
@property({ attribute: false })
public getItems?: (
public getItems!: (
searchString?: string,
section?: string
) => (PickerComboBoxItem | string)[];
@@ -64,6 +67,9 @@ export class HaGenericPicker extends LitElement {
@property({ attribute: false })
public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
@property({ attribute: false })
public searchKeys?: FuseWeightedKey[];
@property({ attribute: false })
public notFoundLabel?: string | ((search: string) => string);
@@ -107,6 +113,8 @@ export class HaGenericPicker extends LitElement {
@property({ attribute: "selected-section" }) public selectedSection?: string;
@property({ attribute: "unknown-item-text" }) public unknownItemText?: string;
@query(".container") private _containerElement?: HTMLDivElement;
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@@ -129,6 +137,10 @@ export class HaGenericPicker extends LitElement {
// helper to set new value after closing picker, to avoid flicker
private _newValue?: string;
@property({ attribute: "error-message" }) public errorMessage?: string;
@property({ type: Boolean, reflect: true }) public invalid = false;
private _unsubscribeTinyKeys?: () => void;
protected render() {
@@ -156,6 +168,8 @@ export class HaGenericPicker extends LitElement {
type="button"
class=${this._opened ? "opened" : ""}
compact
.unknown=${this._unknownValue(this.value, this.getItems())}
.unknownItemText=${this.unknownItemText}
aria-label=${ifDefined(this.label)}
@click=${this.open}
@clear=${this._clear}
@@ -163,6 +177,8 @@ export class HaGenericPicker extends LitElement {
.value=${this.value}
.required=${this.required}
.disabled=${this.disabled}
.errorMessage=${this.errorMessage}
.invalid=${this.invalid}
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${this.valueRenderer}
>
@@ -229,16 +245,34 @@ export class HaGenericPicker extends LitElement {
.sections=${this.sections}
.sectionTitleFunction=${this.sectionTitleFunction}
.selectedSection=${this.selectedSection}
.searchKeys=${this.searchKeys}
></ha-picker-combo-box>
`;
}
private _unknownValue = memoizeOne(
(value?: string, items?: (PickerComboBoxItem | string)[]) => {
if (value === undefined || !items) {
return false;
}
return !items.some(
(item) => typeof item !== "string" && item.id === value
);
}
);
private _renderHelper() {
return this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
: nothing;
const showError = this.invalid && this.errorMessage;
const showHelper = !showError && this.helper;
if (!showError && !showHelper) {
return nothing;
}
return html`<ha-input-helper-text .disabled=${this.disabled}>
${showError ? this.errorMessage : this.helper}
</ha-input-helper-text>`;
}
private _dialogOpened = () => {
@@ -337,6 +371,9 @@ export class HaGenericPicker extends LitElement {
display: block;
margin: var(--ha-space-2) 0 0;
}
:host([invalid]) ha-input-helper-text {
color: var(--mdc-theme-error, var(--error-color, #b00020));
}
wa-popover {
--wa-space-l: var(--ha-space-0);

View File

@@ -1,8 +1,4 @@
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type {
ComboBoxDataProviderCallback,
ComboBoxDataProviderParams,
} from "@vaadin/combo-box/vaadin-combo-box-light";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
@@ -10,9 +6,10 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { customIcons } from "../data/custom_icons";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./ha-combo-box";
import "./ha-icon";
import "./ha-combo-box-item";
import "./ha-generic-picker";
import "./ha-icon";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
interface IconItem {
icon: string;
@@ -21,7 +18,7 @@ interface IconItem {
}
interface RankedIcon {
icon: string;
item: PickerComboBoxItem;
rank: number;
}
@@ -67,13 +64,18 @@ const loadCustomIconItems = async (iconsetPrefix: string) => {
}
};
const rowRenderer: ComboBoxLitRenderer<IconItem | RankedIcon> = (item) => html`
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
<ha-combo-box-item type="button">
<ha-icon .icon=${item.icon} slot="start"></ha-icon>
${item.icon}
<ha-icon .icon=${item.id} slot="start"></ha-icon>
${item.id}
</ha-combo-box-item>
`;
const valueRenderer = (value: string) => html`
<ha-icon .icon=${value} slot="start"></ha-icon>
<span slot="headline">${value}</span>
`;
@customElement("ha-icon-picker")
export class HaIconPicker extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@@ -96,13 +98,11 @@ export class HaIconPicker extends LitElement {
protected render(): TemplateResult {
return html`
<ha-combo-box
<ha-generic-picker
.hass=${this.hass}
item-value-path="icon"
item-label-path="icon"
.value=${this._value}
allow-custom-value
.dataProvider=${ICONS_LOADED ? this._iconProvider : undefined}
.getItems=${this._getItems}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
@@ -110,69 +110,97 @@ export class HaIconPicker extends LitElement {
.placeholder=${this.placeholder}
.errorMessage=${this.errorMessage}
.invalid=${this.invalid}
.renderer=${rowRenderer}
icon
@opened-changed=${this._openedChanged}
.rowRenderer=${rowRenderer}
.valueRenderer=${valueRenderer}
.searchFn=${this._filterIcons}
.notFoundLabel=${this.hass?.localize(
"ui.components.icon-picker.no_match"
)}
popover-placement="bottom-start"
@value-changed=${this._valueChanged}
>
${this._value || this.placeholder
? html`
<ha-icon .icon=${this._value || this.placeholder} slot="icon">
</ha-icon>
`
: html`<slot slot="icon" name="fallback"></slot>`}
</ha-combo-box>
</ha-generic-picker>
`;
}
// Filter can take a significant chunk of frame (up to 3-5 ms)
private _filterIcons = memoizeOne(
(filter: string, iconItems: IconItem[] = ICONS) => {
(filter: string, items: PickerComboBoxItem[]): PickerComboBoxItem[] => {
if (!filter) {
return iconItems;
return items;
}
const filteredItems: RankedIcon[] = [];
const addIcon = (icon: string, rank: number) =>
filteredItems.push({ icon, rank });
const addIcon = (item: PickerComboBoxItem, rank: number) =>
filteredItems.push({ item, rank });
// Filter and rank such that exact matches rank higher, and prefer icon name matches over keywords
for (const item of iconItems) {
if (item.parts.has(filter)) {
addIcon(item.icon, 1);
} else if (item.keywords.includes(filter)) {
addIcon(item.icon, 2);
} else if (item.icon.includes(filter)) {
addIcon(item.icon, 3);
} else if (item.keywords.some((word) => word.includes(filter))) {
addIcon(item.icon, 4);
for (const item of items) {
const iconName = item.id.split(":")[1] || item.id;
const parts = iconName.split("-");
const keywords = item.search_labels
? Object.values(item.search_labels).filter(
(v): v is string => v !== null
)
: [];
if (parts.includes(filter)) {
addIcon(item, 1);
} else if (keywords.includes(filter)) {
addIcon(item, 2);
} else if (item.id.includes(filter)) {
addIcon(item, 3);
} else if (keywords.some((word) => word.includes(filter))) {
addIcon(item, 4);
}
}
// Allow preview for custom icon not in list
if (filteredItems.length === 0) {
addIcon(filter, 0);
addIcon(
{
id: filter,
primary: filter,
icon: filter,
search_labels: { keyword: filter },
sorting_label: filter,
},
0
);
}
return filteredItems.sort((itemA, itemB) => itemA.rank - itemB.rank);
return filteredItems
.sort((itemA, itemB) => itemA.rank - itemB.rank)
.map((item) => item.item);
}
);
private _iconProvider = (
params: ComboBoxDataProviderParams,
callback: ComboBoxDataProviderCallback<IconItem | RankedIcon>
) => {
const filteredItems = this._filterIcons(params.filter.toLowerCase(), ICONS);
const iStart = params.page * params.pageSize;
const iEnd = iStart + params.pageSize;
callback(filteredItems.slice(iStart, iEnd), filteredItems.length);
};
private _getItems = (): PickerComboBoxItem[] =>
ICONS.map((icon: IconItem) => {
const iconName = icon.icon.split(":")[1] || icon.icon;
const searchLabels: Record<string, string> = {
iconName,
};
Array.from(icon.parts).forEach((part, index) => {
searchLabels[`part${index}`] = part;
});
icon.keywords.forEach((keyword, index) => {
searchLabels[`keyword${index}`] = keyword;
});
return {
id: icon.icon,
primary: icon.icon,
icon: icon.icon,
search_labels: searchLabels,
sorting_label: icon.icon,
};
});
private async _openedChanged(ev: ValueChangedEvent<boolean>) {
const opened = ev.detail.value;
if (opened && !ICONS_LOADED) {
await loadIcons();
this.requestUpdate();
protected firstUpdated() {
if (!ICONS_LOADED) {
loadIcons().then(() => {
this.requestUpdate();
});
}
}
@@ -199,15 +227,9 @@ export class HaIconPicker extends LitElement {
}
static styles = css`
*[slot="icon"] {
color: var(--primary-text-color);
position: relative;
bottom: 2px;
}
*[slot="prefix"] {
margin-right: 8px;
margin-inline-end: 8px;
margin-inline-start: initial;
ha-generic-picker {
width: 100%;
display: block;
}
`;
}

View File

@@ -15,6 +15,7 @@ import type { LabelRegistryEntry } from "../data/label_registry";
import {
createLabelRegistryEntry,
getLabels,
labelComboBoxKeys,
subscribeLabelRegistry,
} from "../data/label_registry";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
@@ -237,6 +238,7 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.valueRenderer=${valueRenderer}
.searchKeys=${labelComboBoxKeys}
@value-changed=${this._valueChanged}
>
<slot .slot=${this._slotNodes?.length ? "field" : undefined}></slot>

View File

@@ -40,14 +40,12 @@ export const getLanguageOptions = (
return {
id: lang,
primary,
search_labels: [primary],
};
});
} else if (locale) {
options = languages.map((lang) => ({
id: lang,
primary: formatLanguageCode(lang, locale),
search_labels: [formatLanguageCode(lang, locale)],
}));
}

View File

@@ -70,13 +70,15 @@ export class HaMarkdown extends LitElement {
a {
color: var(--markdown-link-color, var(--primary-color));
}
:host([assist]) img {
height: auto;
width: auto;
transition: height 0.2s ease-in-out;
}
img {
background-color: var(--markdown-image-background-color);
border-radius: var(--markdown-image-border-radius);
max-width: 100%;
height: auto;
width: auto;
transition: height 0.2s ease-in-out;
}
p:first-child > img:first-child {
vertical-align: top;

View File

@@ -15,7 +15,10 @@ import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
import { HaFuse } from "../resources/fuse";
import {
multiTermSortedSearch,
type FuseWeightedKey,
} from "../resources/fuseMultiTerm";
import { haStyleScrollbar } from "../resources/styles";
import { loadVirtualizer } from "../resources/virtualizer";
import type { HomeAssistant } from "../types";
@@ -26,11 +29,26 @@ import "./ha-icon";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
export const DEFAULT_SEARCH_KEYS: FuseWeightedKey[] = [
{
name: "primary",
weight: 10,
},
{
name: "secondary",
weight: 7,
},
{
name: "id",
weight: 3,
},
];
export interface PickerComboBoxItem {
id: string;
primary: string;
secondary?: string;
search_labels?: string[];
search_labels?: Record<string, string | null>;
sorting_label?: string;
icon_path?: string;
icon?: string;
@@ -77,10 +95,13 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@property() public value?: string;
@property({ attribute: false })
public searchKeys?: FuseWeightedKey[];
@state() private _listScrolled = false;
@property({ attribute: false })
public getItems?: (
public getItems!: (
searchString?: string,
section?: string
) => (PickerComboBoxItem | string)[];
@@ -133,6 +154,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@state() private _sectionTitle?: string;
@state() private _valuePinned = true;
private _allItems: (PickerComboBoxItem | string)[] = [];
private _selectedItemIndex = -1;
@@ -194,6 +217,15 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
.renderItem=${this._renderItem}
style="min-height: 36px;"
class=${this._listScrolled ? "scrolled" : ""}
.layout=${this.value && this._valuePinned
? {
pin: {
index: this._getInitialSelectedIndex(),
block: "center",
},
}
: undefined}
@unpinned=${this._handleUnpinned}
@scroll=${this._onScrollList}
@focus=${this._focusList}
@visibilityChanged=${this._visibilityChanged}
@@ -244,15 +276,16 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
}
}
@eventOptions({ passive: true })
private _handleUnpinned() {
this._valuePinned = false;
}
private _getAdditionalItems = (searchString?: string) =>
this.getAdditionalItems?.(searchString) || [];
private _getItems = () => {
let items = [
...(this.getItems
? this.getItems(this._search, this.selectedSection)
: []),
];
let items = [...this.getItems(this._search, this.selectedSection)];
if (!this.sections?.length) {
items = items.sort((entityA, entityB) =>
@@ -279,6 +312,9 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
};
private _renderItem = (item: PickerComboBoxItem | string, index: number) => {
if (!item) {
return nothing;
}
if (item === "padding") {
return html`<div class="bottom-padding"></div>`;
}
@@ -339,8 +375,9 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
fireEvent(this, "value-changed", { value: newValue });
};
private _fuseIndex = memoizeOne((states: PickerComboBoxItem[]) =>
Fuse.createIndex(["search_labels"], states)
private _fuseIndex = memoizeOne(
(states: PickerComboBoxItem[], searchKeys?: FuseWeightedKey[]) =>
Fuse.createIndex(searchKeys || DEFAULT_SEARCH_KEYS, states)
);
private _filterChanged = (ev: Event) => {
@@ -356,34 +393,26 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
return;
}
const index = this._fuseIndex(this._allItems as PickerComboBoxItem[]);
const fuse = new HaFuse(
const index = this._fuseIndex(
this._allItems as PickerComboBoxItem[],
{
shouldSort: false,
minMatchCharLength: Math.min(searchString.length, 2),
},
index
this.searchKeys
);
const results = fuse.multiTermsSearch(searchString);
let filteredItems = [...this._allItems];
let filteredItems = multiTermSortedSearch<PickerComboBoxItem>(
this._allItems as PickerComboBoxItem[],
searchString,
this.searchKeys || DEFAULT_SEARCH_KEYS,
(item) => item.id,
index
) as (PickerComboBoxItem | string)[];
if (results) {
const items: (PickerComboBoxItem | string)[] = results.map(
(result) => result.item
);
if (!items.length) {
filteredItems.push(NO_ITEMS_AVAILABLE_ID);
}
const additionalItems = this._getAdditionalItems();
items.push(...additionalItems);
filteredItems = items;
if (!filteredItems.length) {
filteredItems.push(NO_ITEMS_AVAILABLE_ID);
}
const additionalItems = this._getAdditionalItems();
filteredItems.push(...additionalItems);
if (this.searchFn) {
filteredItems = this.searchFn(
searchString,
@@ -590,7 +619,25 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
}
private _keyFunction = (item: PickerComboBoxItem | string) =>
typeof item === "string" ? item : item.id;
typeof item === "string" ? item : item?.id;
private _getInitialSelectedIndex() {
if (!this._virtualizerElement || !this.value) {
return 0;
}
const index = this._virtualizerElement.items.findIndex(
(item) =>
typeof item !== "string" &&
(item as PickerComboBoxItem).id === this.value
);
if (index === -1) {
return 0;
}
return index;
}
static get styles() {
return [

View File

@@ -1,3 +1,4 @@
import { consume } from "@lit/context";
import { mdiClose, mdiMenuDown } from "@mdi/js";
import {
css,
@@ -7,8 +8,10 @@ import {
type CSSResultGroup,
type TemplateResult,
} from "lit";
import { customElement, property, query } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { localizeContext } from "../data/context";
import type { HomeAssistant } from "../types";
import "./ha-combo-box-item";
import type { HaComboBoxItem } from "./ha-combo-box-item";
import "./ha-icon-button";
@@ -33,14 +36,26 @@ export class HaPickerField extends LitElement {
@property() public placeholder?: string;
@property({ type: Boolean, reflect: true }) public unknown = false;
@property({ attribute: "unknown-item-text" }) public unknownItemText?: string;
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@property({ attribute: false })
public valueRenderer?: PickerValueRenderer;
@property({ attribute: "error-message" }) public errorMessage?: string;
@property({ type: Boolean, reflect: true }) public invalid = false;
@query("ha-combo-box-item", true) public item!: HaComboBoxItem;
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: HomeAssistant["localize"];
public async focus() {
await this.updateComplete;
await this.item?.focus();
@@ -61,6 +76,12 @@ export class HaPickerField extends LitElement {
${this.placeholder}
</span>
`}
${this.unknown
? html`<div slot="supporting-text" class="unknown">
${this.unknownItemText ||
this.localize("ui.components.combo-box.unknown_item")}
</div>`
: nothing}
${showClearIcon
? html`
<ha-icon-button
@@ -142,6 +163,15 @@ export class HaPickerField extends LitElement {
background-color: var(--mdc-theme-primary);
}
:host([unknown]) ha-combo-box-item {
background-color: var(--ha-color-fill-warning-quiet-resting);
}
:host([invalid]) ha-combo-box-item:after {
height: 2px;
background-color: var(--mdc-theme-error, var(--error-color, #b00020));
}
.clear {
margin: 0 -8px;
--mdc-icon-button-size: 32px;
@@ -156,6 +186,10 @@ export class HaPickerField extends LitElement {
color: var(--secondary-text-color);
padding: 0 8px;
}
.unknown {
color: var(--ha-color-on-warning-normal);
}
`,
];
}

View File

@@ -21,6 +21,13 @@ interface ServiceComboBoxItem extends PickerComboBoxItem {
service_id?: string;
}
const SEARCH_KEYS = [
{ name: "name", weight: 10 },
{ name: "description", weight: 8 },
{ name: "domainName", weight: 6 },
{ name: "serviceId", weight: 3 },
];
@customElement("ha-service-picker")
class HaServicePicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -141,6 +148,10 @@ class HaServicePicker extends LitElement {
this.hass.localize,
this.hass.services
)}
.searchKeys=${SEARCH_KEYS}
.unknownItemText=${this.hass.localize(
"ui.components.service-picker.unknown"
)}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
@@ -194,9 +205,7 @@ class HaServicePicker extends LitElement {
secondary: description,
domain_name: domainName,
service_id: serviceId,
search_labels: [serviceId, domainName, name, description].filter(
Boolean
),
search_labels: { serviceId, domainName, name, description },
sorting_label: serviceId,
});
}

View File

@@ -15,17 +15,30 @@ import { fireEvent } from "../common/dom/fire_event";
import { isValidEntityId } from "../common/entity/valid_entity_id";
import { computeRTL } from "../common/util/compute_rtl";
import {
areaFloorComboBoxKeys,
getAreasAndFloors,
type AreaFloorValue,
type FloorComboBoxItem,
} from "../data/area_floor";
import { getConfigEntries, type ConfigEntry } from "../data/config_entries";
import { labelsContext } from "../data/context";
import { getDevices, type DevicePickerItem } from "../data/device_registry";
import {
deviceComboBoxKeys,
getDevices,
type DevicePickerItem,
} from "../data/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../data/entity";
import { getEntities, type EntityComboBoxItem } from "../data/entity_registry";
import {
entityComboBoxKeys,
getEntities,
type EntityComboBoxItem,
} from "../data/entity_registry";
import { domainToName } from "../data/integration";
import { getLabels, type LabelRegistryEntry } from "../data/label_registry";
import {
getLabels,
labelComboBoxKeys,
type LabelRegistryEntry,
} from "../data/label_registry";
import {
areaMeetsFilter,
deviceMeetsFilter,
@@ -37,7 +50,11 @@ import {
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { isHelperDomain } from "../panels/config/helpers/const";
import { showHelperDetailDialog } from "../panels/config/helpers/show-dialog-helper-detail";
import { HaFuse } from "../resources/fuse";
import {
multiTermSearch,
multiTermSortedSearch,
type FuseWeightedKey,
} from "../resources/fuseMultiTerm";
import type { HomeAssistant } from "../types";
import { brandsUrl } from "../util/brands-url";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
@@ -113,16 +130,16 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
private _fuseIndexes = {
area: memoizeOne((states: FloorComboBoxItem[]) =>
this._createFuseIndex(states)
this._createFuseIndex(states, areaFloorComboBoxKeys)
),
entity: memoizeOne((states: EntityComboBoxItem[]) =>
this._createFuseIndex(states)
this._createFuseIndex(states, entityComboBoxKeys)
),
device: memoizeOne((states: DevicePickerItem[]) =>
this._createFuseIndex(states)
this._createFuseIndex(states, deviceComboBoxKeys)
),
label: memoizeOne((states: PickerComboBoxItem[]) =>
this._createFuseIndex(states)
this._createFuseIndex(states, labelComboBoxKeys)
),
};
@@ -134,8 +151,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
}
private _createFuseIndex = (states) =>
Fuse.createIndex(["search_labels"], states);
private _createFuseIndex = (states, keys: FuseWeightedKey[]) =>
Fuse.createIndex(keys, states);
protected render() {
if (this.addOnTop) {
@@ -735,8 +752,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
"entity",
entityItems,
searchTerm,
(item: EntityComboBoxItem) =>
item.stateObj?.entity_id === searchTerm
entityComboBoxKeys
) as EntityComboBoxItem[];
}
@@ -765,7 +781,12 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
);
if (searchTerm) {
deviceItems = this._filterGroup("device", deviceItems, searchTerm);
deviceItems = this._filterGroup(
"device",
deviceItems,
searchTerm,
deviceComboBoxKeys
);
}
if (!filterType && deviceItems.length) {
@@ -799,7 +820,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
areasAndFloors = this._filterGroup(
"area",
areasAndFloors,
searchTerm
searchTerm,
areaFloorComboBoxKeys,
false
) as FloorComboBoxItem[];
}
@@ -844,7 +867,12 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
);
if (searchTerm) {
labels = this._filterGroup("label", labels, searchTerm);
labels = this._filterGroup(
"label",
labels,
searchTerm,
labelComboBoxKeys
);
}
if (!filterType && labels.length) {
@@ -863,40 +891,24 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
type: TargetType,
items: (FloorComboBoxItem | PickerComboBoxItem | EntityComboBoxItem)[],
searchTerm: string,
checkExact?: (
item: FloorComboBoxItem | PickerComboBoxItem | EntityComboBoxItem
) => boolean
weightedKeys: FuseWeightedKey[],
sort = true
) {
const fuseIndex = this._fuseIndexes[type](items);
const fuse = new HaFuse(
items,
{
shouldSort: false,
minMatchCharLength: Math.min(searchTerm.length, 2),
},
fuseIndex
);
const results = fuse.multiTermsSearch(searchTerm);
let filteredItems = items;
if (results) {
filteredItems = results.map((result) => result.item);
if (sort) {
return multiTermSortedSearch(
items,
searchTerm,
weightedKeys,
(item) => item.id,
fuseIndex
);
}
if (!checkExact) {
return filteredItems;
}
// If there is exact match for entity id, put it first
const index = filteredItems.findIndex((item) => checkExact(item));
if (index === -1) {
return filteredItems;
}
const [exactMatch] = filteredItems.splice(index, 1);
filteredItems.unshift(exactMatch);
return filteredItems;
return multiTermSearch(items, searchTerm, weightedKeys, fuseIndex, {
ignoreLocation: true,
});
}
private _getAdditionalItems = () => this._getCreateItems(this.createDomains);

View File

@@ -17,6 +17,12 @@ interface UserComboBoxItem extends PickerComboBoxItem {
user?: User;
}
const SEARCH_KEYS = [
{ name: "primary", weight: 10 },
{ name: "search_labels.username", weight: 6 },
{ name: "id", weight: 3 },
];
@customElement("ha-user-picker")
class HaUserPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -109,9 +115,7 @@ class HaUserPicker extends LitElement {
id: user.id,
primary: user.name,
domain_name: user.name,
search_labels: [user.name, user.id, user.username].filter(
Boolean
) as string[],
search_labels: { username: user.username },
sorting_label: user.name,
user,
}));
@@ -134,6 +138,10 @@ class HaUserPicker extends LitElement {
.getItems=${this._getItems}
.valueRenderer=${this._valueRenderer}
.rowRenderer=${this._rowRenderer}
.searchKeys=${SEARCH_KEYS}
.unknownItemText=${this.hass.localize(
"ui.components.user-picker.unknown"
)}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>

View File

@@ -4,6 +4,7 @@ import { computeDomain } from "../common/entity/compute_domain";
import { computeFloorName } from "../common/entity/compute_floor_name";
import type { HaDevicePickerDeviceFilterFunc } from "../components/device/ha-device-picker";
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type { HomeAssistant } from "../types";
import type { AreaRegistryEntry } from "./area_registry";
import {
@@ -26,7 +27,8 @@ export interface FloorNestedComboBoxItem extends PickerComboBoxItem {
areas: FloorComboBoxItem[];
}
export interface UnassignedAreasFloorComboBoxItem extends PickerComboBoxItem {
export interface UnassignedAreasFloorComboBoxItem {
id: undefined;
areas: FloorComboBoxItem[];
}
@@ -98,6 +100,29 @@ export const getAreasAndFloors = (
excludeFloors
) as FloorComboBoxItem[];
export const areaFloorComboBoxKeys: FuseWeightedKey[] = [
{
name: "search_labels.name",
weight: 10,
},
{
name: "search_labels.aliases",
weight: 8,
},
{
name: "search_labels.floorName",
weight: 6,
},
{
name: "search_labels.relatedAreas",
weight: 4,
},
{
name: "search_labels.id",
weight: 3,
},
];
const getAreasAndFloorsItems = (
states: HomeAssistant["states"],
haFloors: HomeAssistant["floors"],
@@ -304,12 +329,12 @@ const getAreasAndFloorsItems = (
primary: floorName,
floor: floor,
icon: floor.icon || undefined,
search_labels: [
floor.floor_id,
floorName,
...floor.aliases,
...areaSearchLabels,
],
search_labels: {
id: floor.floor_id,
name: floorName || null,
aliases: floor.aliases.join(", ") || null,
relatedAreas: areaSearchLabels.join(" ") || null,
},
};
items.push(floorItem);
@@ -322,11 +347,12 @@ const getAreasAndFloorsItems = (
primary: areaName || area.area_id,
area: area,
icon: area.icon || undefined,
search_labels: [
area.area_id,
...(areaName ? [areaName] : []),
...area.aliases,
],
search_labels: {
id: area.area_id,
name: areaName || null,
aliases: area.aliases.join(", ") || null,
floorName: floorName || null,
},
};
});
@@ -339,19 +365,24 @@ const getAreasAndFloorsItems = (
const unassignedAreaItems = hierarchy.areas.map((areaId) => {
const area = haAreas[areaId];
const areaName = computeAreaName(area) || area.area_id;
const areaName = computeAreaName(area);
return {
id: formatId({ id: area.area_id, type: "area" }),
type: "area" as const,
primary: areaName,
primary: areaName || area.area_id,
area: area,
icon: area.icon || undefined,
search_labels: [area.area_id, areaName, ...area.aliases],
search_labels: {
id: area.area_id,
name: areaName || null,
aliases: area.aliases.join(", ") || null,
},
};
});
if (nested && unassignedAreaItems.length) {
items.push({
id: undefined,
areas: unassignedAreaItems,
} as UnassignedAreasFloorComboBoxItem);
} else {

View File

@@ -6,6 +6,7 @@ import { getDeviceContext } from "../common/entity/context/get_device_context";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { HaDevicePickerDeviceFilterFunc } from "../components/device/ha-device-picker";
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type { HomeAssistant } from "../types";
import type { ConfigEntry } from "./config_entries";
import type { HaEntityPickerEntityFilterFunc } from "./entity";
@@ -181,6 +182,25 @@ export interface DevicePickerItem extends PickerComboBoxItem {
domain_name?: string;
}
export const deviceComboBoxKeys: FuseWeightedKey[] = [
{
name: "search_labels.deviceName",
weight: 10,
},
{
name: "search_labels.areaName",
weight: 8,
},
{
name: "search_labels.domainName",
weight: 4,
},
{
name: "search_labels.domain",
weight: 4,
},
];
export const getDevices = (
hass: HomeAssistant,
configEntryLookup: Record<string, ConfigEntry>,
@@ -311,9 +331,12 @@ export const getDevices = (
secondary: areaName,
domain: configEntry?.domain,
domain_name: domainName,
search_labels: [deviceName, areaName, domain, domainName].filter(
Boolean
) as string[],
search_labels: {
deviceName,
areaName: areaName || null,
domain: domain || null,
domainName: domainName || null,
},
sorting_label: deviceName || "zzz",
};
});

View File

@@ -9,6 +9,7 @@ import { caseInsensitiveStringCompare } from "../common/string/compare";
import { computeRTL } from "../common/util/compute_rtl";
import { debounce } from "../common/util/debounce";
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type { HomeAssistant } from "../types";
import type { HaEntityPickerEntityFilterFunc } from "./entity";
import { domainToName } from "./integration";
@@ -335,6 +336,33 @@ export interface EntityComboBoxItem extends PickerComboBoxItem {
stateObj?: HassEntity;
}
export const entityComboBoxKeys: FuseWeightedKey[] = [
{
name: "search_labels.entityName",
weight: 10,
},
{
name: "search_labels.friendlyName",
weight: 8,
},
{
name: "search_labels.deviceName",
weight: 7,
},
{
name: "search_labels.areaName",
weight: 6,
},
{
name: "search_labels.domainName",
weight: 6,
},
{
name: "search_labels.entityId",
weight: 3,
},
];
export const getEntities = (
hass: HomeAssistant,
includeDomains?: string[],
@@ -403,14 +431,14 @@ export const getEntities = (
secondary: secondary,
domain_name: domainName,
sorting_label: [deviceName, entityName].filter(Boolean).join("_"),
search_labels: [
entityName,
deviceName,
areaName,
domainName,
friendlyName,
entityId,
].filter(Boolean) as string[],
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
areaName: areaName || null,
domainName: domainName || null,
friendlyName: friendlyName || null,
entityId: entityId,
},
stateObj: stateObj,
};
});

View File

@@ -7,6 +7,7 @@ import { stringCompare } from "../common/string/compare";
import { debounce } from "../common/util/debounce";
import type { HaDevicePickerDeviceFilterFunc } from "../components/device/ha-device-picker";
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type { HomeAssistant } from "../types";
import {
getDeviceEntityDisplayLookup,
@@ -100,6 +101,21 @@ export const deleteLabelRegistryEntry = (
label_id: labelId,
});
export const labelComboBoxKeys: FuseWeightedKey[] = [
{
name: "search_labels.name",
weight: 10,
},
{
name: "search_labels.description",
weight: 5,
},
{
name: "search_labels.id",
weight: 4,
},
];
export const getLabels = (
hassStates: HomeAssistant["states"],
hassAreas: HomeAssistant["areas"],
@@ -273,9 +289,11 @@ export const getLabels = (
icon: label.icon || undefined,
icon_path: label.icon ? undefined : mdiLabel,
sorting_label: label.name,
search_labels: [label.name, label.label_id, label.description].filter(
(v): v is string => Boolean(v)
),
search_labels: {
name: label.name,
description: label.description,
id: label.label_id,
},
}));
return items;

View File

@@ -45,7 +45,7 @@ import { domainToName } from "../../data/integration";
import { getPanelNameTranslationKey } from "../../data/panel";
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
import { configSections } from "../../panels/config/ha-panel-config";
import { HaFuse } from "../../resources/fuse";
import { multiTermSortedSearch } from "../../resources/fuseMultiTerm";
import {
haStyleDialog,
haStyleDialogFixedTop,
@@ -58,7 +58,17 @@ import { showConfirmationDialog } from "../generic/show-dialog-box";
import { showShortcutsDialog } from "../shortcuts/show-shortcuts-dialog";
import { QuickBarMode, type QuickBarParams } from "./show-dialog-quick-bar";
const SEARCH_KEYS = [
{ name: "primaryText", weight: 10 },
{ name: "altText", weight: 8 },
{ name: "friendlyName", weight: 8 },
{ name: "area", weight: 6 },
{ name: "translatedDomain", weight: 5 },
{ name: "entityId", weight: 4 }, // for technical search
];
interface QuickBarItem extends ScorableTextItem {
id: string;
primaryText: string;
iconPath?: string;
action(data?: any): void;
@@ -593,6 +603,7 @@ export class QuickBar extends LitElement {
const areaName = area ? computeAreaName(area) : undefined;
const deviceItem = {
id: device.id,
primaryText: deviceName,
deviceId: device.id,
area: areaName,
@@ -666,6 +677,7 @@ export class QuickBar extends LitElement {
);
const entityItem = {
id: `entity-${entityId}`,
primaryText: primary,
altText: secondary,
icon: html`
@@ -767,8 +779,9 @@ export class QuickBar extends LitElement {
),
});
return commands.map((command) => ({
return commands.map((command, index) => ({
...command,
id: `command_${index}_${command.primaryText}`,
categoryKey: "reload",
strings: [`${command.categoryText} ${command.primaryText}`],
}));
@@ -777,10 +790,11 @@ export class QuickBar extends LitElement {
private _generateServerControlCommands(): CommandItem[] {
const serverActions = ["restart", "stop"] as const;
return serverActions.map((action) => {
return serverActions.map((action, index) => {
const categoryKey: CommandItem["categoryKey"] = "server_control";
const item = {
id: `server_control_${index}_${action}`,
primaryText: this.hass.localize(
"ui.dialogs.quick-bar.commands.server_control.perform_action",
{
@@ -940,10 +954,11 @@ export class QuickBar extends LitElement {
private _finalizeNavigationCommands(
items: BaseNavigationCommand[]
): CommandItem[] {
return items.map((item) => {
return items.map((item, index) => {
const categoryKey: CommandItem["categoryKey"] = "navigation";
const navItem = {
id: `navigation_${index}_${item.path}`,
iconPath: mdiEarth,
categoryText: this.hass.localize(
`ui.dialogs.quick-bar.commands.types.${categoryKey}`
@@ -961,28 +976,20 @@ export class QuickBar extends LitElement {
}
private _fuseIndex = memoizeOne((items: QuickBarItem[]) =>
Fuse.createIndex(
[
"primaryText",
"altText",
"friendlyName",
"translatedDomain",
"entityId", // for technical search
],
items
)
Fuse.createIndex(SEARCH_KEYS, items)
);
private _filterItems = memoizeOne(
(items: QuickBarItem[], filter: string): QuickBarItem[] => {
const index = this._fuseIndex(items);
const fuse = new HaFuse(items, {}, index);
const results = fuse.multiTermsSearch(filter.trim());
if (!results || !results.length) {
return items;
}
return results.map((result) => result.item);
return multiTermSortedSearch(
items,
filter,
SEARCH_KEYS,
(item) => item.id,
index
);
}
);

View File

@@ -13,7 +13,6 @@ import "../components/ha-svg-icon";
import "../components/ha-tab";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant, Route } from "../types";
import { withViewTransition } from "../common/util/view-transition";
export interface PageNavigation {
path: string;
@@ -113,11 +112,9 @@ class HassTabsSubpage extends LitElement {
public willUpdate(changedProperties: PropertyValues) {
if (changedProperties.has("route")) {
withViewTransition(() => {
this._activeTab = this.tabs.find((tab) =>
`${this.route.prefix}${this.route.path}`.includes(tab.path)
);
});
this._activeTab = this.tabs.find((tab) =>
`${this.route.prefix}${this.route.path}`.includes(tab.path)
);
}
super.willUpdate(changedProperties);
}

View File

@@ -1,7 +1,7 @@
import { mdiClose, mdiDragHorizontalVariant, mdiTextureBox } from "@mdi/js";
import { mdiDragHorizontalVariant, mdiTextureBox } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import {
type AreasFloorHierarchy,
@@ -11,12 +11,10 @@ import {
} from "../../../common/areas/areas-floor-hierarchy";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-button";
import "../../../components/ha-dialog-header";
import "../../../components/ha-dialog-footer";
import "../../../components/ha-floor-icon";
import "../../../components/ha-icon";
import "../../../components/ha-icon-button";
import "../../../components/ha-md-dialog";
import type { HaMdDialog } from "../../../components/ha-md-dialog";
import "../../../components/ha-wa-dialog";
import "../../../components/ha-md-list";
import "../../../components/ha-md-list-item";
import "../../../components/ha-sortable";
@@ -49,8 +47,6 @@ class DialogAreasFloorsOrder extends LitElement {
@state() private _saving = false;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
public async showDialog(
_params: AreasFloorsOrderDialogParams
): Promise<void> {
@@ -66,7 +62,8 @@ class DialogAreasFloorsOrder extends LitElement {
}
public closeDialog(): void {
this._dialog?.close();
this._saving = false;
this._open = false;
}
private _dialogClosed(): void {
@@ -77,7 +74,7 @@ class DialogAreasFloorsOrder extends LitElement {
}
protected render() {
if (!this._open || !this._hierarchy) {
if (!this._hierarchy) {
return nothing;
}
@@ -89,17 +86,13 @@ class DialogAreasFloorsOrder extends LitElement {
);
return html`
<ha-md-dialog open @closed=${this._dialogClosed}>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
@click=${this.closeDialog}
></ha-icon-button>
<span slot="title" .title=${dialogTitle}>${dialogTitle}</span>
</ha-dialog-header>
<div slot="content" class="content">
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${dialogTitle}
@closed=${this._dialogClosed}
>
<div class="content">
<ha-sortable
handle-selector=".floor-handle"
draggable-selector=".floor"
@@ -116,15 +109,23 @@ class DialogAreasFloorsOrder extends LitElement {
</ha-sortable>
${this._renderUnassignedAreas()}
</div>
<div slot="actions">
<ha-button @click=${this.closeDialog} appearance="plain">
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
@click=${this.closeDialog}
appearance="plain"
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button @click=${this._save} .disabled=${this._saving}>
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${this._saving}
>
${this.hass.localize("ui.common.save")}
</ha-button>
</div>
</ha-md-dialog>
</ha-dialog-footer>
</ha-wa-dialog>
`;
}
@@ -391,15 +392,13 @@ class DialogAreasFloorsOrder extends LitElement {
haStyle,
haStyleDialog,
css`
ha-md-dialog {
min-width: 600px;
ha-wa-dialog {
max-height: 90%;
--dialog-content-padding: 8px 24px;
--dialog-content-padding: var(--ha-space-2) var(--ha-space-6);
}
@media all and (max-width: 600px), all and (max-height: 500px) {
ha-md-dialog {
--md-dialog-container-shape: 0;
@media all and (max-width: 580px), all and (max-height: 500px) {
ha-wa-dialog {
min-width: 100%;
min-height: 100%;
}

View File

@@ -2,7 +2,6 @@ import type { LitVirtualizer } from "@lit-labs/virtualizer";
import { consume } from "@lit/context";
import "@material/mwc-list/mwc-list";
import { mdiPlus, mdiTextureBox } from "@mdi/js";
import type { IFuseOptions } from "fuse.js";
import Fuse from "fuse.js";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import {
@@ -34,6 +33,7 @@ import "../../../../components/ha-section-title";
import "../../../../components/ha-tree-indicator";
import { ACTION_BUILDING_BLOCKS_GROUP } from "../../../../data/action";
import {
areaFloorComboBoxKeys,
getAreasAndFloors,
type AreaFloorValue,
type FloorComboBoxItem,
@@ -42,23 +42,30 @@ import { CONDITION_BUILDING_BLOCKS_GROUP } from "../../../../data/condition";
import type { ConfigEntry } from "../../../../data/config_entries";
import { labelsContext } from "../../../../data/context";
import {
deviceComboBoxKeys,
getDevices,
type DevicePickerItem,
} from "../../../../data/device_registry";
import {
entityComboBoxKeys,
getEntities,
type EntityComboBoxItem,
} from "../../../../data/entity_registry";
import type { DomainManifestLookup } from "../../../../data/integration";
import {
getLabels,
labelComboBoxKeys,
type LabelRegistryEntry,
} from "../../../../data/label_registry";
import {
getTargetComboBoxItemType,
TARGET_SEPARATOR,
} from "../../../../data/target";
import { HaFuse } from "../../../../resources/fuse";
import {
multiTermSearch,
multiTermSortedSearch,
type FuseWeightedKey,
} from "../../../../resources/fuseMultiTerm";
import { loadVirtualizer } from "../../../../resources/virtualizer";
import type { HomeAssistant } from "../../../../types";
import type {
@@ -76,6 +83,17 @@ const TARGET_SEARCH_SECTIONS = [
"label",
] as const;
export const ITEM_SEARCH_KEYS: FuseWeightedKey[] = [
{
name: "name",
weight: 10,
},
{
name: "description",
weight: 7,
},
];
type SearchSection = "item" | "block" | "entity" | "device" | "area" | "label";
@customElement("ha-automation-add-search")
@@ -434,27 +452,27 @@ export class HaAutomationAddSearch extends LitElement {
private _keyFunction = (item: PickerComboBoxItem | string) =>
typeof item === "string" ? item : item.id;
private _createFuseIndex = (states) =>
Fuse.createIndex(["search_labels"], states);
private _createFuseIndex = (states, keys: FuseWeightedKey[]) =>
Fuse.createIndex(keys, states);
private _fuseIndexes = {
area: memoizeOne((states: PickerComboBoxItem[]) =>
this._createFuseIndex(states)
this._createFuseIndex(states, areaFloorComboBoxKeys)
),
entity: memoizeOne((states: PickerComboBoxItem[]) =>
this._createFuseIndex(states)
this._createFuseIndex(states, entityComboBoxKeys)
),
device: memoizeOne((states: PickerComboBoxItem[]) =>
this._createFuseIndex(states)
this._createFuseIndex(states, deviceComboBoxKeys)
),
label: memoizeOne((states: PickerComboBoxItem[]) =>
this._createFuseIndex(states)
this._createFuseIndex(states, labelComboBoxKeys)
),
item: memoizeOne((states: PickerComboBoxItem[]) =>
this._createFuseIndex(states)
this._createFuseIndex(states, ITEM_SEARCH_KEYS)
),
block: memoizeOne((states: PickerComboBoxItem[]) =>
this._createFuseIndex(states)
this._createFuseIndex(states, ITEM_SEARCH_KEYS)
),
};
@@ -478,11 +496,12 @@ export class HaAutomationAddSearch extends LitElement {
if (!selectedSection || selectedSection === "item") {
let items = this._convertItemsToComboBoxItems(automationItems, type);
if (searchTerm) {
items = this._filterGroup("item", items, searchTerm, {
ignoreLocation: true,
includeScore: true,
minMatchCharLength: Math.min(2, this.filter.length),
}) as AutomationItemComboBoxItem[];
items = this._filterGroup(
"item",
items,
searchTerm,
ITEM_SEARCH_KEYS
) as AutomationItemComboBoxItem[];
}
if (!selectedSection && items.length) {
@@ -514,11 +533,12 @@ export class HaAutomationAddSearch extends LitElement {
);
if (searchTerm) {
blocks = this._filterGroup("block", blocks, searchTerm, {
ignoreLocation: true,
includeScore: true,
minMatchCharLength: Math.min(2, this.filter.length),
}) as AutomationItemComboBoxItem[];
blocks = this._filterGroup(
"block",
blocks,
searchTerm,
ITEM_SEARCH_KEYS
) as AutomationItemComboBoxItem[];
}
if (!selectedSection && blocks.length) {
@@ -550,9 +570,7 @@ export class HaAutomationAddSearch extends LitElement {
"entity",
entityItems,
searchTerm,
undefined,
(item: EntityComboBoxItem) =>
item.stateObj?.entity_id === searchTerm
entityComboBoxKeys
) as EntityComboBoxItem[];
}
@@ -581,7 +599,12 @@ export class HaAutomationAddSearch extends LitElement {
);
if (searchTerm) {
deviceItems = this._filterGroup("device", deviceItems, searchTerm);
deviceItems = this._filterGroup(
"device",
deviceItems,
searchTerm,
deviceComboBoxKeys
);
}
if (!selectedSection && deviceItems.length) {
@@ -617,7 +640,9 @@ export class HaAutomationAddSearch extends LitElement {
areasAndFloors = this._filterGroup(
"area",
areasAndFloors,
searchTerm
searchTerm,
areaFloorComboBoxKeys,
false
) as FloorComboBoxItem[];
}
@@ -664,7 +689,12 @@ export class HaAutomationAddSearch extends LitElement {
);
if (searchTerm) {
labels = this._filterGroup("label", labels, searchTerm);
labels = this._filterGroup(
"label",
labels,
searchTerm,
labelComboBoxKeys
);
}
if (!selectedSection && labels.length) {
@@ -691,50 +721,28 @@ export class HaAutomationAddSearch extends LitElement {
| AutomationItemComboBoxItem
)[],
searchTerm: string,
fuseOptions?: IFuseOptions<any>,
checkExact?: (
item:
| FloorComboBoxItem
| PickerComboBoxItem
| EntityComboBoxItem
| AutomationItemComboBoxItem
) => boolean
searchKeys: FuseWeightedKey[],
sort = true
) {
const fuseIndex = this._fuseIndexes[type](items);
const fuse = new HaFuse<
| FloorComboBoxItem
| PickerComboBoxItem
| EntityComboBoxItem
| AutomationItemComboBoxItem
>(
if (sort) {
return multiTermSortedSearch<PickerComboBoxItem>(
items,
searchTerm,
searchKeys,
(item) => item.id,
fuseIndex
);
}
return multiTermSearch<PickerComboBoxItem>(
items,
fuseOptions || {
shouldSort: false,
minMatchCharLength: Math.min(searchTerm.length, 2),
},
fuseIndex
searchTerm,
searchKeys,
fuseIndex,
{ ignoreLocation: true }
);
const results = fuse.multiTermsSearch(searchTerm);
let filteredItems = items;
if (results) {
filteredItems = results.map((result) => result.item);
}
if (!checkExact) {
return filteredItems;
}
// If there is exact match for entity id, put it first
const index = filteredItems.findIndex((item) => checkExact(item));
if (index === -1) {
return filteredItems;
}
const [exactMatch] = filteredItems.splice(index, 1);
filteredItems.unshift(exactMatch);
return filteredItems;
}
private _toggleSection(ev: Event) {
@@ -787,7 +795,11 @@ export class HaAutomationAddSearch extends LitElement {
iconPath,
renderedIcon: icon,
type,
search_labels: [key, name, description],
search_labels: {
key,
name,
description,
},
}));
private _focusSearchList = () => {

View File

@@ -1,15 +1,14 @@
import { mdiClose, mdiContentCopy, mdiDownload } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-icon-button-prev";
import "../../../../components/ha-md-dialog";
import type { HaMdDialog } from "../../../../components/ha-md-dialog";
import "../../../../components/ha-wa-dialog";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-password-field";
@@ -31,20 +30,18 @@ type Step = (typeof STEPS)[number];
class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _opened = false;
@state() private _open = false;
@state() private _step?: Step;
@state() private _params?: ChangeBackupEncryptionKeyDialogParams;
@query("ha-md-dialog") private _dialog!: HaMdDialog;
@state() private _newEncryptionKey?: string;
public showDialog(params: ChangeBackupEncryptionKeyDialogParams): void {
this._params = params;
this._step = STEPS[0];
this._opened = true;
this._open = true;
this._newEncryptionKey = generateEncryptionKey();
}
@@ -52,10 +49,10 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
if (this._params!.cancel) {
this._params!.cancel();
}
if (this._opened) {
if (this._open) {
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
this._opened = false;
this._open = false;
this._step = undefined;
this._params = undefined;
this._newEncryptionKey = undefined;
@@ -64,7 +61,7 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
private _done() {
this._params?.submit!(true);
this._dialog.close();
this.closeDialog();
}
private _previousStep() {
@@ -84,10 +81,6 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
}
protected render() {
if (!this._opened || !this._params) {
return nothing;
}
const dialogTitle =
this._step === "current" || this._step === "new"
? this.hass.localize(
@@ -96,36 +89,40 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
: "";
return html`
<ha-md-dialog disable-cancel-action open @closed=${this.closeDialog}>
<ha-dialog-header slot="headline">
${this._step === "new"
? html`
<ha-icon-button-prev
slot="navigationIcon"
@click=${this._previousStep}
></ha-icon-button-prev>
`
: html`
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
@click=${this.closeDialog}
></ha-icon-button>
`}
<span slot="title">${dialogTitle}</span>
</ha-dialog-header>
<div slot="content">${this._renderStepContent()}</div>
<div slot="actions">
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${dialogTitle}
prevent-scrim-close
@closed=${this.closeDialog}
>
${this._step === "new"
? html`
<ha-icon-button-prev
slot="headerNavigationIcon"
@click=${this._previousStep}
></ha-icon-button-prev>
`
: html`
<ha-icon-button
slot="headerNavigationIcon"
data-dialog="close"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
`}
${this._renderStepContent()}
<ha-dialog-footer slot="footer">
${this._step === "current"
? html`
<ha-button @click=${this._nextStep}>
<ha-button slot="primaryAction" @click=${this._nextStep}>
${this.hass.localize("ui.common.next")}
</ha-button>
`
: this._step === "new"
? html`
<ha-button
slot="primaryAction"
@click=${this._submit}
.disabled=${!this._newEncryptionKey}
variant="danger"
@@ -136,14 +133,14 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
</ha-button>
`
: html`
<ha-button @click=${this._done}>
<ha-button slot="primaryAction" @click=${this._done}>
${this.hass.localize(
"ui.panel.config.backup.dialogs.change_encryption_key.actions.done"
)}
</ha-button>
`}
</div>
</ha-md-dialog>
</ha-dialog-footer>
</ha-wa-dialog>
`;
}
@@ -287,10 +284,8 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
haStyle,
haStyleDialog,
css`
ha-md-dialog {
width: 90vw;
max-width: 560px;
--dialog-content-padding: 8px 24px;
ha-wa-dialog {
--dialog-content-padding: var(--ha-space-2) var(--ha-space-6);
}
ha-md-list {
background: none;
@@ -321,14 +316,7 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
flex: none;
margin: -16px;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-md-dialog {
max-width: none;
}
div[slot="content"] {
margin-top: 0;
}
}
p {
margin-top: 0;
}

View File

@@ -1,18 +1,12 @@
import { mdiClose } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-md-dialog";
import type { HaMdDialog } from "../../../../components/ha-md-dialog";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-password-field";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-wa-dialog";
import "../../../../components/ha-password-field";
import {
canDecryptBackupOnDownload,
getPreferredAgentForDownload,
@@ -27,102 +21,96 @@ import type { DownloadDecryptedBackupDialogParams } from "./show-dialog-download
class DialogDownloadDecryptedBackup extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _opened = false;
@state() private _open = false;
@state() private _params?: DownloadDecryptedBackupDialogParams;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
@state() private _encryptionKey = "";
@state() private _error = "";
public showDialog(params: DownloadDecryptedBackupDialogParams): void {
this._opened = true;
this._open = true;
this._params = params;
}
public closeDialog() {
this._dialog?.close();
this._open = false;
return true;
}
private _dialogClosed() {
if (this._opened) {
if (this._open) {
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
this._opened = false;
this._open = false;
this._params = undefined;
this._encryptionKey = "";
this._error = "";
}
protected render() {
if (!this._opened || !this._params) {
if (!this._params) {
return nothing;
}
return html`
<ha-md-dialog open @closed=${this._dialogClosed} disable-cancel-action>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
@click=${this.closeDialog}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<span slot="title">
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.title"
)}
</span>
</ha-dialog-header>
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${this.hass.localize(
"ui.panel.config.backup.dialogs.download.title"
)}
prevent-scrim-close
@closed=${this._dialogClosed}
>
<p>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.description"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.download_backup_encrypted",
{
download_it_encrypted: html`<button
class="link"
@click=${this._downloadEncrypted}
>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.download_it_encrypted"
)}
</button>`,
}
)}
</p>
<div slot="content">
<p>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.description"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.download_backup_encrypted",
{
download_it_encrypted: html`<button
class="link"
@click=${this._downloadEncrypted}
>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.download_it_encrypted"
)}
</button>`,
}
)}
</p>
<ha-password-field
.label=${this.hass.localize(
"ui.panel.config.backup.dialogs.download.encryption_key"
)}
@input=${this._keyChanged}
></ha-password-field>
<ha-password-field
.label=${this.hass.localize(
"ui.panel.config.backup.dialogs.download.encryption_key"
)}
@input=${this._keyChanged}
></ha-password-field>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
</div>
<div slot="actions">
<ha-button appearance="plain" @click=${this._cancel}>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this._cancel}
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button @click=${this._submit}>
<ha-button slot="primaryAction" @click=${this._submit}>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.download"
)}
</ha-button>
</div>
</ha-md-dialog>
</ha-dialog-footer>
</ha-wa-dialog>
`;
}
@@ -191,17 +179,8 @@ class DialogDownloadDecryptedBackup extends LitElement implements HassDialog {
haStyle,
haStyleDialog,
css`
ha-md-dialog {
--dialog-content-padding: 8px 24px;
max-width: 500px;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-md-dialog {
max-width: none;
}
div[slot="content"] {
margin-top: 0;
}
ha-wa-dialog {
--dialog-content-padding: var(--ha-space-2) var(--ha-space-6);
}
button.link {

View File

@@ -1,15 +1,14 @@
import { mdiClose, mdiContentCopy, mdiDownload } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-icon-button-prev";
import "../../../../components/ha-md-dialog";
import type { HaMdDialog } from "../../../../components/ha-md-dialog";
import "../../../../components/ha-wa-dialog";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-password-field";
@@ -24,89 +23,83 @@ import type { ShowBackupEncryptionKeyDialogParams } from "./show-dialog-show-bac
class DialogShowBackupEncryptionKey extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: ShowBackupEncryptionKeyDialogParams;
@state() private _open = false;
@query("ha-md-dialog") private _dialog!: HaMdDialog;
@state() private _params?: ShowBackupEncryptionKeyDialogParams;
public showDialog(params: ShowBackupEncryptionKeyDialogParams): void {
this._params = params;
this._open = true;
}
public closeDialog() {
if (this._open) {
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
this._open = false;
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
return true;
}
private _closeDialog() {
this._dialog.close();
}
protected render() {
if (!this._params) {
return nothing;
}
return html`
<ha-md-dialog disable-cancel-action open @closed=${this.closeDialog}>
<ha-dialog-header slot="headline">
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${this.hass.localize(
"ui.panel.config.backup.dialogs.show_encryption_key.title"
)}
prevent-scrim-close
@closed=${this.closeDialog}
>
<ha-icon-button
slot="headerNavigationIcon"
data-dialog="close"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<p>
${this.hass.localize(
"ui.panel.config.backup.dialogs.show_encryption_key.description"
)}
</p>
<div class="encryption-key">
<p>${this._params?.currentKey}</p>
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
@click=${this._closeDialog}
.path=${mdiContentCopy}
@click=${this._copyKeyToClipboard}
></ha-icon-button>
<span slot="title">
${this.hass.localize(
"ui.panel.config.backup.dialogs.show_encryption_key.title"
)}
</span>
</ha-dialog-header>
<div slot="content">
<p>
${this.hass.localize(
"ui.panel.config.backup.dialogs.show_encryption_key.description"
)}
</p>
<div class="encryption-key">
<p>${this._params?.currentKey}</p>
<ha-icon-button
.path=${mdiContentCopy}
@click=${this._copyKeyToClipboard}
></ha-icon-button>
</div>
<ha-md-list>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit_description"
)}
</span>
<ha-button
size="small"
appearance="plain"
slot="end"
@click=${this._download}
>
<ha-svg-icon .path=${mdiDownload} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit_action"
)}
</ha-button>
</ha-md-list-item>
</ha-md-list>
</div>
<div slot="actions">
<ha-button @click=${this._closeDialog}>
<ha-md-list>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit_description"
)}
</span>
<ha-button slot="end" @click=${this._download}>
<ha-svg-icon .path=${mdiDownload} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit_action"
)}
</ha-button>
</ha-md-list-item>
</ha-md-list>
<ha-dialog-footer slot="footer">
<ha-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.close")}
</ha-button>
</div>
</ha-md-dialog>
</ha-dialog-footer>
</ha-wa-dialog>
`;
}
@@ -135,10 +128,8 @@ class DialogShowBackupEncryptionKey extends LitElement implements HassDialog {
haStyle,
haStyleDialog,
css`
ha-md-dialog {
width: 90vw;
max-width: 560px;
--dialog-content-padding: 8px 24px;
ha-wa-dialog {
--dialog-content-padding: var(--ha-space-2) var(--ha-space-6);
}
ha-md-list {
background: none;
@@ -169,14 +160,7 @@ class DialogShowBackupEncryptionKey extends LitElement implements HassDialog {
flex: none;
margin: -16px;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-md-dialog {
max-width: none;
}
div[slot="content"] {
margin-top: 0;
}
}
p {
margin-top: 0;
}

View File

@@ -1,19 +1,17 @@
import { mdiClose, mdiFolderUpload } from "@mdi/js";
import { mdiFolderUpload } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import {
fireEvent,
type HASSDomEvent,
} from "../../../../common/dom/fire_event";
import "../../../../components/ha-alert";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-file-upload";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-md-dialog";
import type { HaMdDialog } from "../../../../components/ha-md-dialog";
import "../../../../components/ha-wa-dialog";
import {
CORE_LOCAL_AGENT,
HASSIO_LOCAL_AGENT,
@@ -43,11 +41,12 @@ export class DialogUploadBackup
@state() private _formData?: BackupUploadFileFormData;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
@state() private _open = false;
public async showDialog(params: UploadBackupDialogParams): Promise<void> {
this._params = params;
this._formData = INITIAL_UPLOAD_FORM_DATA;
this._open = true;
}
private _dialogClosed() {
@@ -56,11 +55,12 @@ export class DialogUploadBackup
}
this._formData = undefined;
this._params = undefined;
this._open = false;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
public closeDialog() {
this._dialog?.close();
this._open = false;
return true;
}
@@ -74,52 +74,44 @@ export class DialogUploadBackup
}
return html`
<ha-md-dialog
open
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${this.hass.localize(
"ui.panel.config.backup.dialogs.upload.title"
)}
?prevent-scrim-close=${this._uploading}
@closed=${this._dialogClosed}
.disableCancelAction=${this._uploading}
>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
@click=${this.closeDialog}
.disabled=${this._uploading}
></ha-icon-button>
<span slot="title">
${this.hass.localize("ui.panel.config.backup.dialogs.upload.title")}
</span>
</ha-dialog-header>
<div slot="content">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<ha-file-upload
.hass=${this.hass}
.uploading=${this._uploading}
.icon=${mdiFolderUpload}
.accept=${SUPPORTED_UPLOAD_FORMAT}
.localize=${this.hass.localize}
.label=${this.hass.localize(
"ui.panel.config.backup.dialogs.upload.input_label"
)}
.supports=${this.hass.localize(
"ui.panel.config.backup.dialogs.upload.supports_tar"
)}
@file-picked=${this._filePicked}
@files-cleared=${this._filesCleared}
></ha-file-upload>
</div>
<div slot="actions">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<ha-file-upload
.hass=${this.hass}
.uploading=${this._uploading}
.icon=${mdiFolderUpload}
.accept=${SUPPORTED_UPLOAD_FORMAT}
.localize=${this.hass.localize}
.label=${this.hass.localize(
"ui.panel.config.backup.dialogs.upload.input_label"
)}
.supports=${this.hass.localize(
"ui.panel.config.backup.dialogs.upload.supports_tar"
)}
@file-picked=${this._filePicked}
@files-cleared=${this._filesCleared}
></ha-file-upload>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this.closeDialog}
.disabled=${this._uploading}
>${this.hass.localize("ui.common.cancel")}</ha-button
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._upload}
.disabled=${!this._formValid() || this._uploading}
>
@@ -127,8 +119,8 @@ export class DialogUploadBackup
"ui.panel.config.backup.dialogs.upload.action"
)}
</ha-button>
</div>
</ha-md-dialog>
</ha-dialog-footer>
</ha-wa-dialog>
`;
}
@@ -183,15 +175,9 @@ export class DialogUploadBackup
haStyle,
haStyleDialog,
css`
ha-md-dialog {
max-width: 500px;
width: 100%;
max-width: 500px;
max-height: 100%;
}
ha-alert {
display: block;
margin-bottom: 16px;
margin-bottom: var(--ha-space-4);
}
`,
];

View File

@@ -120,9 +120,6 @@ export class HaCategoryPicker extends SubscribeMixin(LitElement) {
icon: category.icon || undefined,
icon_path: category.icon ? undefined : mdiTag,
sorting_label: category.name,
search_labels: [category.name, category.category_id].filter(
(v): v is string => Boolean(v)
),
}));
return items;
@@ -203,6 +200,9 @@ export class HaCategoryPicker extends SubscribeMixin(LitElement) {
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.valueRenderer=${valueRenderer}
.unknownItemText=${this.hass.localize(
"ui.components.category-picker.unknown"
)}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>

View File

@@ -1,11 +1,11 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { relativeTime } from "../../../common/datetime/relative_time";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-button";
import type { HaMdDialog } from "../../../components/ha-md-dialog";
import "../../../components/ha-md-dialog";
import "../../../components/ha-dialog-footer";
import "../../../components/ha-wa-dialog";
import "../../../components/ha-md-list";
import "../../../components/ha-md-list-item";
import type { HaSwitch } from "../../../components/ha-switch";
@@ -30,13 +30,14 @@ export class DialogLabsPreviewFeatureEnable
@state() private _createBackup = false;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
@state() private _open = false;
public async showDialog(
params: LabsPreviewFeatureEnableDialogParams
): Promise<void> {
this._params = params;
this._createBackup = false;
this._open = true;
this._fetchBackupConfig();
if (isComponentLoaded(this.hass, "hassio")) {
this._fetchUpdateBackupConfig();
@@ -44,7 +45,7 @@ export class DialogLabsPreviewFeatureEnable
}
public closeDialog(): boolean {
this._dialog?.close();
this._open = false;
return true;
}
@@ -143,72 +144,69 @@ export class DialogLabsPreviewFeatureEnable
const createBackupTexts = this._computeCreateBackupTexts();
return html`
<ha-md-dialog open @closed=${this._dialogClosed}>
<span slot="headline">
${this.hass.localize("ui.panel.config.labs.enable_title")}
</span>
<div slot="content">
<p>
${this.hass.localize(
`component.${this._params.preview_feature.domain}.preview_features.${this._params.preview_feature.preview_feature}.enable_confirmation`
) || this.hass.localize("ui.panel.config.labs.enable_confirmation")}
</p>
</div>
<div slot="actions">
${createBackupTexts
? html`
<ha-md-list>
<ha-md-list-item>
<span slot="headline">${createBackupTexts.title}</span>
${createBackupTexts.description
? html`
<span slot="supporting-text">
${createBackupTexts.description}
</span>
`
: nothing}
<ha-switch
slot="end"
.checked=${this._createBackup}
@change=${this._createBackupChanged}
></ha-switch>
</ha-md-list-item>
</ha-md-list>
`
: nothing}
<div>
<ha-button appearance="plain" @click=${this._handleCancel}>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
appearance="filled"
variant="brand"
@click=${this._handleConfirm}
>
${this.hass.localize("ui.panel.config.labs.enable")}
</ha-button>
</div>
</div>
</ha-md-dialog>
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${this.hass.localize("ui.panel.config.labs.enable_title")}
@closed=${this._dialogClosed}
>
<p>
${this.hass.localize(
`component.${this._params.preview_feature.domain}.preview_features.${this._params.preview_feature.preview_feature}.enable_confirmation`
) || this.hass.localize("ui.panel.config.labs.enable_confirmation")}
</p>
${createBackupTexts
? html`
<ha-md-list>
<ha-md-list-item>
<span slot="headline">${createBackupTexts.title}</span>
${createBackupTexts.description
? html`
<span slot="supporting-text">
${createBackupTexts.description}
</span>
`
: nothing}
<ha-switch
slot="end"
.checked=${this._createBackup}
@change=${this._createBackupChanged}
></ha-switch>
</ha-md-list-item>
</ha-md-list>
`
: nothing}
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this._handleCancel}
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
appearance="filled"
variant="brand"
@click=${this._handleConfirm}
>
${this.hass.localize("ui.panel.config.labs.enable")}
</ha-button>
</ha-dialog-footer>
</ha-wa-dialog>
`;
}
static readonly styles = css`
ha-md-dialog {
--dialog-content-padding: var(--ha-space-6);
ha-wa-dialog {
--dialog-content-padding: var(--ha-space-0);
}
p {
margin: 0;
margin: 0 var(--ha-space-6) var(--ha-space-6);
color: var(--secondary-text-color);
}
div[slot="actions"] {
display: flex;
flex-direction: column;
padding: 0;
}
ha-md-list {
background: none;
--md-list-item-leading-space: var(--ha-space-6);
@@ -217,13 +215,6 @@ export class DialogLabsPreviewFeatureEnable
padding: 0;
border-top: var(--ha-border-width-sm) solid var(--divider-color);
}
div[slot="actions"] > div {
display: flex;
justify-content: flex-end;
gap: var(--ha-space-2);
padding: var(--ha-space-4) var(--ha-space-6);
}
`;
}

View File

@@ -1,7 +1,7 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-md-dialog";
import "../../../components/ha-wa-dialog";
import "../../../components/ha-spinner";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import type { HomeAssistant } from "../../../types";
@@ -39,36 +39,36 @@ export class DialogLabsProgress
}
return html`
<ha-md-dialog
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
disable-cancel-action
prevent-scrim-close
@closed=${this._handleClosed}
>
<div slot="content">
<div class="summary">
<ha-spinner></ha-spinner>
<div class="content">
<p class="heading">
${this.hass.localize(
"ui.panel.config.labs.progress.creating_backup"
)}
</p>
<p class="description">
${this.hass.localize(
this._params.enabled
? "ui.panel.config.labs.progress.backing_up_before_enabling"
: "ui.panel.config.labs.progress.backing_up_before_disabling"
)}
</p>
</div>
<div slot="header"></div>
<div class="summary">
<ha-spinner></ha-spinner>
<div class="content">
<p class="heading">
${this.hass.localize(
"ui.panel.config.labs.progress.creating_backup"
)}
</p>
<p class="description">
${this.hass.localize(
this._params.enabled
? "ui.panel.config.labs.progress.backing_up_before_enabling"
: "ui.panel.config.labs.progress.backing_up_before_disabling"
)}
</p>
</div>
</div>
</ha-md-dialog>
</ha-wa-dialog>
`;
}
static readonly styles = css`
ha-md-dialog {
ha-wa-dialog {
--dialog-content-padding: var(--ha-space-6);
}

View File

@@ -4,7 +4,6 @@ import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { navigate } from "../../common/navigate";
import { withViewTransition } from "../../common/util/view-transition";
import "../../components/ha-button-menu";
import "../../components/ha-icon-button";
import "../../components/ha-list-item";
@@ -117,9 +116,7 @@ class PanelDeveloperTools extends LitElement {
return;
}
if (newPage !== this._page) {
withViewTransition(() => {
navigate(`/developer-tools/${newPage}`);
});
navigate(`/developer-tools/${newPage}`);
} else {
scrollTo({ behavior: "smooth", top: 0 });
}
@@ -128,9 +125,7 @@ class PanelDeveloperTools extends LitElement {
private async _handleMenuAction(ev: CustomEvent<ActionDetail>) {
switch (ev.detail.index) {
case 0:
withViewTransition(() => {
navigate(`/developer-tools/debug`);
});
navigate(`/developer-tools/debug`);
break;
}
}

View File

@@ -99,7 +99,6 @@ import type { HUIView } from "./views/hui-view";
import "./views/hui-view-background";
import "./views/hui-view-container";
import { UndoRedoController } from "../../common/controllers/undo-redo-controller";
import { withViewTransition } from "../../common/util/view-transition";
interface ActionItem {
icon: string;
@@ -1246,9 +1245,7 @@ class HUIRoot extends LitElement {
const viewIndex = Number(ev.detail.name);
if (viewIndex !== this._curView) {
const path = this.config.views[viewIndex].path || viewIndex;
withViewTransition(() => {
this._navigateToView(path);
});
this._navigateToView(path);
} else if (!this._editMode) {
scrollTo({ behavior: "smooth", top: 0 });
}

View File

@@ -1,78 +0,0 @@
import Fuse, {
type Expression,
type FuseIndex,
type FuseResult,
type FuseSearchOptions,
type IFuseOptions,
} from "fuse.js";
export interface FuseKey {
getFn: null;
id: string;
path: string[];
src: string;
weight: number;
}
const DEFAULT_OPTIONS: IFuseOptions<any> = {
ignoreDiacritics: true,
isCaseSensitive: false,
threshold: 0.3,
minMatchCharLength: 2,
};
export class HaFuse<T> extends Fuse<T> {
public constructor(
list: readonly T[],
options?: IFuseOptions<T>,
index?: FuseIndex<T>
) {
const mergedOptions = {
...DEFAULT_OPTIONS,
...options,
};
super(list, mergedOptions, index);
}
/**
* Performs a multi-term search across the indexed data.
* Splits the search string into individual terms and performs an AND operation between terms,
* where each term is searched across all indexed keys with an OR operation. words with less than
* 2 characters are ignored. If no valid terms are found, the search will return null.
*
* @param search - The search string to split into terms. Terms are space-separated.
* @param options - Optional Fuse.js search options to customize the search behavior.
* @typeParam R - The type of the result items. Defaults to T (the type of the indexed items).
* @returns An array of FuseResult objects containing matched items and their matching information.
* If no valid terms are found (after filtering by minimum length), returns all items with empty matches.
*/
public multiTermsSearch(
search: string,
options?: FuseSearchOptions
): FuseResult<T>[] | null {
const terms = search.toLowerCase().split(" ");
// @ts-expect-error options is not part of the Fuse type
const { minMatchCharLength } = this.options as IFuseOptions<T>;
const filteredTerms = minMatchCharLength
? terms.filter((term) => term.length >= minMatchCharLength)
: terms;
if (filteredTerms.length === 0) {
// If no valid terms are found, return null to indicate no search was performed
return null;
}
const index = this.getIndex().toJSON();
const keys = index.keys as unknown as FuseKey[]; // Fuse type for key is not correct
const expression: Expression = {
$and: filteredTerms.map((term) => ({
$or: keys.map((key) => ({ $path: key.path, $val: term })),
})),
};
return this.search(expression, options);
}
}

View File

@@ -0,0 +1,254 @@
import type {
Expression,
FuseIndex,
FuseOptionKey,
FuseResult,
IFuseOptions,
} from "fuse.js";
import Fuse from "fuse.js";
export interface FuseWeightedKey {
name: string | string[];
weight: number;
}
const DEFAULT_OPTIONS: IFuseOptions<any> = {
ignoreDiacritics: true,
isCaseSensitive: false,
threshold: 0.3,
minMatchCharLength: 2,
};
const DEFAULT_MIN_CHAR_LENGTH = 2;
/**
* Searches for a term within a collection of items using Fuse.js fuzzy search.
* @param items - The array of items to search through.
* @param search - The search term to look for.
* @param fuseIndex - Optional pre-built Fuse index for improved performance.
* @param options - Optional Fuse.js configuration options to override defaults.
* @returns An array of search results matching the search term.
*/
function searchTerm<T>(
items: T[],
search: string | Expression,
fuseIndex?: FuseIndex<T>,
options?: IFuseOptions<T>,
minMatchCharLength?: number
) {
const fuse = new Fuse<T>(
items,
{
...DEFAULT_OPTIONS,
minMatchCharLength:
minMatchCharLength ??
(typeof search === "string" && search.length < DEFAULT_MIN_CHAR_LENGTH
? search.length
: DEFAULT_MIN_CHAR_LENGTH),
...(options || {}),
},
fuseIndex
);
return fuse.search(search);
}
/**
* Performs a multi-term search across an array of items using Fuse.js.
* All search terms must match for an item to be included in the results.
* Result is NOT sorted by relevance.
*
* @template T - The type of items being searched
* @param items - The array of items to search through
* @param search - The search string containing one or more space-separated terms
* @param searchKeys - An array of weighted keys defining which properties to search
* @param fuseIndex - Optional pre-built Fuse index for improved performance
* @param options - Optional Fuse.js configuration options
* @returns An array of items that match all search terms
*/
export function multiTermSearch<T>(
items: T[],
search: string,
searchKeys: FuseOptionKey<T>[],
fuseIndex?: FuseIndex<T>,
options: IFuseOptions<T> = {}
): T[] {
const terms = search
.toLowerCase()
.split(" ")
.filter((t) => t.trim());
if (!terms.length) {
return items;
}
// be sure that all terms are used in the search
// just use DEFAULT_MIN_CHAR_LENGTH if the terms are at least that long
let minLength = DEFAULT_MIN_CHAR_LENGTH;
terms.forEach((term) => {
if (term.length < minLength) {
minLength = term.length;
}
});
const expression: Expression = {
$and: terms.map((term) => ({
$or: searchKeys.map((key) => ({
$path:
typeof key === "string"
? key
: Array.isArray(key)
? key.join(".")
: typeof key.name === "string"
? key.name
: key.name.join("."),
$val: term,
})),
})),
};
return searchTerm<T>(
items,
expression,
fuseIndex,
{
...options,
shouldSort: false,
},
minLength
).map((r) => r.item);
}
/**
* Performs a multi-term search across items using Fuse.js, returning results sorted by relevance.
*
* This function splits the search string into individual terms and searches for each term
* independently. Results are aggregated and scored based on:
* - Number of terms matched (items must match ALL terms to be included)
* - Fuse.js match score for each term
* - Weight of the matched keys
*
* @template T - The type of items being searched
* @param items - The array of items to search through
* @param search - The search string, which will be split by spaces into multiple terms
* @param searchKeys - Array of weighted keys configuration for Fuse.js search
* @param getItemId - Function to extract a unique identifier from each item
* @param fuseIndex - Optional but highly recommended! Pre-built Fuse.js index for improved performance
* @param options - Optional Fuse.js options to customize search behavior
* @returns An array of items that match all search terms, sorted by relevance score (highest first).
* Returns all items if search is empty, or empty array if not all terms have matches.
*/
export function multiTermSortedSearch<T>(
items: T[],
search: string,
searchKeys: FuseWeightedKey[],
getItemId: (item: T) => string,
fuseIndex?: FuseIndex<T>,
options: IFuseOptions<T> = {}
) {
const terms = search
.toLowerCase()
.split(" ")
.filter((t) => t.trim());
if (!terms.length) {
return items;
}
if (terms.length === 1) {
return searchTerm<T>(items, terms[0], fuseIndex, options).map(
(r) => r.item
);
}
const searchResults: Record<
string,
{ item: T; hits: number; score: number }
> = {};
let termHits = 0;
terms.forEach((term) => {
if (!term.trim()) {
return;
}
const termResults = searchTerm<T>(items, term, fuseIndex, {
...options,
shouldSort: false,
includeScore: true,
includeMatches: true,
});
if (termResults.length) {
termHits++;
termResults.forEach((r) => {
const itemId = getItemId(r.item);
if (!searchResults[itemId]) {
searchResults[itemId] = {
item: r.item,
hits: 0,
score: 0,
};
}
searchResults[itemId].hits += 1;
const weight = _getMatchedKeyHighestWeight<T>(r, searchKeys);
const score = r.score ? 1 - r.score : 0;
const weightedScore = score * weight;
searchResults[itemId].score += weightedScore;
});
}
});
// just return smth if for the full terms combination are results
if (termHits !== terms.length) {
return [];
}
// Filter to only items that matched all terms
const results = Object.values(searchResults).filter(
({ hits }) => hits === terms.length
);
// Sort by score descending
results.sort((a, b) => b.score - a.score);
return results.map(({ item }) => item);
}
/**
* Finds the highest weight among all matched keys in a Fuse.js search result.
*
* @typeParam T - The type of items being searched
* @param result - A single Fuse.js search result containing match information
* @param searchKeys - Array of weighted search keys configured for the Fuse instance
* @returns The highest weight value among matched keys, or 1 if no matches exist or no weights are defined
*/
function _getMatchedKeyHighestWeight<T>(
result: FuseResult<T>,
searchKeys: FuseWeightedKey[]
): number {
if (!result.matches || result.matches.length === 0) {
return 1;
}
// Find the highest weighted key that matched
let maxWeight = 1;
for (const match of result.matches) {
const keyConfig = searchKeys.find((k) => {
if (typeof k.name === "string") {
return k.name === match.key;
}
return k.name.join(".") === match.key;
});
if (keyConfig && keyConfig.weight && keyConfig.weight > maxWeight) {
maxWeight = keyConfig.weight;
}
}
return maxWeight;
}

View File

@@ -658,7 +658,8 @@
"show_entities": "Show entities",
"new_entity": "Create a new entity",
"placeholder": "Select an entity",
"create_helper": "Create a new {domain, select, \n undefined {} \n other {{domain} }\n } helper."
"create_helper": "Create a new {domain, select, \n undefined {} \n other {{domain} }\n } helper.",
"unknown": "Unknown entity selected"
},
"entity-name-picker": {
"types": {
@@ -768,6 +769,9 @@
"no_match": "No languages found for {term}",
"no_languages": "No languages available"
},
"icon-picker": {
"no_match": "No matching icons found"
},
"tts-picker": {
"tts": "Text-to-speech",
"none": "None"
@@ -779,7 +783,8 @@
"user-picker": {
"no_match": "No users found for {term}",
"user": "User",
"add_user": "Add user"
"add_user": "Add user",
"unknown": "Unknown user selected"
},
"blueprint-picker": {
"select_blueprint": "Select a blueprint"
@@ -793,7 +798,8 @@
"device": "Device",
"unnamed_device": "Unnamed device",
"no_area": "No area",
"placeholder": "Select a device"
"placeholder": "Select a device",
"unknown": "Unknown device selected"
},
"category-picker": {
"clear": "Clear",
@@ -805,6 +811,7 @@
"add_new": "Add new category…",
"no_categories": "No categories available",
"no_match": "No categories found for {term}",
"unknown": "Unknown category selected",
"add_dialog": {
"title": "Add new category",
"text": "Enter the name of the new category.",
@@ -831,7 +838,8 @@
"add_new": "Add new area…",
"no_areas": "No areas available",
"no_match": "No areas found for {term}",
"failed_create_area": "Failed to create area."
"failed_create_area": "Failed to create area.",
"unknown": "Unknown area selected"
},
"floor-picker": {
"clear": "Clear",
@@ -841,7 +849,8 @@
"add_new": "Add new floor…",
"no_floors": "No floors available",
"no_match": "No floors found for {term}",
"failed_create_floor": "Failed to create floor."
"failed_create_floor": "Failed to create floor.",
"unknown": "Unknown floor selected"
},
"area-filter": {
"title": "Areas",
@@ -858,7 +867,8 @@
"no_match": "No statistics found for {term}",
"no_state": "Entity without state",
"missing_entity": "Why is my entity not listed?",
"learn_more": "Learn more about statistics"
"learn_more": "Learn more about statistics",
"unknown": "Unknown statistic selected"
},
"addon-picker": {
"addon": "Add-on",
@@ -998,7 +1008,8 @@
},
"service-picker": {
"action": "Action",
"no_match": "No matching actions found"
"no_match": "No matching actions found",
"unknown": "Unknown action selected"
},
"service-control": {
"required": "This field is required",
@@ -1296,7 +1307,8 @@
},
"combo-box": {
"no_match": "No matching items found",
"no_items": "No items available"
"no_items": "No items available",
"unknown_item": "Unknown item"
},
"suggest_with_ai": {
"label": "Suggest",