diff --git a/demo/src/stubs/area_registry.ts b/demo/src/stubs/area_registry.ts index b7d8e5a34b..59dd77ffe8 100644 --- a/demo/src/stubs/area_registry.ts +++ b/demo/src/stubs/area_registry.ts @@ -4,4 +4,11 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass"; export const mockAreaRegistry = ( hass: MockHomeAssistant, data: AreaRegistryEntry[] = [] -) => hass.mockWS("config/area_registry/list", () => data); +) => { + hass.mockWS("config/area_registry/list", () => data); + const areas = {}; + data.forEach((area) => { + areas[area.area_id] = area; + }); + hass.updateHass({ areas }); +}; diff --git a/demo/src/stubs/device_registry.ts b/demo/src/stubs/device_registry.ts index 28c47e4a96..d1ab8025ee 100644 --- a/demo/src/stubs/device_registry.ts +++ b/demo/src/stubs/device_registry.ts @@ -4,4 +4,11 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass"; export const mockDeviceRegistry = ( hass: MockHomeAssistant, data: DeviceRegistryEntry[] = [] -) => hass.mockWS("config/device_registry/list", () => data); +) => { + hass.mockWS("config/device_registry/list", () => data); + const devices = {}; + data.forEach((device) => { + devices[device.id] = device; + }); + hass.updateHass({ devices }); +}; diff --git a/demo/src/stubs/floor_registry.ts b/demo/src/stubs/floor_registry.ts new file mode 100644 index 0000000000..c962f07a5c --- /dev/null +++ b/demo/src/stubs/floor_registry.ts @@ -0,0 +1,7 @@ +import { FloorRegistryEntry } from "../../../src/data/floor_registry"; +import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass"; + +export const mockFloorRegistry = ( + hass: MockHomeAssistant, + data: FloorRegistryEntry[] = [] +) => hass.mockWS("config/floor_registry/list", () => data); diff --git a/demo/src/stubs/label_registry.ts b/demo/src/stubs/label_registry.ts new file mode 100644 index 0000000000..27ca8fdc8e --- /dev/null +++ b/demo/src/stubs/label_registry.ts @@ -0,0 +1,7 @@ +import { LabelRegistryEntry } from "../../../src/data/label_registry"; +import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass"; + +export const mockLabelRegistry = ( + hass: MockHomeAssistant, + data: LabelRegistryEntry[] = [] +) => hass.mockWS("config/label_registry/list", () => data); diff --git a/gallery/src/pages/components/ha-selector.ts b/gallery/src/pages/components/ha-selector.ts index 15e2aae1ad..3824d9bb18 100644 --- a/gallery/src/pages/components/ha-selector.ts +++ b/gallery/src/pages/components/ha-selector.ts @@ -17,6 +17,10 @@ import { provideHass } from "../../../../src/fake_data/provide_hass"; import { ProvideHassElement } from "../../../../src/mixins/provide-hass-lit-mixin"; import type { HomeAssistant } from "../../../../src/types"; import "../../components/demo-black-white-row"; +import { FloorRegistryEntry } from "../../../../src/data/floor_registry"; +import { LabelRegistryEntry } from "../../../../src/data/label_registry"; +import { mockFloorRegistry } from "../../../../demo/src/stubs/floor_registry"; +import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry"; const ENTITIES = [ getEntity("alarm_control_panel", "alarm", "disarmed", { @@ -100,7 +104,7 @@ const DEVICES = [ const AREAS: AreaRegistryEntry[] = [ { area_id: "backyard", - floor_id: null, + floor_id: "ground", name: "Backyard", icon: null, picture: null, @@ -109,7 +113,7 @@ const AREAS: AreaRegistryEntry[] = [ }, { area_id: "bedroom", - floor_id: null, + floor_id: "first", name: "Bedroom", icon: "mdi:bed", picture: null, @@ -118,7 +122,7 @@ const AREAS: AreaRegistryEntry[] = [ }, { area_id: "livingroom", - floor_id: null, + floor_id: "ground", name: "Livingroom", icon: "mdi:sofa", picture: null, @@ -127,6 +131,45 @@ const AREAS: AreaRegistryEntry[] = [ }, ]; +const FLOORS: FloorRegistryEntry[] = [ + { + floor_id: "ground", + name: "Ground floor", + level: 0, + icon: null, + aliases: [], + }, + { + floor_id: "first", + name: "First floor", + level: 1, + icon: "mdi:numeric-1", + aliases: [], + }, + { + floor_id: "second", + name: "Second floor", + level: 2, + icon: "mdi:numeric-2", + aliases: [], + }, +]; + +const LABELS: LabelRegistryEntry[] = [ + { + label_id: "energy", + name: "Energy", + icon: null, + color: "yellow", + }, + { + label_id: "entertainment", + name: "Entertainment", + icon: "mdi:popcorn", + color: "blue", + }, +]; + const SCHEMAS: { name: string; input: Record; @@ -134,7 +177,12 @@ const SCHEMAS: { { name: "One of each", input: { + label: { name: "Label", selector: { label: {} } }, + floor: { name: "Floor", selector: { floor: {} } }, + area: { name: "Area", selector: { area: {} } }, + device: { name: "Device", selector: { device: {} } }, entity: { name: "Entity", selector: { entity: {} } }, + target: { name: "Target", selector: { target: {} } }, state: { name: "State", selector: { state: { entity_id: "alarm_control_panel.alarm" } }, @@ -143,15 +191,12 @@ const SCHEMAS: { name: "Attribute", selector: { attribute: { entity_id: "" } }, }, - device: { name: "Device", selector: { device: {} } }, config_entry: { name: "Integration", selector: { config_entry: {} }, }, duration: { name: "Duration", selector: { duration: {} } }, addon: { name: "Addon", selector: { addon: {} } }, - area: { name: "Area", selector: { area: {} } }, - target: { name: "Target", selector: { target: {} } }, number_box: { name: "Number Box", selector: { @@ -300,6 +345,8 @@ const SCHEMAS: { entity: { name: "Entity", selector: { entity: { multiple: true } } }, device: { name: "Device", selector: { device: { multiple: true } } }, area: { name: "Area", selector: { area: { multiple: true } } }, + floor: { name: "Floor", selector: { floor: { multiple: true } } }, + label: { name: "Label", selector: { label: { multiple: true } } }, select: { name: "Select Multiple", selector: { @@ -356,6 +403,8 @@ class DemoHaSelector extends LitElement implements ProvideHassElement { mockDeviceRegistry(hass, DEVICES); mockConfigEntries(hass); mockAreaRegistry(hass, AREAS); + mockFloorRegistry(hass, FLOORS); + mockLabelRegistry(hass, LABELS); mockHassioSupervisor(hass); hass.mockWS("auth/sign_path", (params) => params); hass.mockWS("media_player/browse_media", this._browseMedia); diff --git a/package.json b/package.json index cece79f41b..95e64ad94f 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@codemirror/legacy-modes": "6.3.3", "@codemirror/search": "6.5.6", "@codemirror/state": "6.4.1", - "@codemirror/view": "6.26.0", + "@codemirror/view": "6.26.1", "@egjs/hammerjs": "2.0.17", "@formatjs/intl-datetimeformat": "6.12.3", "@formatjs/intl-displaynames": "6.6.6", diff --git a/pyproject.toml b/pyproject.toml index 26cdafe444..caf1247abc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "home-assistant-frontend" -version = "20240329.1" +version = "20240402.0" license = {text = "Apache-2.0"} description = "The Home Assistant frontend" readme = "README.md" diff --git a/src/components/chips/ha-assist-chip.ts b/src/components/chips/ha-assist-chip.ts index 6e9e6bc7c9..5a7b0d1fd5 100644 --- a/src/components/chips/ha-assist-chip.ts +++ b/src/components/chips/ha-assist-chip.ts @@ -22,14 +22,6 @@ export class HaAssistChip extends MdAssistChip { ); --md-assist-chip-outline-color: var(--outline-color); --md-assist-chip-label-text-weight: 400; - --ha-assist-chip-filled-container-color: rgba( - var(--rgb-primary-text-color), - 0.15 - ); - --ha-assist-chip-active-container-color: rgba( - var(--rgb-primary-color), - 0.15 - ); } /** Material 3 doesn't have a filled chip, so we have to make our own **/ .filled { @@ -52,10 +44,17 @@ export class HaAssistChip extends MdAssistChip { margin-inline-end: unset; margin-inline-start: var(--_icon-label-space); } + ::before { + background: var(--ha-assist-chip-container-color); + opacity: var(--ha-assist-chip-container-opacity); + } :where(.active)::before { background: var(--ha-assist-chip-active-container-color); opacity: var(--ha-assist-chip-active-container-opacity); } + .label { + font-family: Roboto, sans-serif; + } `, ]; diff --git a/src/components/data-table/ha-data-table.ts b/src/components/data-table/ha-data-table.ts index cf442f525f..e0f7c8894e 100644 --- a/src/components/data-table/ha-data-table.ts +++ b/src/components/data-table/ha-data-table.ts @@ -181,6 +181,13 @@ export class HaDataTable extends LitElement { this._checkedRowsChanged(); } + public selectAll(): void { + this._checkedRows = this._filteredData + .filter((data) => data.selectable !== false) + .map((data) => data[this.id]); + this._checkedRowsChanged(); + } + public connectedCallback() { super.connectedCallback(); if (this._items.length) { @@ -386,7 +393,7 @@ export class HaDataTable extends LitElement { `; } - private _keyFunction = (row: DataTableRowData) => row[this.id] || row; + private _keyFunction = (row: DataTableRowData) => row?.[this.id] || row; private _renderRow = (row: DataTableRowData, index: number) => { // not sure how this happens... @@ -593,10 +600,7 @@ export class HaDataTable extends LitElement { private _handleHeaderRowCheckboxClick(ev: Event) { const checkbox = ev.target as HaCheckbox; if (checkbox.checked) { - this._checkedRows = this._filteredData - .filter((data) => data.selectable !== false) - .map((data) => data[this.id]); - this._checkedRowsChanged(); + this.selectAll(); } else { this._checkedRows = []; this._checkedRowsChanged(); @@ -623,9 +627,13 @@ export class HaDataTable extends LitElement { ev .composedPath() .find((el) => - ["ha-checkbox", "mwc-button", "ha-button", "ha-assist-chip"].includes( - (el as HTMLElement).localName - ) + [ + "ha-checkbox", + "mwc-button", + "ha-button", + "ha-icon-button", + "ha-assist-chip", + ].includes((el as HTMLElement).localName) ) ) { return; diff --git a/src/components/ha-button-menu-new.ts b/src/components/ha-button-menu-new.ts new file mode 100644 index 0000000000..3ec12b1108 --- /dev/null +++ b/src/components/ha-button-menu-new.ts @@ -0,0 +1,89 @@ +import { Button } from "@material/mwc-button"; +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, query } from "lit/decorators"; +import { FOCUS_TARGET } from "../dialogs/make-dialog-manager"; +import type { HaIconButton } from "./ha-icon-button"; +import "./ha-menu"; +import type { HaMenu } from "./ha-menu"; + +@customElement("ha-button-menu-new") +export class HaButtonMenuNew extends LitElement { + protected readonly [FOCUS_TARGET]; + + @property({ type: Boolean }) public disabled = false; + + @property() public positioning?: "fixed" | "absolute" | "popover"; + + @property({ type: Boolean, attribute: "has-overflow" }) public hasOverflow = + false; + + @query("ha-menu", true) private _menu!: HaMenu; + + public get items() { + return this._menu.items; + } + + public override focus() { + if (this._menu.open) { + this._menu.focus(); + } else { + this._triggerButton?.focus(); + } + } + + protected render(): TemplateResult { + return html` +
+ +
+ + + + `; + } + + private _handleClick(): void { + if (this.disabled) { + return; + } + this._menu.anchorElement = this; + if (this._menu.open) { + this._menu.close(); + } else { + this._menu.show(); + } + } + + private get _triggerButton() { + return this.querySelector( + 'ha-icon-button[slot="trigger"], mwc-button[slot="trigger"], ha-assist-chip[slot="trigger"]' + ) as HaIconButton | Button | null; + } + + private _setTriggerAria() { + if (this._triggerButton) { + this._triggerButton.ariaHasPopup = "menu"; + } + } + + static get styles(): CSSResultGroup { + return css` + :host { + display: inline-block; + position: relative; + } + ::slotted([disabled]) { + color: var(--disabled-text-color); + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-button-menu-new": HaButtonMenuNew; + } +} diff --git a/src/components/ha-color-picker.ts b/src/components/ha-color-picker.ts index f5af1d76a5..2acd53aa14 100644 --- a/src/components/ha-color-picker.ts +++ b/src/components/ha-color-picker.ts @@ -6,6 +6,7 @@ import { computeCssColor, THEME_COLORS } from "../common/color/compute-color"; import { fireEvent } from "../common/dom/fire_event"; import { stopPropagation } from "../common/dom/stop_propagation"; import "./ha-select"; +import "./ha-list-item"; import { HomeAssistant } from "../types"; import { LocalizeKeys } from "../common/translations/localize"; @@ -53,18 +54,18 @@ export class HaColorPicker extends LitElement { ` : nothing} ${this.defaultColor - ? html` + ? html` ${this.hass.localize(`ui.components.color-picker.default_color`)} - ` + ` : nothing} ${Array.from(THEME_COLORS).map( (color) => html` - + ${this.hass.localize( `ui.components.color-picker.colors.${color}` as LocalizeKeys ) || color} ${this.renderColorCircle(color)} - + ` )} diff --git a/src/components/ha-filter-blueprints.ts b/src/components/ha-filter-blueprints.ts index f7ef069c5e..c20dc197bf 100644 --- a/src/components/ha-filter-blueprints.ts +++ b/src/components/ha-filter-blueprints.ts @@ -50,7 +50,7 @@ export class HaFilterBlueprints extends LitElement { ? nothing : html` ${blueprint.metadata.name || id} ` diff --git a/src/components/ha-filter-devices.ts b/src/components/ha-filter-devices.ts index 944b26c87c..4b9f2bebc4 100644 --- a/src/components/ha-filter-devices.ts +++ b/src/components/ha-filter-devices.ts @@ -57,7 +57,8 @@ export class HaFilterDevices extends LitElement { ${this._shouldRender ? html` @@ -68,6 +69,8 @@ export class HaFilterDevices extends LitElement { `; } + private _keyFunction = (device) => device?.id; + private _renderItem = (device) => html` { + private _devices = memoizeOne((devices: HomeAssistant["devices"], _value) => { const values = Object.values(devices); return values.sort((a, b) => stringCompare( diff --git a/src/components/ha-filter-entities.ts b/src/components/ha-filter-entities.ts index 5d43de5f1c..2cffd99456 100644 --- a/src/components/ha-filter-entities.ts +++ b/src/components/ha-filter-entities.ts @@ -59,7 +59,12 @@ export class HaFilterEntities extends LitElement { ? html` @@ -81,6 +86,8 @@ export class HaFilterEntities extends LitElement { } } + private _keyFunction = (entity) => entity?.entity_id; + private _renderItem = (entity) => html` { + (states: HomeAssistant["states"], type: this["type"], _value) => { const values = Object.values(states); return values .filter( diff --git a/src/components/ha-filter-integrations.ts b/src/components/ha-filter-integrations.ts index 6fd909168c..5f8b1224b5 100644 --- a/src/components/ha-filter-integrations.ts +++ b/src/components/ha-filter-integrations.ts @@ -1,15 +1,16 @@ import { SelectedDetail } from "@material/mwc-list"; import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; +import { repeat } from "lit/directives/repeat"; import memoizeOne from "memoize-one"; import { fireEvent } from "../common/dom/fire_event"; import { stringCompare } from "../common/string/compare"; -import { haStyleScrollbar } from "../resources/styles"; -import type { HomeAssistant } from "../types"; import { fetchIntegrationManifests, IntegrationManifest, } from "../data/integration"; +import { haStyleScrollbar } from "../resources/styles"; +import type { HomeAssistant } from "../types"; import "./ha-domain-icon"; @customElement("ha-filter-integrations") @@ -47,11 +48,15 @@ export class HaFilterIntegrations extends LitElement { multi class="ha-scrollbar" > - ${this._integrations(this._manifests).map( + ${repeat( + this._integrations(this._manifests, this.value), + (i) => i.domain, (integration) => html` - manifest - .filter( - (mnfst) => - !mnfst.integration_type || - !["entity", "system", "hardware"].includes(mnfst.integration_type) - ) - .sort((a, b) => - stringCompare( - a.name || a.domain, - b.name || b.domain, - this.hass.locale.language + private _integrations = memoizeOne( + (manifest: IntegrationManifest[], _value) => + manifest + .filter( + (mnfst) => + !mnfst.integration_type || + !["entity", "system", "hardware"].includes(mnfst.integration_type) + ) + .sort((a, b) => + stringCompare( + a.name || a.domain, + b.name || b.domain, + this.hass.locale.language + ) ) - ) ); private async _integrationsSelected( ev: CustomEvent>> ) { - const integrations = this._integrations(this._manifests!); + const integrations = this._integrations(this._manifests!, this.value); if (!ev.detail.index.size) { fireEvent(this, "data-table-filter-changed", { diff --git a/src/components/ha-filter-labels.ts b/src/components/ha-filter-labels.ts index dd7ceada0b..43c3c10098 100644 --- a/src/components/ha-filter-labels.ts +++ b/src/components/ha-filter-labels.ts @@ -1,9 +1,10 @@ import { SelectedDetail } from "@material/mwc-list"; import "@material/mwc-menu/mwc-menu-surface"; +import { mdiPlus } from "@mdi/js"; import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { CSSResultGroup, LitElement, css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; -import { mdiPlus } from "@mdi/js"; +import { repeat } from "lit/directives/repeat"; import { computeCssColor } from "../common/color/compute-color"; import { fireEvent } from "../common/dom/fire_event"; import { @@ -12,13 +13,13 @@ import { subscribeLabelRegistry, } from "../data/label_registry"; import { SubscribeMixin } from "../mixins/subscribe-mixin"; +import { showLabelDetailDialog } from "../panels/config/labels/show-dialog-label-detail"; import { haStyleScrollbar } from "../resources/styles"; import type { HomeAssistant } from "../types"; import "./ha-check-list-item"; import "./ha-expansion-panel"; import "./ha-icon"; import "./ha-label"; -import { showLabelDetailDialog } from "../panels/config/labels/show-dialog-label-detail"; @customElement("ha-filter-labels") export class HaFilterLabels extends SubscribeMixin(LitElement) { @@ -63,26 +64,30 @@ export class HaFilterLabels extends SubscribeMixin(LitElement) { class="ha-scrollbar" multi > - ${this._labels.map((label) => { - const color = label.color - ? computeCssColor(label.color) - : undefined; - return html` - - ${label.icon - ? html`` - : nothing} - ${label.name} - - `; - })} + ${repeat( + this._labels, + (label) => label.label_id, + (label) => { + const color = label.color + ? computeCssColor(label.color) + : undefined; + return html` + + ${label.icon + ? html`` + : nothing} + ${label.name} + + `; + } + )} ` : nothing} diff --git a/src/components/ha-floor-picker.ts b/src/components/ha-floor-picker.ts index 59886ffa29..9ac37cf746 100644 --- a/src/components/ha-floor-picker.ts +++ b/src/components/ha-floor-picker.ts @@ -274,7 +274,7 @@ export class HaFloorPicker extends SubscribeMixin(LitElement) { if (areaIds) { const floorAreaLookup = getFloorAreaLookup(areas); outputFloors = outputFloors.filter((floor) => - floorAreaLookup[floor.floor_id].some((area) => + floorAreaLookup[floor.floor_id]?.some((area) => areaIds!.includes(area.area_id) ) ); diff --git a/src/components/ha-floors-picker.ts b/src/components/ha-floors-picker.ts new file mode 100644 index 0000000000..e5f0e39655 --- /dev/null +++ b/src/components/ha-floors-picker.ts @@ -0,0 +1,169 @@ +import { HassEntity } from "home-assistant-js-websocket"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property } from "lit/decorators"; +import { fireEvent } from "../common/dom/fire_event"; +import { SubscribeMixin } from "../mixins/subscribe-mixin"; +import type { HomeAssistant } from "../types"; +import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; +import "./ha-floor-picker"; + +@customElement("ha-floors-picker") +export class HaFloorsPicker extends SubscribeMixin(LitElement) { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public label?: string; + + @property({ type: Array }) public value?: string[]; + + @property() public helper?: string; + + @property() public placeholder?: string; + + @property({ type: Boolean, attribute: "no-add" }) + public noAdd = false; + + /** + * Show only floors with entities from specific domains. + * @type {Array} + * @attr include-domains + */ + @property({ type: Array, attribute: "include-domains" }) + public includeDomains?: string[]; + + /** + * Show no floors with entities of these domains. + * @type {Array} + * @attr exclude-domains + */ + @property({ type: Array, attribute: "exclude-domains" }) + public excludeDomains?: string[]; + + /** + * Show only floors with entities of these device classes. + * @type {Array} + * @attr include-device-classes + */ + @property({ type: Array, attribute: "include-device-classes" }) + public includeDeviceClasses?: string[]; + + @property({ attribute: false }) + public deviceFilter?: HaDevicePickerDeviceFilterFunc; + + @property({ attribute: false }) + public entityFilter?: (entity: HassEntity) => boolean; + + @property({ attribute: "picked-floor-label" }) + public pickedFloorLabel?: string; + + @property({ attribute: "pick-floor-label" }) + public pickFloorLabel?: string; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = false; + + protected render() { + if (!this.hass) { + return nothing; + } + + const currentFloors = this._currentFloors; + return html` + ${currentFloors.map( + (floor) => html` +
+ +
+ ` + )} +
+ +
+ `; + } + + private get _currentFloors(): string[] { + return this.value || []; + } + + private async _updateFloors(floors) { + this.value = floors; + + fireEvent(this, "value-changed", { + value: floors, + }); + } + + private _floorChanged(ev: CustomEvent) { + ev.stopPropagation(); + const curValue = (ev.currentTarget as any).curValue; + const newValue = ev.detail.value; + if (newValue === curValue) { + return; + } + const currentFloors = this._currentFloors; + if (!newValue || currentFloors.includes(newValue)) { + this._updateFloors(currentFloors.filter((ent) => ent !== curValue)); + return; + } + this._updateFloors( + currentFloors.map((ent) => (ent === curValue ? newValue : ent)) + ); + } + + private _addFloor(ev: CustomEvent) { + ev.stopPropagation(); + + const toAdd = ev.detail.value; + if (!toAdd) { + return; + } + (ev.currentTarget as any).value = ""; + const currentFloors = this._currentFloors; + if (currentFloors.includes(toAdd)) { + return; + } + + this._updateFloors([...currentFloors, toAdd]); + } + + static override styles = css` + div { + margin-top: 8px; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-floors-picker": HaFloorsPicker; + } +} diff --git a/src/components/ha-menu-item.ts b/src/components/ha-menu-item.ts new file mode 100644 index 0000000000..e96e867f6f --- /dev/null +++ b/src/components/ha-menu-item.ts @@ -0,0 +1,37 @@ +import { customElement } from "lit/decorators"; +import "element-internals-polyfill"; +import { CSSResult, css } from "lit"; +import { MdMenuItem } from "@material/web/menu/menu-item"; + +@customElement("ha-menu-item") +export class HaMenuItem extends MdMenuItem { + static override styles: CSSResult[] = [ + ...MdMenuItem.styles, + css` + :host { + --ha-icon-display: block; + --md-sys-color-primary: var(--primary-text-color); + --md-sys-color-on-primary: var(--primary-text-color); + --md-sys-color-secondary: var(--secondary-text-color); + --md-sys-color-surface: var(--card-background-color); + --md-sys-color-on-surface: var(--primary-text-color); + --md-sys-color-on-surface-variant: var(--secondary-text-color); + --md-sys-color-secondary-container: rgba( + var(--rgb-primary-color), + 0.15 + ); + --md-sys-color-on-secondary-container: var(--text-primary-color); + --mdc-icon-size: 16px; + + --md-sys-color-on-primary-container: var(--primary-text-color); + --md-sys-color-on-secondary-container: var(--primary-text-color); + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-menu-item": HaMenuItem; + } +} diff --git a/src/components/ha-menu.ts b/src/components/ha-menu.ts new file mode 100644 index 0000000000..d1a4146984 --- /dev/null +++ b/src/components/ha-menu.ts @@ -0,0 +1,22 @@ +import { customElement } from "lit/decorators"; +import "element-internals-polyfill"; +import { CSSResult, css } from "lit"; +import { MdMenu } from "@material/web/menu/menu"; + +@customElement("ha-menu") +export class HaMenu extends MdMenu { + static override styles: CSSResult[] = [ + ...MdMenu.styles, + css` + :host { + --md-sys-color-surface-container: var(--card-background-color); + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-menu": HaMenu; + } +} diff --git a/src/components/ha-outlined-text-field.ts b/src/components/ha-outlined-text-field.ts index 0580f65fcb..3118c85049 100644 --- a/src/components/ha-outlined-text-field.ts +++ b/src/components/ha-outlined-text-field.ts @@ -27,6 +27,9 @@ export class HaOutlinedTextField extends MdOutlinedTextField { --md-outlined-field-focus-outline-width: 1px; --mdc-icon-size: var(--md-input-chip-icon-size, 18px); } + .input { + font-family: Roboto, sans-serif; + } `, ]; } diff --git a/src/components/ha-selector/ha-selector-area.ts b/src/components/ha-selector/ha-selector-area.ts index e746314231..7690c387ad 100644 --- a/src/components/ha-selector/ha-selector-area.ts +++ b/src/components/ha-selector/ha-selector-area.ts @@ -87,8 +87,12 @@ export class HaAreaSelector extends LitElement { .label=${this.label} .helper=${this.helper} no-add - .deviceFilter=${this._filterDevices} - .entityFilter=${this._filterEntities} + .deviceFilter=${this.selector.area?.device + ? this._filterDevices + : undefined} + .entityFilter=${this.selector.area?.entity + ? this._filterEntities + : undefined} .disabled=${this.disabled} .required=${this.required} > @@ -102,8 +106,12 @@ export class HaAreaSelector extends LitElement { .helper=${this.helper} .pickAreaLabel=${this.label} no-add - .deviceFilter=${this._filterDevices} - .entityFilter=${this._filterEntities} + .deviceFilter=${this.selector.area?.device + ? this._filterDevices + : undefined} + .entityFilter=${this.selector.area?.entity + ? this._filterEntities + : undefined} .disabled=${this.disabled} .required=${this.required} > diff --git a/src/components/ha-selector/ha-selector-floor.ts b/src/components/ha-selector/ha-selector-floor.ts new file mode 100644 index 0000000000..eac63f414e --- /dev/null +++ b/src/components/ha-selector/ha-selector-floor.ts @@ -0,0 +1,153 @@ +import { HassEntity } from "home-assistant-js-websocket"; +import { html, LitElement, PropertyValues, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { ensureArray } from "../../common/array/ensure-array"; +import type { DeviceRegistryEntry } from "../../data/device_registry"; +import { getDeviceIntegrationLookup } from "../../data/device_registry"; +import { fireEvent } from "../../common/dom/fire_event"; +import { + EntitySources, + fetchEntitySourcesWithCache, +} from "../../data/entity_sources"; +import type { FloorSelector } from "../../data/selector"; +import { + filterSelectorDevices, + filterSelectorEntities, +} from "../../data/selector"; +import { HomeAssistant } from "../../types"; +import "../ha-floor-picker"; +import "../ha-floors-picker"; + +@customElement("ha-selector-floor") +export class HaFloorSelector extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public selector!: FloorSelector; + + @property() public value?: any; + + @property() public label?: string; + + @property() public helper?: string; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = true; + + @state() private _entitySources?: EntitySources; + + private _deviceIntegrationLookup = memoizeOne(getDeviceIntegrationLookup); + + private _hasIntegration(selector: FloorSelector) { + return ( + (selector.floor?.entity && + ensureArray(selector.floor.entity).some( + (filter) => filter.integration + )) || + (selector.floor?.device && + ensureArray(selector.floor.device).some((device) => device.integration)) + ); + } + + protected willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has("selector") && this.value !== undefined) { + if (this.selector.floor?.multiple && !Array.isArray(this.value)) { + this.value = [this.value]; + fireEvent(this, "value-changed", { value: this.value }); + } else if (!this.selector.floor?.multiple && Array.isArray(this.value)) { + this.value = this.value[0]; + fireEvent(this, "value-changed", { value: this.value }); + } + } + } + + protected updated(changedProperties: PropertyValues): void { + if ( + changedProperties.has("selector") && + this._hasIntegration(this.selector) && + !this._entitySources + ) { + fetchEntitySourcesWithCache(this.hass).then((sources) => { + this._entitySources = sources; + }); + } + } + + protected render() { + if (this._hasIntegration(this.selector) && !this._entitySources) { + return nothing; + } + + if (!this.selector.floor?.multiple) { + return html` + + `; + } + + return html` + + `; + } + + private _filterEntities = (entity: HassEntity): boolean => { + if (!this.selector.floor?.entity) { + return true; + } + + return ensureArray(this.selector.floor.entity).some((filter) => + filterSelectorEntities(filter, entity, this._entitySources) + ); + }; + + private _filterDevices = (device: DeviceRegistryEntry): boolean => { + if (!this.selector.floor?.device) { + return true; + } + + const deviceIntegrations = this._entitySources + ? this._deviceIntegrationLookup( + this._entitySources, + Object.values(this.hass.entities) + ) + : undefined; + + return ensureArray(this.selector.floor.device).some((filter) => + filterSelectorDevices(filter, device, deviceIntegrations) + ); + }; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-selector-floor": HaFloorSelector; + } +} diff --git a/src/components/ha-selector/ha-selector.ts b/src/components/ha-selector/ha-selector.ts index 5ab02782c1..e622721b6d 100644 --- a/src/components/ha-selector/ha-selector.ts +++ b/src/components/ha-selector/ha-selector.ts @@ -30,6 +30,7 @@ const LOAD_ELEMENTS = { entity: () => import("./ha-selector-entity"), statistic: () => import("./ha-selector-statistic"), file: () => import("./ha-selector-file"), + floor: () => import("./ha-selector-floor"), label: () => import("./ha-selector-label"), language: () => import("./ha-selector-language"), navigation: () => import("./ha-selector-navigation"), diff --git a/src/components/ha-sub-menu.ts b/src/components/ha-sub-menu.ts new file mode 100644 index 0000000000..15a5afdc47 --- /dev/null +++ b/src/components/ha-sub-menu.ts @@ -0,0 +1,38 @@ +import { customElement } from "lit/decorators"; +import "element-internals-polyfill"; +import { CSSResult, css } from "lit"; +import { MdSubMenu } from "@material/web/menu/sub-menu"; + +@customElement("ha-sub-menu") +// @ts-expect-error +export class HaSubMenu extends MdSubMenu { + static override styles: CSSResult[] = [ + MdSubMenu.styles, + css` + :host { + --ha-icon-display: block; + --md-sys-color-primary: var(--primary-text-color); + --md-sys-color-on-primary: var(--primary-text-color); + --md-sys-color-secondary: var(--secondary-text-color); + --md-sys-color-surface: var(--card-background-color); + --md-sys-color-on-surface: var(--primary-text-color); + --md-sys-color-on-surface-variant: var(--secondary-text-color); + --md-sys-color-secondary-container: rgba( + var(--rgb-primary-color), + 0.15 + ); + --md-sys-color-on-secondary-container: var(--text-primary-color); + --mdc-icon-size: 16px; + + --md-sys-color-on-primary-container: var(--primary-text-color); + --md-sys-color-on-secondary-container: var(--primary-text-color); + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-sub-menu": HaSubMenu; + } +} diff --git a/src/data/selector.ts b/src/data/selector.ts index 442fba220f..3abb7ae6fc 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -31,6 +31,7 @@ export type Selector = | DateSelector | DateTimeSelector | DeviceSelector + | FloorSelector | LegacyDeviceSelector | DurationSelector | EntitySelector @@ -170,6 +171,14 @@ export interface DeviceSelector { } | null; } +export interface FloorSelector { + floor: { + entity?: EntitySelectorFilter | readonly EntitySelectorFilter[]; + device?: DeviceSelectorFilter | readonly DeviceSelectorFilter[]; + multiple?: boolean; + } | null; +} + export interface LegacyDeviceSelector { device: DeviceSelector["device"] & { /** diff --git a/src/layouts/hass-subpage.ts b/src/layouts/hass-subpage.ts index f297a77b18..cabbeaf689 100644 --- a/src/layouts/hass-subpage.ts +++ b/src/layouts/hass-subpage.ts @@ -142,9 +142,12 @@ class HassSubpage extends LitElement { right: calc(16px + env(safe-area-inset-right)); inset-inline-end: calc(16px + env(safe-area-inset-right)); inset-inline-start: initial; - bottom: calc(16px + env(safe-area-inset-bottom)); z-index: 1; + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; } :host([narrow]) #fab.tabs { bottom: calc(84px + env(safe-area-inset-bottom)); diff --git a/src/layouts/hass-tabs-subpage-data-table.ts b/src/layouts/hass-tabs-subpage-data-table.ts index b4d4ddc58f..53a60e37e3 100644 --- a/src/layouts/hass-tabs-subpage-data-table.ts +++ b/src/layouts/hass-tabs-subpage-data-table.ts @@ -1,15 +1,13 @@ import { ResizeController } from "@lit-labs/observers/resize-controller"; import "@lrnwebcomponents/simple-tooltip/simple-tooltip"; import "@material/mwc-button/mwc-button"; -import "@material/web/menu/menu"; -import type { MdMenu } from "@material/web/menu/menu"; -import "@material/web/menu/menu-item"; +import "@material/web/divider/divider"; import { mdiArrowDown, mdiArrowUp, mdiClose, - mdiFilterRemove, mdiFilterVariant, + mdiFilterVariantRemove, mdiFormatListChecks, mdiMenuDown, } from "@mdi/js"; @@ -34,7 +32,10 @@ import type { HaDataTable, SortingDirection, } from "../components/data-table/ha-data-table"; +import "../components/ha-button-menu-new"; import "../components/ha-dialog"; +import { HaMenu } from "../components/ha-menu"; +import "../components/ha-menu-item"; import "../components/search-input-outlined"; import type { HomeAssistant, Route } from "../types"; import "./hass-tabs-subpage"; @@ -177,9 +178,9 @@ export class HaTabsSubpageDataTable extends LitElement { @query("ha-data-table", true) private _dataTable!: HaDataTable; - @query("#group-by-menu") private _groupByMenu!: MdMenu; + @query("#group-by-menu") private _groupByMenu!: HaMenu; - @query("#sort-by-menu") private _sortByMenu!: MdMenu; + @query("#sort-by-menu") private _sortByMenu!: HaMenu; private _showPaneController = new ResizeController(this, { callback: (entries) => entries[0]?.contentRect.width > 750, @@ -227,6 +228,9 @@ export class HaTabsSubpageDataTable extends LitElement { class="has-dropdown select-mode-chip" .active=${this._selectMode} @click=${this._enableSelectMode} + .title=${localize( + "ui.components.subpage-data-table.enter_selection_mode" + )} > ` @@ -252,8 +256,11 @@ export class HaTabsSubpageDataTable extends LitElement { id="sort-by-anchor" @click=${this._toggleSortBy} > - + + ` : nothing; @@ -290,11 +297,45 @@ export class HaTabsSubpageDataTable extends LitElement { > ${this._selectMode ? html`
-
+
+ + + + + ${localize("ui.components.subpage-data-table.select_all")} + + ${localize("ui.components.subpage-data-table.select_none")} + + + ${localize( + "ui.components.subpage-data-table.close_select_mode" + )} + +

${localize("ui.components.subpage-data-table.selected", { selected: this.selected || "0", @@ -318,6 +359,9 @@ export class HaTabsSubpageDataTable extends LitElement { slot="navigationIcon" .path=${mdiClose} @click=${this._toggleFilters} + .label=${localize( + "ui.components.subpage-data-table.close_filter" + )} > ${localize( @@ -326,7 +370,11 @@ export class HaTabsSubpageDataTable extends LitElement { >

@@ -347,8 +395,11 @@ export class HaTabsSubpageDataTable extends LitElement { >
@@ -409,39 +460,39 @@ export class HaTabsSubpageDataTable extends LitElement { `}
- + ${Object.entries(this.columns).map(([id, column]) => column.groupable ? html` - ${column.title || column.label} - + ` : nothing )} -
  • - + ${localize( - "ui.components.subpage-data-table.dont_group_by" - )} -
    - + ${localize("ui.components.subpage-data-table.dont_group_by")} + + + ${Object.entries(this.columns).map(([id, column]) => column.sortable ? html` - @@ -456,11 +507,11 @@ export class HaTabsSubpageDataTable extends LitElement { ` : nothing} ${column.title || column.label} - + ` : nothing )} - + `; } @@ -478,8 +529,6 @@ export class HaTabsSubpageDataTable extends LitElement { } private _handleSortBy(ev) { - ev.stopPropagation(); - ev.preventDefault(); const columnId = ev.currentTarget.value; if (!this._sortDirection || this._sortColumn !== columnId) { this._sortDirection = "asc"; @@ -504,6 +553,14 @@ export class HaTabsSubpageDataTable extends LitElement { this._dataTable.clearSelection(); } + private _selectAll() { + this._dataTable.selectAll(); + } + + private _selectNone() { + this._dataTable.clearSelection(); + } + private _handleSearchChange(ev: CustomEvent) { if (this.filter === ev.detail.value) { return; @@ -637,6 +694,8 @@ export class HaTabsSubpageDataTable extends LitElement { position: absolute; top: -4px; right: -4px; + inset-inline-end: -4px; + inset-inline-start: initial; min-width: 16px; box-sizing: border-box; border-radius: 50%; @@ -669,21 +728,31 @@ export class HaTabsSubpageDataTable extends LitElement { padding: 8px 12px; box-sizing: border-box; font-size: 14px; + --ha-assist-chip-container-color: var(--primary-background-color); + } + + .selection-controls { + display: flex; + align-items: center; + gap: 8px; + } + + .selection-controls p { + margin-left: 8px; + margin-inline-start: 8px; + margin-inline-end: initial; } .center-vertical { display: flex; align-items: center; + gap: 8px; } .relative { position: relative; } - .selection-bar p { - margin-left: 16px; - } - ha-assist-chip { --ha-assist-chip-container-shape: 10px; } @@ -712,23 +781,10 @@ export class HaTabsSubpageDataTable extends LitElement { display: flex; flex-direction: column; } - /* TODO: Migrate to ha-menu and ha-menu-item */ - md-menu { - --md-menu-container-color: var(--card-background-color); - } - md-menu-item { - --md-menu-item-label-text-color: var(--primary-text-color); - --mdc-icon-size: 16px; - --md-menu-item-selected-container-color: rgba( - var(--rgb-primary-color), - 0.15 - ); - } - md-menu-item.selected { - --md-menu-item-label-text-color: var(--primary-color); - } + #sort-by-anchor, - #group-by-anchor { + #group-by-anchor, + ha-button-menu-new ha-assist-chip { --md-assist-chip-trailing-space: 8px; } `; diff --git a/src/layouts/hass-tabs-subpage.ts b/src/layouts/hass-tabs-subpage.ts index c557856eb8..ee34ad75e2 100644 --- a/src/layouts/hass-tabs-subpage.ts +++ b/src/layouts/hass-tabs-subpage.ts @@ -344,6 +344,10 @@ class HassTabsSubpage extends LitElement { inset-inline-start: initial; bottom: calc(16px + env(safe-area-inset-bottom)); z-index: 1; + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; } :host([narrow]) #fab.tabs { bottom: calc(84px + env(safe-area-inset-bottom)); diff --git a/src/managers/notification-manager.ts b/src/managers/notification-manager.ts index a4636d2f7c..f7d4a16f85 100644 --- a/src/managers/notification-manager.ts +++ b/src/managers/notification-manager.ts @@ -27,7 +27,7 @@ class NotificationManager extends LitElement { @query("ha-toast") private _toast!: HaToast | undefined; public async showDialog(parameters: ShowToastParams) { - if (this._parameters) { + if (this._parameters && this._parameters.message !== parameters.message) { this._parameters = undefined; await this.updateComplete; } diff --git a/src/panels/config/areas/dialog-floor-registry-detail.ts b/src/panels/config/areas/dialog-floor-registry-detail.ts index 421c996b0c..775d183993 100644 --- a/src/panels/config/areas/dialog-floor-registry-detail.ts +++ b/src/panels/config/areas/dialog-floor-registry-detail.ts @@ -213,6 +213,9 @@ class DialogFloorDetail extends LitElement { display: block; margin-bottom: 16px; } + ha-floor-icon { + color: var(--secondary-text-color); + } `, ]; } diff --git a/src/panels/config/areas/ha-config-areas-dashboard.ts b/src/panels/config/areas/ha-config-areas-dashboard.ts index e322274d34..875e13367b 100644 --- a/src/panels/config/areas/ha-config-areas-dashboard.ts +++ b/src/panels/config/areas/ha-config-areas-dashboard.ts @@ -23,9 +23,11 @@ import "../../../components/ha-fab"; import "../../../components/ha-floor-icon"; import "../../../components/ha-icon-button"; import "../../../components/ha-svg-icon"; +import "../../../components/ha-sortable"; import { AreaRegistryEntry, createAreaRegistryEntry, + updateAreaRegistryEntry, } from "../../../data/area_registry"; import { FloorRegistryEntry, @@ -50,6 +52,10 @@ import { } from "./show-dialog-area-registry-detail"; import { showFloorRegistryDetailDialog } from "./show-dialog-floor-registry-detail"; +const UNASSIGNED_PATH = ["__unassigned__"]; + +const SORT_OPTIONS = { sort: false, delay: 500, delayOnTouchOnly: true }; + @customElement("ha-config-areas-dashboard") export class HaConfigAreasDashboard extends SubscribeMixin(LitElement) { @property({ attribute: false }) public hass!: HomeAssistant; @@ -187,13 +193,22 @@ export class HaConfigAreasDashboard extends SubscribeMixin(LitElement) { >
    -
    - ${floor.areas.map((area) => this._renderArea(area))} -
    + +
    + ${floor.areas.map((area) => this._renderArea(area))} +
    +
    ` )} ${areasAndFloors?.unassisgnedAreas.length - ? html`
    + ? html`

    ${this.hass.localize( @@ -201,11 +216,20 @@ export class HaConfigAreasDashboard extends SubscribeMixin(LitElement) { )}

    -
    - ${areasAndFloors?.unassisgnedAreas.map((area) => - this._renderArea(area) - )} -
    + +
    + ${areasAndFloors?.unassisgnedAreas.map((area) => + this._renderArea(area) + )} +
    +
    ` : nothing}
    @@ -281,6 +305,29 @@ export class HaConfigAreasDashboard extends SubscribeMixin(LitElement) { loadAreaRegistryDetailDialog(); } + private async _areaMoved(ev) { + const areasAndFloors = this._processAreas( + this.hass.areas, + this.hass.devices, + this.hass.entities, + this._floors! + ); + let area: AreaRegistryEntry; + if (ev.detail.oldPath === UNASSIGNED_PATH) { + area = areasAndFloors.unassisgnedAreas[ev.detail.oldIndex]; + } else { + const oldFloor = areasAndFloors.floors!.find( + (floor) => floor.floor_id === ev.detail.oldPath[0] + ); + area = oldFloor!.areas[ev.detail.oldIndex]; + } + + await updateAreaRegistryEntry(this.hass, area.area_id, { + floor_id: + ev.detail.newPath === UNASSIGNED_PATH ? null : ev.detail.newPath[0], + }); + } + private _handleFloorAction(ev: CustomEvent) { const floor = (ev.currentTarget as any).floor; switch (ev.detail.index) { @@ -424,7 +471,6 @@ export class HaConfigAreasDashboard extends SubscribeMixin(LitElement) { } .floor { --primary-color: var(--secondary-text-color); - margin-inline-end: 8px; } .warning { color: var(--error-color); diff --git a/src/panels/config/automation/add-automation-element-dialog.ts b/src/panels/config/automation/add-automation-element-dialog.ts index 3222c1b1e2..09fcf360a1 100644 --- a/src/panels/config/automation/add-automation-element-dialog.ts +++ b/src/panels/config/automation/add-automation-element-dialog.ts @@ -556,7 +556,7 @@ class DialogAddAutomationElement extends LitElement implements HassDialog { > - ` + ` : ""} ${repeat( items, diff --git a/src/panels/config/automation/ha-automation-picker.ts b/src/panels/config/automation/ha-automation-picker.ts index 5057c1e4d1..b5208b944e 100644 --- a/src/panels/config/automation/ha-automation-picker.ts +++ b/src/panels/config/automation/ha-automation-picker.ts @@ -1,16 +1,23 @@ import { consume } from "@lit-labs/context"; import "@lrnwebcomponents/simple-tooltip/simple-tooltip"; +import "@material/web/divider/divider"; import { + mdiChevronRight, + mdiCog, mdiContentDuplicate, mdiDelete, + mdiDotsVertical, mdiHelpCircle, mdiInformationOutline, + mdiMenuDown, mdiPlay, mdiPlayCircleOutline, mdiPlus, mdiRobotHappy, mdiStopCircleOutline, mdiTag, + mdiToggleSwitch, + mdiToggleSwitchOffOutline, mdiTransitConnection, } from "@mdi/js"; import { differenceInDays } from "date-fns/esm"; @@ -24,9 +31,10 @@ import { html, nothing, } from "lit"; -import { customElement, property, state } from "lit/decorators"; +import { customElement, property, query, state } from "lit/decorators"; import { styleMap } from "lit/directives/style-map"; import memoizeOne from "memoize-one"; +import { computeCssColor } from "../../../common/color/compute-color"; import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { formatShortDateTime } from "../../../common/datetime/format_date_time"; import { relativeTime } from "../../../common/datetime/relative_time"; @@ -38,6 +46,7 @@ import "../../../components/chips/ha-assist-chip"; import type { DataTableColumnContainer, RowClickedEvent, + SelectionChangedEvent, } from "../../../components/data-table/ha-data-table"; import "../../../components/data-table/ha-data-table-labels"; import "../../../components/entity/ha-entity-toggle"; @@ -50,6 +59,9 @@ import "../../../components/ha-filter-floor-areas"; import "../../../components/ha-filter-labels"; import "../../../components/ha-icon-button"; import "../../../components/ha-icon-overflow-menu"; +import "../../../components/ha-menu"; +import "../../../components/ha-menu-item"; +import "../../../components/ha-sub-menu"; import "../../../components/ha-svg-icon"; import { AutomationEntity, @@ -66,7 +78,11 @@ import { } from "../../../data/category_registry"; import { fullEntitiesContext } from "../../../data/context"; import { UNAVAILABLE } from "../../../data/entity"; -import { EntityRegistryEntry } from "../../../data/entity_registry"; +import { + EntityRegistryEntry, + UpdateEntityRegistryEntryResult, + updateEntityRegistryEntry, +} from "../../../data/entity_registry"; import { LabelRegistryEntry, subscribeLabelRegistry, @@ -79,11 +95,13 @@ import { import "../../../layouts/hass-tabs-subpage-data-table"; import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import { haStyle } from "../../../resources/styles"; -import { HomeAssistant, Route } from "../../../types"; +import { HomeAssistant, Route, ServiceCallResponse } from "../../../types"; import { documentationUrl } from "../../../util/documentation-url"; +import { turnOnOffEntity } from "../../lovelace/common/entity/turn-on-off-entity"; import { showAssignCategoryDialog } from "../category/show-dialog-assign-category"; import { configSections } from "../ha-panel-config"; import { showNewAutomationDialog } from "./show-dialog-new-automation"; +import type { HaMenu } from "../../../components/ha-menu"; type AutomationItem = AutomationEntity & { name: string; @@ -116,6 +134,8 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) { @state() private _expandedFilter?: string; + @state() private _selected: string[] = []; + @state() _categories!: CategoryRegistryEntry[]; @@ -126,6 +146,10 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) { @consume({ context: fullEntitiesContext, subscribe: true }) _entityReg!: EntityRegistryEntry[]; + @state() private _overflowAutomation?: AutomationItem; + + @query("#overflow-menu") private _overflowMenu!: HaMenu; + private _automations = memoizeOne( ( automations: AutomationEntity[], @@ -274,82 +298,33 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) { columns.actions = { title: "", width: "64px", - type: "overflow-menu", + type: "icon-button", template: (automation) => html` - this._showInfo(automation), - }, - { - path: mdiTag, - label: this.hass.localize( - `ui.panel.config.automation.picker.${automation.category ? "edit_category" : "assign_category"}` - ), - action: () => this._editCategory(automation), - }, - { - path: mdiPlay, - label: this.hass.localize( - "ui.panel.config.automation.editor.run" - ), - action: () => this._runActions(automation), - }, - { - path: mdiTransitConnection, - label: this.hass.localize( - "ui.panel.config.automation.editor.show_trace" - ), - action: () => this._showTrace(automation), - }, - { - divider: true, - }, - { - path: mdiContentDuplicate, - label: this.hass.localize( - "ui.panel.config.automation.picker.duplicate" - ), - action: () => this.duplicate(automation), - }, - { - path: - automation.state === "off" - ? mdiPlayCircleOutline - : mdiStopCircleOutline, - label: - automation.state === "off" - ? this.hass.localize( - "ui.panel.config.automation.editor.enable" - ) - : this.hass.localize( - "ui.panel.config.automation.editor.disable" - ), - action: () => this._toggle(automation), - }, - { - label: this.hass.localize( - "ui.panel.config.automation.picker.delete" - ), - path: mdiDelete, - action: () => this._deleteConfirm(automation), - warning: true, - }, - ]} - > - + `, }; return columns; } ); + private _showOverflowMenu = (ev) => { + if ( + this._overflowMenu.open && + ev.target === this._overflowMenu.anchorElement + ) { + this._overflowMenu.close(); + return; + } + this._overflowAutomation = ev.target.automation; + this._overflowMenu.anchorElement = ev.target; + this._overflowMenu.show(); + }; + protected hassSubscribe(): (UnsubscribeFunc | Promise)[] { return [ subscribeCategoryRegistry( @@ -366,6 +341,40 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) { } protected render(): TemplateResult { + const categoryItems = html`${this._categories?.map( + (category) => + html` + ${category.icon + ? html`` + : html``} +
    ${category.name}
    +
    ` + )} + +
    + ${this.hass.localize( + "ui.panel.config.automation.picker.bulk_actions.no_category" + )} +
    +
    `; + const labelItems = html` ${this._labels?.map((label) => { + const color = label.color ? computeCssColor(label.color) : undefined; + return html` + + ${label.icon + ? html`` + : nothing} + ${label.name} + + `; + })}`; + return html` filter.value?.length - ).length} + .filters=${ + Object.values(this._filters).filter((filter) => filter.value?.length) + .length + } .columns=${this._columns( this.narrow, this.hass.localize, @@ -466,36 +479,156 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) { .narrow=${this.narrow} @expanded-changed=${this._filterExpanded} > - ${!this.automations.length - ? html`
    - -

    + ${ + !this.narrow + ? html` + + + + ${categoryItems} + + ${this.hass.dockedSidebar === "docked" + ? nothing + : html` + + + + ${labelItems} + `}` + : nothing + } + + ${ + this.narrow + ? html` + + ` + : html`` + } + + ${ + this.narrow + ? html` + +
    + ${this.hass.localize( + "ui.panel.config.automation.picker.bulk_actions.move_category" + )} +
    + +
    + ${categoryItems} +
    ` + : nothing + } + ${ + this.narrow || this.hass.dockedSidebar === "docked" + ? html` + +
    + ${this.hass.localize( + "ui.panel.config.automation.picker.bulk_actions.add_label" + )} +
    + +
    + ${labelItems} +
    ` + : nothing + } + + +
    ${this.hass.localize( - "ui.panel.config.automation.picker.empty_header" + "ui.panel.config.automation.picker.bulk_actions.enable" )} -

    -

    +

    + + + +
    ${this.hass.localize( - "ui.panel.config.automation.picker.empty_text_1" + "ui.panel.config.automation.picker.bulk_actions.disable" )} -

    -

    - ${this.hass.localize( - "ui.panel.config.automation.picker.empty_text_2", - { user: this.hass.user?.name || "Alice" } - )} -

    - - - ${this.hass.localize("ui.panel.config.common.learn_more")} - - -
    ` - : nothing} +
    + + + ${ + !this.automations.length + ? html`
    + +

    + ${this.hass.localize( + "ui.panel.config.automation.picker.empty_header" + )} +

    +

    + ${this.hass.localize( + "ui.panel.config.automation.picker.empty_text_1" + )} +

    +

    + ${this.hass.localize( + "ui.panel.config.automation.picker.empty_text_2", + { user: this.hass.user?.name || "Alice" } + )} +

    + + + ${this.hass.localize("ui.panel.config.common.learn_more")} + + +
    ` + : nothing + } + + + +
    + ${this.hass.localize("ui.panel.config.automation.editor.show_info")} +
    +
    + + + +
    + ${this.hass.localize( + "ui.panel.config.automation.picker.show_settings" + )} +
    +
    + + +
    + ${this.hass.localize( + `ui.panel.config.automation.picker.${this._overflowAutomation?.category ? "edit_category" : "assign_category"}` + )} +
    +
    + + +
    + ${this.hass.localize("ui.panel.config.automation.editor.run")} +
    +
    + + +
    + ${this.hass.localize( + "ui.panel.config.automation.editor.show_trace" + )} +
    +
    + + + +
    + ${this.hass.localize("ui.panel.config.automation.picker.duplicate")} +
    +
    + + +
    + ${ + this._overflowAutomation?.state === "off" + ? this.hass.localize("ui.panel.config.automation.editor.enable") + : this.hass.localize( + "ui.panel.config.automation.editor.disable" + ) + } +
    +
    + + +
    + ${this.hass.localize("ui.panel.config.automation.picker.delete")} +
    +
    +
    `; } @@ -633,15 +840,29 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) { this._applyFilters(); } - private _showInfo(automation: any) { + private _showInfo(ev) { + const automation = ev.currentTarget.parentElement.anchorElement.automation; fireEvent(this, "hass-more-info", { entityId: automation.entity_id }); } - private _runActions(automation: any) { + private _showSettings(ev) { + const automation = ev.currentTarget.parentElement.anchorElement.automation; + + fireEvent(this, "hass-more-info", { + entityId: automation.entity_id, + view: "settings", + }); + } + + private _runActions(ev) { + const automation = ev.currentTarget.parentElement.anchorElement.automation; + triggerAutomationActions(this.hass, automation.entity_id); } - private _editCategory(automation: any) { + private _editCategory(ev) { + const automation = ev.currentTarget.parentElement.anchorElement.automation; + const entityReg = this._entityReg.find( (reg) => reg.entity_id === automation.entity_id ); @@ -662,7 +883,9 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) { }); } - private _showTrace(automation: any) { + private _showTrace(ev) { + const automation = ev.currentTarget.parentElement.anchorElement.automation; + if (!automation.attributes.id) { showAlertDialog(this, { text: this.hass.localize( @@ -676,14 +899,18 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) { ); } - private async _toggle(automation): Promise { + private async _toggle(ev): Promise { + const automation = ev.currentTarget.parentElement.anchorElement.automation; + const service = automation.state === "off" ? "turn_on" : "turn_off"; await this.hass.callService("automation", service, { entity_id: automation.entity_id, }); } - private async _deleteConfirm(automation) { + private async _deleteConfirm(ev) { + const automation = ev.currentTarget.parentElement.anchorElement.automation; + showConfirmationDialog(this, { title: this.hass.localize( "ui.panel.config.automation.picker.delete_confirm_title" @@ -717,7 +944,9 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) { } } - private async duplicate(automation) { + private async _duplicate(ev) { + const automation = ev.currentTarget.parentElement.anchorElement.automation; + try { const config = await fetchAutomationFileConfig( this.hass, @@ -776,6 +1005,12 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) { } } + private _handleSelectionChanged( + ev: HASSDomEvent + ): void { + this._selected = ev.detail.value; + } + private _createNew() { if (isComponentLoaded(this.hass, "blueprint")) { showNewAutomationDialog(this, { mode: "automation" }); @@ -784,6 +1019,48 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) { } } + private async _handleBulkCategory(ev) { + const category = ev.currentTarget.value; + const promises: Promise[] = []; + this._selected.forEach((entityId) => { + promises.push( + updateEntityRegistryEntry(this.hass, entityId, { + categories: { automation: category }, + }) + ); + }); + await Promise.all(promises); + } + + private async _handleBulkLabel(ev) { + const label = ev.currentTarget.value; + const promises: Promise[] = []; + this._selected.forEach((entityId) => { + promises.push( + updateEntityRegistryEntry(this.hass, entityId, { + labels: this.hass.entities[entityId].labels.concat(label), + }) + ); + }); + await Promise.all(promises); + } + + private async _handleBulkEnable() { + const promises: Promise[] = []; + this._selected.forEach((entityId) => { + promises.push(turnOnOffEntity(this.hass, entityId, true)); + }); + await Promise.all(promises); + } + + private async _handleBulkDisable() { + const promises: Promise[] = []; + this._selected.forEach((entityId) => { + promises.push(turnOnOffEntity(this.hass, entityId, false)); + }); + await Promise.all(promises); + } + static get styles(): CSSResultGroup { return [ haStyle, @@ -799,6 +1076,16 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) { --mdc-icon-size: 80px; max-width: 500px; } + ha-assist-chip { + --ha-assist-chip-container-shape: 10px; + } + ha-button-menu-new ha-assist-chip { + --md-assist-chip-trailing-space: 8px; + } + ha-label { + --ha-label-background-color: var(--color, var(--grey-color)); + --ha-label-background-opacity: 0.5; + } `, ]; } diff --git a/src/panels/config/entities/ha-config-entities.ts b/src/panels/config/entities/ha-config-entities.ts index 7df4b35bd8..c0d92b96b7 100644 --- a/src/panels/config/entities/ha-config-entities.ts +++ b/src/panels/config/entities/ha-config-entities.ts @@ -527,11 +527,11 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) { .filters=${Object.values(this._filters).filter( (filter) => filter.value?.length ).length} - .selected=${this._selectedEntities.length} .filter=${this._filter} selectable - clickable + .selected=${this._selectedEntities.length} @selection-changed=${this._handleSelectionChanged} + clickable @clear-filter=${this._clearFilter} @search-changed=${this._handleSearchChange} @row-click=${this._openEditEntry} diff --git a/src/panels/config/helpers/ha-config-helpers.ts b/src/panels/config/helpers/ha-config-helpers.ts index 6be03d6771..125663863f 100644 --- a/src/panels/config/helpers/ha-config-helpers.ts +++ b/src/panels/config/helpers/ha-config-helpers.ts @@ -1,8 +1,17 @@ import "@lrnwebcomponents/simple-tooltip/simple-tooltip"; import { mdiAlertCircle, mdiPencilOff, mdiPlus } from "@mdi/js"; import { HassEntity } from "home-assistant-js-websocket"; -import { LitElement, PropertyValues, TemplateResult, html } from "lit"; +import { + CSSResultGroup, + LitElement, + PropertyValues, + TemplateResult, + css, + html, + nothing, +} from "lit"; import { customElement, property, state } from "lit/decorators"; +import { consume } from "@lit-labs/context"; import memoizeOne from "memoize-one"; import { computeStateDomain } from "../../../common/entity/compute_state_domain"; import { navigate } from "../../../common/navigate"; @@ -15,6 +24,7 @@ import { DataTableColumnContainer, RowClickedEvent, } from "../../../components/data-table/ha-data-table"; +import "../../../components/data-table/ha-data-table-labels"; import "../../../components/ha-fab"; import "../../../components/ha-icon"; import "../../../components/ha-state-icon"; @@ -44,6 +54,13 @@ import { configSections } from "../ha-panel-config"; import "../integrations/ha-integration-overflow-menu"; import { isHelperDomain } from "./const"; import { showHelperDetailDialog } from "./show-dialog-helper-detail"; +import { + LabelRegistryEntry, + subscribeLabelRegistry, +} from "../../../data/label_registry"; +import { fullEntitiesContext } from "../../../data/context"; +import "../../../components/ha-filter-labels"; +import { haStyle } from "../../../resources/styles"; type HelperItem = { id: string; @@ -54,6 +71,7 @@ type HelperItem = { type: string; configEntry?: ConfigEntry; entity?: HassEntity; + label_entries: LabelRegistryEntry[]; }; // This groups items by a key but only returns last entry per key. @@ -93,6 +111,24 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { @state() private _configEntries?: Record; + @state() private _activeFilters?: string[]; + + @state() private _filters: Record< + string, + { value: string[] | undefined; items: Set | undefined } + > = {}; + + @state() private _expandedFilter?: string; + + @state() + _labels!: LabelRegistryEntry[]; + + @state() + @consume({ context: fullEntitiesContext, subscribe: true }) + _entityReg!: EntityRegistryEntry[]; + + @state() private _filteredStateItems?: string[] | null; + public hassSubscribe() { return [ subscribeConfigEntries( @@ -117,6 +153,9 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { subscribeEntityRegistry(this.hass.connection!, (entries) => { this._entityEntries = groupByOne(entries, (entry) => entry.entity_id); }), + subscribeLabelRegistry(this.hass.connection, (labels) => { + this._labels = labels; + }), ]; } @@ -146,10 +185,17 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { grows: true, direction: "asc", template: (helper) => html` - ${helper.name} +
    ${helper.name}
    ${narrow ? html`
    ${helper.entity_id}
    ` - : ""} + : nothing} + ${helper.label_entries.length + ? html` + + ` + : nothing} `, }, }; @@ -201,8 +247,15 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { localize: LocalizeFunc, stateItems: HassEntity[], entityEntries: Record, - configEntries: Record + configEntries: Record, + entityReg: EntityRegistryEntry[], + labelReg?: LabelRegistryEntry[], + filteredStateItems?: string[] | null ): HelperItem[] => { + if (filteredStateItems === null) { + return []; + } + const configEntriesCopy = { ...configEntries }; const states = stateItems.map((entityState) => { @@ -241,14 +294,29 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { entity: undefined, })); - return [...states, ...entries].map((item) => ({ - ...item, - localized_type: item.configEntry - ? domainToName(localize, item.type) - : localize( - `ui.panel.config.helpers.types.${item.type}` as LocalizeKeys - ) || item.type, - })); + return [...states, ...entries] + .filter((item) => + filteredStateItems + ? filteredStateItems?.includes(item.entity_id) + : true + ) + .map((item) => { + const entityRegEntry = entityReg.find( + (reg) => reg.entity_id === item.entity_id + ); + const labels = labelReg && entityRegEntry?.labels; + return { + ...item, + localized_type: item.configEntry + ? domainToName(localize, item.type) + : localize( + `ui.panel.config.helpers.types.${item.type}` as LocalizeKeys + ) || item.type, + label_entries: (labels || []).map( + (lbl) => labelReg!.find((label) => label.label_id === lbl)! + ), + }; + }); } ); @@ -269,20 +337,40 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { back-path="/config" .route=${this.route} .tabs=${configSections.devices} + hasFilters + .filters=${Object.values(this._filters).filter( + (filter) => filter.value?.length + ).length} .columns=${this._columns(this.narrow, this.hass.localize)} .data=${this._getItems( this.hass.localize, this._stateItems, this._entityEntries, - this._configEntries + this._configEntries, + this._entityReg, + this._labels, + this._filteredStateItems )} + .activeFilters=${this._activeFilters} + @clear-filter=${this._clearFilter} @row-click=${this._openEditDialog} hasFab clickable .noDataText=${this.hass.localize( "ui.panel.config.helpers.picker.no_helpers" )} + class=${this.narrow ? "narrow" : ""} > + + @@ -301,6 +389,63 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { `; } + private _filterExpanded(ev) { + if (ev.detail.expanded) { + this._expandedFilter = ev.target.localName; + } else if (this._expandedFilter === ev.target.localName) { + this._expandedFilter = undefined; + } + } + + private _filterChanged(ev) { + const type = ev.target.localName; + this._filters[type] = ev.detail; + this._applyFilters(); + } + + private _applyFilters() { + const filters = Object.entries(this._filters); + let items: Set | undefined; + for (const [key, filter] of filters) { + if (filter.items) { + if (!items) { + items = filter.items; + continue; + } + items = + "intersection" in items + ? // @ts-ignore + items.intersection(filter.items) + : new Set([...items].filter((x) => filter.items!.has(x))); + } + if (key === "ha-filter-labels" && filter.value?.length) { + const labelItems: Set = new Set(); + this._stateItems + .filter((stateItem) => + this._entityReg + .find((reg) => reg.entity_id === stateItem.entity_id) + ?.labels.some((lbl) => filter.value!.includes(lbl)) + ) + .forEach((stateItem) => labelItems.add(stateItem.entity_id)); + if (!items) { + items = labelItems; + continue; + } + items = + "intersection" in items + ? // @ts-ignore + items.intersection(labelItems) + : new Set([...items].filter((x) => labelItems!.has(x))); + } + } + this._filteredStateItems = items ? [...items] : undefined; + } + + private _clearFilter() { + this._filters = {}; + this._applyFilters(); + } + protected firstUpdated(changedProps: PropertyValues) { super.firstUpdated(changedProps); if (this.route.path === "/add") { @@ -418,9 +563,23 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { } } - private _createHelpler() { + private _createHelper() { showHelperDetailDialog(this, {}); } + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + hass-tabs-subpage-data-table { + --data-table-row-height: 60px; + } + hass-tabs-subpage-data-table.narrow { + --data-table-row-height: 72px; + } + `, + ]; + } } declare global { diff --git a/src/panels/config/labels/dialog-label-detail.ts b/src/panels/config/labels/dialog-label-detail.ts index c5e36cb723..b0e0638627 100644 --- a/src/panels/config/labels/dialog-label-detail.ts +++ b/src/panels/config/labels/dialog-label-detail.ts @@ -49,11 +49,19 @@ class DialogLabelDetail this._icon = ""; this._color = ""; } + document.body.addEventListener("keydown", this._handleKeyPress); } + private _handleKeyPress = (ev: KeyboardEvent) => { + if (ev.key === "Escape") { + ev.stopPropagation(); + } + }; + public closeDialog(): void { this._params = undefined; fireEvent(this, "dialog-closed", { dialog: this.localName }); + document.body.removeEventListener("keydown", this._handleKeyPress); } protected render() { diff --git a/src/resources/styles-data.ts b/src/resources/styles-data.ts index 8670024ca6..ae04a4c4c4 100644 --- a/src/resources/styles-data.ts +++ b/src/resources/styles-data.ts @@ -143,7 +143,10 @@ export const derivedStyles = { "mdc-select-disabled-ink-color": "var(--input-disabled-ink-color)", "mdc-select-dropdown-icon-color": "var(--input-dropdown-icon-color)", "mdc-select-disabled-dropdown-icon-color": "var(--input-disabled-ink-color)", - + "ha-assist-chip-filled-container-color": + "rgba(var(--rgb-primary-text-color),0.15)", + "ha-assist-chip-active-container-color": + "rgba(var(--rgb-primary-color),0.15)", "chip-background-color": "rgba(var(--rgb-primary-text-color), 0.15)", // Vaadin "material-body-text-color": "var(--primary-text-color)", diff --git a/src/translations/en.json b/src/translations/en.json index 8e282fba9d..9a68c368b7 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -501,11 +501,18 @@ }, "subpage-data-table": { "filters": "Filters", + "clear_filter": "Clear filter", + "close_filter": "Close filters", + "exit_selection_mode": "Exit selection mode", + "enter_selection_mode": "Enter selection mode", "sort_by": "Sort by {sortColumn}", "group_by": "Group by {groupColumn}", "dont_group_by": "Don't group", "select": "Select", - "selected": "Selected {selected}" + "selected": "Selected {selected}", + "close_select_mode": "Close selection mode", + "select_all": "Select all", + "select_none": "Select none" }, "config-entry-picker": { "config_entry": "Integration" @@ -2669,6 +2676,7 @@ "edit_automation": "Edit automation", "dev_automation": "Debug automation", "show_info_automation": "Show info about automation", + "show_settings": "Show settings", "delete": "[%key:ui::common::delete%]", "delete_confirm_title": "Delete automation?", "delete_confirm_text": "{name} will be permanently deleted.", @@ -2689,6 +2697,14 @@ "state": "State", "category": "Category" }, + "bulk_action": "Action", + "bulk_actions": { + "move_category": "Move to category", + "no_category": "No category", + "add_label": "Add label", + "enable": "Enable", + "disable": "Disable" + }, "empty_header": "Start automating", "empty_text_1": "Automations make Home Assistant automatically respond to things happening in and around your home.", "empty_text_2": "Automations connect triggers to actions in a ''when trigger then action'' fashion with optional conditions. For example: ''When the sun sets and if {user} is home, then turn on the lights''." diff --git a/yarn.lock b/yarn.lock index 93b495eaf2..f109503928 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1526,14 +1526,14 @@ __metadata: languageName: node linkType: hard -"@codemirror/view@npm:6.26.0, @codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.23.0": - version: 6.26.0 - resolution: "@codemirror/view@npm:6.26.0" +"@codemirror/view@npm:6.26.1, @codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.23.0": + version: 6.26.1 + resolution: "@codemirror/view@npm:6.26.1" dependencies: "@codemirror/state": "npm:^6.4.0" style-mod: "npm:^4.1.0" w3c-keyname: "npm:^2.2.4" - checksum: 10/d4ef249044cbc293a7267c83e08671a68646fd7bbe1efb8d205c01385f157c93918eabeaedb62a4cc10598ab63818ac749cec4f6355fe0404d9d4beb7857c31f + checksum: 10/6d2b19b2439c36b2712d3560eeb0c198ad2ee442ad22641c2b4bce94077812cffbb52ca12328219d3b9663b2dd0ffc63481432a2550839e5c7a7a53704e82a9a languageName: node linkType: hard @@ -9604,7 +9604,7 @@ __metadata: "@codemirror/legacy-modes": "npm:6.3.3" "@codemirror/search": "npm:6.5.6" "@codemirror/state": "npm:6.4.1" - "@codemirror/view": "npm:6.26.0" + "@codemirror/view": "npm:6.26.1" "@egjs/hammerjs": "npm:2.0.17" "@formatjs/intl-datetimeformat": "npm:6.12.3" "@formatjs/intl-displaynames": "npm:6.6.6"