diff --git a/src/components/chips/ha-assist-chip.ts b/src/components/chips/ha-assist-chip.ts index 7071663cd2..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,6 +44,10 @@ 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); diff --git a/src/components/data-table/ha-data-table.ts b/src/components/data-table/ha-data-table.ts index fbd6d97281..e514526c3b 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) { @@ -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(); 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-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/layouts/hass-tabs-subpage-data-table.ts b/src/layouts/hass-tabs-subpage-data-table.ts index b58d073165..53a60e37e3 100644 --- a/src/layouts/hass-tabs-subpage-data-table.ts +++ b/src/layouts/hass-tabs-subpage-data-table.ts @@ -1,12 +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/divider/divider"; import { mdiArrowDown, mdiArrowUp, mdiClose, - mdiFilterVariantRemove, mdiFilterVariant, + mdiFilterVariantRemove, mdiFormatListChecks, mdiMenuDown, } from "@mdi/js"; @@ -31,14 +32,14 @@ import type { HaDataTable, SortingDirection, } from "../components/data-table/ha-data-table"; +import "../components/ha-button-menu-new"; import "../components/ha-dialog"; -import "../components/search-input-outlined"; -import "../components/ha-menu"; +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"; import type { PageNavigation } from "./hass-tabs-subpage"; -import type { HaMenu } from "../components/ha-menu"; declare global { // for fire event @@ -227,7 +228,7 @@ export class HaTabsSubpageDataTable extends LitElement { class="has-dropdown select-mode-chip" .active=${this._selectMode} @click=${this._enableSelectMode} - .label=${localize( + .title=${localize( "ui.components.subpage-data-table.enter_selection_mode" )} > @@ -255,8 +256,11 @@ export class HaTabsSubpageDataTable extends LitElement { id="sort-by-anchor" @click=${this._toggleSortBy} > - + + ` : nothing; @@ -293,7 +297,7 @@ 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", @@ -440,16 +475,15 @@ export class HaTabsSubpageDataTable extends LitElement { ` : 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]) => @@ -458,6 +492,7 @@ export class HaTabsSubpageDataTable extends LitElement { @@ -494,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"; @@ -520,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; @@ -687,23 +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; - margin-inline-start: 16px; - margin-inline-end: initial; - } - ha-assist-chip { --ha-assist-chip-container-shape: 10px; } @@ -732,8 +781,10 @@ export class HaTabsSubpageDataTable extends LitElement { display: flex; flex-direction: column; } + #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/panels/config/automation/ha-automation-picker.ts b/src/panels/config/automation/ha-automation-picker.ts index d99731e954..c53a786621 100644 --- a/src/panels/config/automation/ha-automation-picker.ts +++ b/src/panels/config/automation/ha-automation-picker.ts @@ -1,17 +1,22 @@ import { consume } from "@lit-labs/context"; import "@lrnwebcomponents/simple-tooltip/simple-tooltip"; 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"; @@ -28,6 +33,7 @@ import { import { customElement, property, 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"; @@ -39,6 +45,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"; @@ -51,6 +58,8 @@ 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-item"; +import "../../../components/ha-sub-menu"; import "../../../components/ha-svg-icon"; import { AutomationEntity, @@ -67,7 +76,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, @@ -80,8 +93,9 @@ 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"; @@ -117,6 +131,8 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) { @state() private _expandedFilter?: string; + @state() private _selected: string[] = []; + @state() _categories!: CategoryRegistryEntry[]; @@ -374,6 +390,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, @@ -474,36 +528,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 + } + ): void { + this._selected = ev.detail.value; + } + private _createNew() { if (isComponentLoaded(this.hass, "blueprint")) { showNewAutomationDialog(this, { mode: "automation" }); @@ -799,6 +979,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, @@ -814,6 +1036,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/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 c4877539c1..9a68c368b7 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -509,7 +509,10 @@ "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" @@ -2694,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''."