diff --git a/src/components/ha-bottom-sheet.ts b/src/components/ha-bottom-sheet.ts index 604d5956e6..5af8ab8041 100644 --- a/src/components/ha-bottom-sheet.ts +++ b/src/components/ha-bottom-sheet.ts @@ -1,6 +1,7 @@ import "@home-assistant/webawesome/dist/components/drawer/drawer"; import { css, html, LitElement, type PropertyValues } from "lit"; import { customElement, property, state } from "lit/decorators"; +import { haStyleScrollbar } from "../resources/styles"; export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300; @@ -37,49 +38,61 @@ export class HaBottomSheet extends LitElement { @wa-after-hide=${this._handleAfterHide} without-header > - + +
+ +
`; } - static styles = css` - wa-drawer { - --wa-color-surface-raised: transparent; - --spacing: 0; - --size: var(--ha-bottom-sheet-height, auto); - --show-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms; - --hide-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms; - } - wa-drawer::part(dialog) { - max-height: var(--ha-bottom-sheet-max-height, 90vh); - align-items: center; - } - wa-drawer::part(body) { - max-width: var(--ha-bottom-sheet-max-width); - width: 100%; - border-top-left-radius: var( - --ha-bottom-sheet-border-radius, - var(--ha-dialog-border-radius, var(--ha-border-radius-2xl)) - ); - border-top-right-radius: var( - --ha-bottom-sheet-border-radius, - var(--ha-dialog-border-radius, var(--ha-border-radius-2xl)) - ); - background-color: var( - --ha-bottom-sheet-surface-background, - var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)), - ); - padding: var( - --ha-bottom-sheet-padding, - 0 var(--safe-area-inset-right) var(--safe-area-inset-bottom) - var(--safe-area-inset-left) - ); - } - - :host([flexcontent]) wa-drawer::part(body) { - display: flex; - } - `; + static styles = [ + haStyleScrollbar, + css` + wa-drawer { + --wa-color-surface-raised: transparent; + --spacing: 0; + --size: var(--ha-bottom-sheet-height, auto); + --show-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms; + --hide-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms; + } + wa-drawer::part(dialog) { + max-height: var(--ha-bottom-sheet-max-height, 90vh); + align-items: center; + } + wa-drawer::part(body) { + max-width: var(--ha-bottom-sheet-max-width); + width: 100%; + border-top-left-radius: var( + --ha-bottom-sheet-border-radius, + var(--ha-dialog-border-radius, var(--ha-border-radius-2xl)) + ); + border-top-right-radius: var( + --ha-bottom-sheet-border-radius, + var(--ha-dialog-border-radius, var(--ha-border-radius-2xl)) + ); + background-color: var( + --ha-bottom-sheet-surface-background, + var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)), + ); + padding: var( + --ha-bottom-sheet-padding, + 0 var(--safe-area-inset-right) var(--safe-area-inset-bottom) + var(--safe-area-inset-left) + ); + } + :host([flexcontent]) wa-drawer::part(body) { + display: flex; + flex-direction: column; + } + :host([flexcontent]) .body { + flex: 1; + max-width: 100%; + display: flex; + flex-direction: column; + } + `, + ]; } declare global { diff --git a/src/components/ha-button-toggle-group.ts b/src/components/ha-button-toggle-group.ts index 27709a5cf0..4a1793332f 100644 --- a/src/components/ha-button-toggle-group.ts +++ b/src/components/ha-button-toggle-group.ts @@ -31,6 +31,9 @@ export class HaButtonToggleGroup extends LitElement { @property({ type: Boolean, reflect: true, attribute: "no-wrap" }) public nowrap = false; + @property({ type: Boolean, reflect: true, attribute: "full-width" }) + public fullWidth = false; + @property() public variant: | "brand" | "neutral" @@ -38,6 +41,13 @@ export class HaButtonToggleGroup extends LitElement { | "warning" | "danger" = "brand"; + @property({ attribute: "active-variant" }) public activeVariant?: + | "brand" + | "neutral" + | "success" + | "warning" + | "danger"; + protected render(): TemplateResult { return html` @@ -46,7 +56,9 @@ export class HaButtonToggleGroup extends LitElement { html` @@ -152,6 +161,10 @@ export class HaWaDialog extends LitElement { (this.querySelector("[autofocus]") as HTMLElement | null)?.focus(); }; + private _handleAfterShow = () => { + fireEvent(this, "after-show"); + }; + private _handleAfterHide = () => { this._open = false; fireEvent(this, "closed"); @@ -183,7 +196,7 @@ export class HaWaDialog extends LitElement { ) ) ); - --width: var(--ha-dialog-width-md, min(580px, var(--full-width))); + --width: min(var(--ha-dialog-width-md, 580px), var(--full-width)); --spacing: var(--dialog-content-padding, var(--ha-space-6)); --show-duration: var(--ha-dialog-show-duration, 200ms); --hide-duration: var(--ha-dialog-hide-duration, 200ms); @@ -204,11 +217,11 @@ export class HaWaDialog extends LitElement { } :host([width="small"]) wa-dialog { - --width: var(--ha-dialog-width-sm, min(320px, var(--full-width))); + --width: min(var(--ha-dialog-width-sm, 320px), var(--full-width)); } :host([width="large"]) wa-dialog { - --width: var(--ha-dialog-width-lg, min(720px, var(--full-width))); + --width: min(var(--ha-dialog-width-lg, 720px), var(--full-width)); } :host([width="full"]) wa-dialog { @@ -222,6 +235,7 @@ export class HaWaDialog extends LitElement { --ha-dialog-max-height, calc(100% - var(--ha-space-20)) ); + min-height: var(--ha-dialog-min-height); position: var(--dialog-surface-position, relative); margin-top: var(--dialog-surface-margin-top, auto); display: flex; @@ -295,6 +309,7 @@ export class HaWaDialog extends LitElement { } :host([flexcontent]) .body { max-width: 100%; + flex: 1; display: flex; flex-direction: column; } @@ -323,6 +338,7 @@ declare global { interface HASSDomEvents { opened: undefined; + "after-show": undefined; closed: undefined; } } diff --git a/src/data/action.ts b/src/data/action.ts index f8fe4aeb75..3293ad4dac 100644 --- a/src/data/action.ts +++ b/src/data/action.ts @@ -6,8 +6,6 @@ import { mdiCallSplit, mdiCodeBraces, mdiDevices, - mdiDotsHorizontal, - mdiExcavator, mdiFormatListNumbered, mdiGestureDoubleTap, mdiHandBackRight, @@ -16,10 +14,10 @@ import { mdiRoomService, mdiShuffleDisabled, mdiTimerOutline, - mdiTools, mdiTrafficLight, } from "@mdi/js"; -import type { AutomationElementGroup } from "./automation"; +import type { AutomationElementGroupCollection } from "./automation"; +import type { Action } from "./script"; export const ACTION_ICONS = { condition: mdiAbTesting, @@ -48,37 +46,73 @@ export const YAML_ONLY_ACTION_TYPES = new Set([ "variables", ]); -export const ACTION_GROUPS: AutomationElementGroup = { - device_id: {}, - helpers: { - icon: mdiTools, - members: {}, - }, - building_blocks: { - icon: mdiExcavator, - members: { - condition: {}, - delay: {}, - wait_template: {}, - wait_for_trigger: {}, - repeat_count: {}, - repeat_while: {}, - repeat_until: {}, - repeat_for_each: {}, - choose: {}, - if: {}, - stop: {}, - sequence: {}, - parallel: {}, - variables: {}, +export const ACTION_COLLECTIONS: AutomationElementGroupCollection[] = [ + { + groups: { + device_id: {}, + serviceGroups: {}, }, }, - other: { - icon: mdiDotsHorizontal, - members: { + { + titleKey: "ui.panel.config.automation.editor.actions.groups.helpers.label", + groups: { + helpers: {}, + }, + }, + { + titleKey: "ui.panel.config.automation.editor.actions.groups.other.label", + groups: { event: {}, service: {}, set_conversation_response: {}, + other: {}, + }, + }, +] as const; + +export const ACTION_BUILDING_BLOCKS_GROUP = { + condition: {}, + delay: {}, + wait_template: {}, + wait_for_trigger: {}, + repeat_count: {}, + repeat_while: {}, + repeat_until: {}, + repeat_for_each: {}, + choose: {}, + if: {}, + stop: {}, + sequence: {}, + parallel: {}, + variables: {}, +}; + +// These will be replaced with the correct action +export const VIRTUAL_ACTIONS: Partial< + Record +> = { + repeat_count: { + repeat: { + count: 2, + sequence: [], + }, + }, + repeat_while: { + repeat: { + while: [], + sequence: [], + }, + }, + repeat_until: { + repeat: { + until: [], + sequence: [], + }, + }, + repeat_for_each: { + repeat: { + for_each: {}, + sequence: [], }, }, } as const; diff --git a/src/data/automation.ts b/src/data/automation.ts index bdd56573d2..e0eca6cc58 100644 --- a/src/data/automation.ts +++ b/src/data/automation.ts @@ -4,6 +4,7 @@ import type { } from "home-assistant-js-websocket"; import { ensureArray } from "../common/array/ensure-array"; import { navigate } from "../common/navigate"; +import type { LocalizeKeys } from "../common/translations/localize"; import { createSearchParam } from "../common/url/search-params"; import type { Context, HomeAssistant } from "../types"; import type { BlueprintInput } from "./blueprint"; @@ -293,6 +294,11 @@ export interface ShorthandNotCondition extends ShorthandBaseCondition { not: Condition[]; } +export interface AutomationElementGroupCollection { + titleKey?: LocalizeKeys; + groups: AutomationElementGroup; +} + export type AutomationElementGroup = Record< string, { icon?: string; members?: AutomationElementGroup } diff --git a/src/data/condition.ts b/src/data/condition.ts index 8c44a3d001..7f93eaf991 100644 --- a/src/data/condition.ts +++ b/src/data/condition.ts @@ -3,8 +3,6 @@ import { mdiClockOutline, mdiCodeBraces, mdiDevices, - mdiDotsHorizontal, - mdiExcavator, mdiGateOr, mdiIdentifier, mdiMapClock, @@ -15,7 +13,7 @@ import { mdiStateMachine, mdiWeatherSunny, } from "@mdi/js"; -import type { AutomationElementGroup } from "./automation"; +import type { AutomationElementGroupCollection } from "./automation"; export const CONDITION_ICONS = { device: mdiDevices, @@ -31,25 +29,31 @@ export const CONDITION_ICONS = { zone: mdiMapMarkerRadius, }; -export const CONDITION_GROUPS: AutomationElementGroup = { - device: {}, - entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } }, - time_location: { - icon: mdiMapClock, - members: { sun: {}, time: {}, zone: {} }, +export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [ + { + groups: { + device: {}, + entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } }, + time_location: { + icon: mdiMapClock, + members: { sun: {}, time: {}, zone: {} }, + }, + }, }, - building_blocks: { - icon: mdiExcavator, - members: { and: {}, or: {}, not: {} }, - }, - other: { - icon: mdiDotsHorizontal, - members: { + { + titleKey: "ui.panel.config.automation.editor.conditions.groups.other.label", + groups: { template: {}, trigger: {}, }, }, -} as const; +] as const; + +export const CONDITION_BUILDING_BLOCKS_GROUP = { + and: {}, + or: {}, + not: {}, +}; export const CONDITION_BUILDING_BLOCKS = ["and", "or", "not"]; diff --git a/src/data/trigger.ts b/src/data/trigger.ts index c6bb2f688a..1eeb7361be 100644 --- a/src/data/trigger.ts +++ b/src/data/trigger.ts @@ -4,7 +4,6 @@ import { mdiClockOutline, mdiCodeBraces, mdiDevices, - mdiDotsHorizontal, mdiFormatListBulleted, mdiGestureDoubleTap, mdiMapClock, @@ -23,7 +22,7 @@ import { import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg"; import type { - AutomationElementGroup, + AutomationElementGroupCollection, Trigger, TriggerList, } from "./automation"; @@ -49,16 +48,26 @@ export const TRIGGER_ICONS = { list: mdiFormatListBulleted, }; -export const TRIGGER_GROUPS: AutomationElementGroup = { - device: {}, - entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } }, - time_location: { - icon: mdiMapClock, - members: { calendar: {}, sun: {}, time: {}, time_pattern: {}, zone: {} }, +export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [ + { + groups: { + device: {}, + entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } }, + time_location: { + icon: mdiMapClock, + members: { + calendar: {}, + sun: {}, + time: {}, + time_pattern: {}, + zone: {}, + }, + }, + }, }, - other: { - icon: mdiDotsHorizontal, - members: { + { + titleKey: "ui.panel.config.automation.editor.triggers.groups.other.label", + groups: { event: {}, geo_location: {}, homeassistant: {}, @@ -70,7 +79,7 @@ export const TRIGGER_GROUPS: AutomationElementGroup = { persistent_notification: {}, }, }, -} as const; +] as const; export const isTriggerList = (trigger: Trigger): trigger is TriggerList => "triggers" in trigger; diff --git a/src/panels/config/automation/action/ha-automation-action.ts b/src/panels/config/automation/action/ha-automation-action.ts index 4fe2568775..23eb1c8ec9 100644 --- a/src/panels/config/automation/action/ha-automation-action.ts +++ b/src/panels/config/automation/action/ha-automation-action.ts @@ -1,9 +1,10 @@ import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js"; import deepClone from "deep-clone-simple"; import type { PropertyValues } from "lit"; -import { LitElement, html, nothing } from "lit"; +import { html, LitElement, nothing } from "lit"; import { customElement, property, queryAll, state } from "lit/decorators"; import { repeat } from "lit/directives/repeat"; +import { ensureArray } from "../../../../common/array/ensure-array"; import { storage } from "../../../../common/decorators/storage"; import { fireEvent } from "../../../../common/dom/fire_event"; import { stopPropagation } from "../../../../common/dom/stop_propagation"; @@ -15,19 +16,18 @@ import { ACTION_BUILDING_BLOCKS, getService, isService, + VIRTUAL_ACTIONS, } from "../../../../data/action"; import type { AutomationClipboard } from "../../../../data/automation"; import type { Action } from "../../../../data/script"; import type { HomeAssistant } from "../../../../types"; import { PASTE_VALUE, - VIRTUAL_ACTIONS, showAddAutomationElementDialog, } from "../show-add-automation-element-dialog"; import { automationRowsStyles } from "../styles"; import type HaAutomationActionRow from "./ha-automation-action-row"; import { getAutomationActionType } from "./ha-automation-action-row"; -import { ensureArray } from "../../../../common/array/ensure-array"; @customElement("ha-automation-action") export default class HaAutomationAction extends LitElement { @@ -136,17 +136,6 @@ export default class HaAutomationAction extends LitElement { "ui.panel.config.automation.editor.actions.add" )} - - - ${this.hass.localize( - "ui.panel.config.automation.editor.actions.add_building_block" - )} - @@ -222,15 +211,6 @@ export default class HaAutomationAction extends LitElement { }); } - private _addActionBuildingBlockDialog() { - showAddAutomationElementDialog(this, { - type: "action", - add: this._addAction, - clipboardItem: getAutomationActionType(this._clipboard?.action), - group: "building_blocks", - }); - } - private _addAction = (action: string) => { let actions: Action[]; if (action === PASTE_VALUE) { diff --git a/src/panels/config/automation/add-automation-element-dialog.ts b/src/panels/config/automation/add-automation-element-dialog.ts index 8a61b5ab17..a3394a5f7c 100644 --- a/src/panels/config/automation/add-automation-element-dialog.ts +++ b/src/panels/config/automation/add-automation-element-dialog.ts @@ -7,18 +7,29 @@ import { import Fuse from "fuse.js"; import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; import { LitElement, css, html, nothing } from "lit"; -import { customElement, property, query, state } from "lit/decorators"; +import { + customElement, + eventOptions, + property, + query, + state, +} from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; import { ifDefined } from "lit/directives/if-defined"; import { repeat } from "lit/directives/repeat"; -import { styleMap } from "lit/directives/style-map"; import memoizeOne from "memoize-one"; +import { tinykeys } from "tinykeys"; import { fireEvent } from "../../../common/dom/fire_event"; import { computeDomain } from "../../../common/entity/compute_domain"; import { stringCompare } from "../../../common/string/compare"; -import type { LocalizeFunc } from "../../../common/translations/localize"; +import type { + LocalizeFunc, + LocalizeKeys, +} from "../../../common/translations/localize"; +import { debounce } from "../../../common/util/debounce"; import { deepEqual } from "../../../common/util/deep-equal"; -import "../../../components/ha-dialog"; -import type { HaDialog } from "../../../components/ha-dialog"; +import "../../../components/ha-bottom-sheet"; +import "../../../components/ha-button-toggle-group"; import "../../../components/ha-dialog-header"; import "../../../components/ha-domain-icon"; import "../../../components/ha-icon-button"; @@ -26,29 +37,38 @@ import "../../../components/ha-icon-button-prev"; import "../../../components/ha-icon-next"; import "../../../components/ha-md-divider"; import "../../../components/ha-md-list"; +import type { HaMdList } from "../../../components/ha-md-list"; import "../../../components/ha-md-list-item"; import "../../../components/ha-service-icon"; +import "../../../components/ha-wa-dialog"; import "../../../components/search-input"; import { - ACTION_GROUPS, + ACTION_BUILDING_BLOCKS_GROUP, + ACTION_COLLECTIONS, ACTION_ICONS, SERVICE_PREFIX, getService, isService, } from "../../../data/action"; -import type { AutomationElementGroup } from "../../../data/automation"; -import { CONDITION_GROUPS, CONDITION_ICONS } from "../../../data/condition"; +import type { + AutomationElementGroup, + AutomationElementGroupCollection, +} from "../../../data/automation"; +import { + CONDITION_BUILDING_BLOCKS_GROUP, + CONDITION_COLLECTIONS, + CONDITION_ICONS, +} from "../../../data/condition"; import { getServiceIcons } from "../../../data/icons"; import type { IntegrationManifest } from "../../../data/integration"; import { domainToName, fetchIntegrationManifests, } from "../../../data/integration"; -import { TRIGGER_GROUPS, TRIGGER_ICONS } from "../../../data/trigger"; +import { TRIGGER_COLLECTIONS, TRIGGER_ICONS } from "../../../data/trigger"; import type { HassDialog } from "../../../dialogs/make-dialog-manager"; import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin"; import { HaFuse } from "../../../resources/fuse"; -import { haStyle, haStyleDialog } from "../../../resources/styles"; import type { HomeAssistant } from "../../../types"; import { isMac } from "../../../util/is_mac"; import { showToast } from "../../../util/toast"; @@ -56,13 +76,13 @@ import type { AddAutomationElementDialogParams } from "./show-add-automation-ele import { PASTE_VALUE } from "./show-add-automation-element-dialog"; const TYPES = { - trigger: { groups: TRIGGER_GROUPS, icons: TRIGGER_ICONS }, + trigger: { collections: TRIGGER_COLLECTIONS, icons: TRIGGER_ICONS }, condition: { - groups: CONDITION_GROUPS, + collections: CONDITION_COLLECTIONS, icons: CONDITION_ICONS, }, action: { - groups: ACTION_GROUPS, + collections: ACTION_COLLECTIONS, icons: ACTION_ICONS, }, }; @@ -73,7 +93,6 @@ interface ListItem { description: string; iconPath?: string; icon?: TemplateResult; - group: boolean; } type DomainManifestLookup = Record; @@ -92,6 +111,8 @@ const ENTITY_DOMAINS_OTHER = new Set([ const ENTITY_DOMAINS_MAIN = new Set(["notify"]); +const ACTION_SERVICE_KEYWORDS = ["serviceGroups", "helpers", "other"]; + @customElement("add-automation-element-dialog") class DialogAddAutomationElement extends KeyboardShortcutMixin(LitElement) @@ -101,9 +122,11 @@ class DialogAddAutomationElement @state() private _params?: AddAutomationElementDialogParams; - @state() private _group?: string; + @state() private _selectedCollectionIndex?: number; - @state() private _prev?: string; + @state() private _selectedGroup?: string; + + @state() private _tab: "groups" | "blocks" = "groups"; @state() private _filter = ""; @@ -111,19 +134,26 @@ class DialogAddAutomationElement @state() private _domains?: Set; - @query("ha-dialog") private _dialog?: HaDialog; + @state() private _open = true; - private _fullScreen = false; + @state() private _itemsScrolled = false; - @state() private _width?: number; - - @state() private _height?: number; + @state() private _bottomSheetMode = false; @state() private _narrow = false; + @query(".items ha-md-list ha-md-list-item") + private _itemsListFirstElement?: HaMdList; + + @query(".items") + private _itemsListElement?: HTMLDivElement; + + private _fullScreen = false; + + private _removeKeyboardShortcuts?: () => void; + public showDialog(params): void { this._params = params; - this._group = params.group; this.addKeyboardShortcuts(); @@ -137,7 +167,11 @@ class DialogAddAutomationElement "all and (max-width: 450px), all and (max-height: 500px)" ).matches; - this._narrow = matchMedia("(max-width: 870px)").matches; + window.addEventListener("resize", this._updateNarrow); + this._updateNarrow(); + + // prevent view mode switch when resizing window + this._bottomSheetMode = this._narrow; } public closeDialog() { @@ -145,11 +179,13 @@ class DialogAddAutomationElement if (this._params) { fireEvent(this, "dialog-closed", { dialog: this.localName }); } - this._height = undefined; - this._width = undefined; + this._open = true; + this._itemsScrolled = false; + this._bottomSheetMode = false; this._params = undefined; - this._group = undefined; - this._prev = undefined; + this._selectedGroup = undefined; + this._tab = "groups"; + this._selectedCollectionIndex = undefined; this._filter = ""; this._manifests = undefined; this._domains = undefined; @@ -158,13 +194,22 @@ class DialogAddAutomationElement private _getGroups = ( type: AddAutomationElementDialogParams["type"], - group: string | undefined - ): AutomationElementGroup => - group - ? isService(group) - ? {} - : TYPES[type].groups[group].members! - : TYPES[type].groups; + group?: string, + collectionIndex?: number + ): AutomationElementGroup => { + if (group && collectionIndex !== undefined) { + return ( + TYPES[type].collections[collectionIndex].groups[group].members || { + [group]: {}, + } + ); + } + + return TYPES[type].collections.reduce( + (acc, collection) => ({ ...acc, ...collection.groups }), + {} as AutomationElementGroup + ); + }; private _convertToItem = ( key: string, @@ -172,7 +217,6 @@ class DialogAddAutomationElement type: AddAutomationElementDialogParams["type"], localize: LocalizeFunc ): ListItem => ({ - group: Boolean(options.members), key, name: localize( // @ts-ignore @@ -192,20 +236,22 @@ class DialogAddAutomationElement private _getFilteredItems = memoizeOne( ( type: AddAutomationElementDialogParams["type"], - group: string | undefined, filter: string, - domains: Set | undefined, localize: LocalizeFunc, services: HomeAssistant["services"], manifests?: DomainManifestLookup ): ListItem[] => { - const items = this._items(type, group, localize, services, manifests); + const items = this._items(type, localize, services, manifests); const index = this._fuseIndex(items); const fuse = new HaFuse( items, - { ignoreLocation: true, includeScore: true }, + { + ignoreLocation: true, + includeScore: true, + minMatchCharLength: Math.min(2, this._filter.length), + }, index ); @@ -213,26 +259,55 @@ class DialogAddAutomationElement if (results) { return results.map((result) => result.item); } - return this._getGroupItems( - type, - group, - domains, - localize, - services, - manifests + return items; + } + ); + + private _getFilteredBuildingBlocks = memoizeOne( + ( + type: AddAutomationElementDialogParams["type"], + filter: string, + localize: LocalizeFunc + ): ListItem[] => { + const groups = + type === "action" + ? ACTION_BUILDING_BLOCKS_GROUP + : type === "condition" + ? CONDITION_BUILDING_BLOCKS_GROUP + : {}; + + const items = Object.keys(groups).map((key) => + this._convertToItem(key, {}, type, localize) ); + + const index = this._fuseIndexBlock(items); + + const fuse = new HaFuse( + items, + { + ignoreLocation: true, + includeScore: true, + minMatchCharLength: Math.min(2, this._filter.length), + }, + index + ); + + const results = fuse.multiTermsSearch(filter); + if (results) { + return results.map((result) => result.item); + } + return items; } ); private _items = memoizeOne( ( type: AddAutomationElementDialogParams["type"], - group: string | undefined, localize: LocalizeFunc, services: HomeAssistant["services"], manifests?: DomainManifestLookup ): ListItem[] => { - const groups = this._getGroups(type, group); + const groups = this._getGroups(type); const flattenGroups = (grp: AutomationElementGroup) => Object.entries(grp).map(([key, options]) => @@ -243,7 +318,7 @@ class DialogAddAutomationElement const items = flattenGroups(groups).flat(); if (type === "action") { - items.push(...this._services(localize, services, manifests, group)); + items.push(...this._services(localize, services, manifests)); } return items; } @@ -253,10 +328,95 @@ class DialogAddAutomationElement Fuse.createIndex(["key", "name", "description"], items) ); + private _fuseIndexBlock = memoizeOne((items: ListItem[]) => + Fuse.createIndex(["key", "name", "description"], items) + ); + + private _getCollections = memoizeOne( + ( + type: AddAutomationElementDialogParams["type"], + collections: AutomationElementGroupCollection[], + domains: Set | undefined, + localize: LocalizeFunc, + services: HomeAssistant["services"], + manifests?: DomainManifestLookup + ): { + titleKey?: LocalizeKeys; + groups: ListItem[]; + }[] => { + const generatedCollections: any = []; + + collections.forEach((collection) => { + let collectionGroups = Object.entries(collection.groups); + const groups: ListItem[] = []; + + if ( + type === "action" && + Object.keys(collection.groups).some((item) => + ACTION_SERVICE_KEYWORDS.includes(item) + ) + ) { + groups.push( + ...this._serviceGroups( + localize, + services, + manifests, + domains, + collection.groups.serviceGroups + ? undefined + : collection.groups.helpers + ? "helper" + : "other" + ) + ); + + collectionGroups = collectionGroups.filter( + ([key]) => !ACTION_SERVICE_KEYWORDS.includes(key) + ); + } + + groups.push( + ...collectionGroups.map(([key, options]) => + this._convertToItem(key, options, type, localize) + ) + ); + + generatedCollections.push({ + titleKey: collection.titleKey, + groups: groups.sort((a, b) => + stringCompare(a.name, b.name, this.hass.locale.language) + ), + }); + }); + return generatedCollections; + } + ); + + private _getBlockItems = memoizeOne( + ( + type: AddAutomationElementDialogParams["type"], + localize: LocalizeFunc + ): ListItem[] => { + const groups = + type === "action" + ? ACTION_BUILDING_BLOCKS_GROUP + : CONDITION_BUILDING_BLOCKS_GROUP; + + const result = Object.entries(groups).map(([key, options]) => + this._convertToItem(key, options, type, localize) + ); + + return result.sort((a, b) => + stringCompare(a.name, b.name, this.hass.locale.language) + ); + } + ); + private _getGroupItems = memoizeOne( ( type: AddAutomationElementDialogParams["type"], - group: string | undefined, + group: string, + collectionIndex: number, domains: Set | undefined, localize: LocalizeFunc, services: HomeAssistant["services"], @@ -266,14 +426,14 @@ class DialogAddAutomationElement return this._services(localize, services, manifests, group); } - const groups = this._getGroups(type, group); + const groups = this._getGroups(type, group, collectionIndex); const result = Object.entries(groups).map(([key, options]) => this._convertToItem(key, options, type, localize) ); if (type === "action") { - if (!this._group) { + if (!this._selectedGroup) { result.unshift( ...this._serviceGroups( localize, @@ -283,7 +443,7 @@ class DialogAddAutomationElement undefined ) ); - } else if (this._group === "helpers") { + } else if (this._selectedGroup === "helpers") { result.unshift( ...this._serviceGroups( localize, @@ -293,7 +453,7 @@ class DialogAddAutomationElement "helper" ) ); - } else if (this._group === "other") { + } else if (this._selectedGroup === "other") { result.unshift( ...this._serviceGroups( localize, @@ -306,18 +466,9 @@ class DialogAddAutomationElement } } - return result.sort((a, b) => { - if (a.group && b.group) { - return 0; - } - if (a.group && !b.group) { - return 1; - } - if (!a.group && b.group) { - return -1; - } - return stringCompare(a.name, b.name, this.hass.locale.language); - }); + return result.sort((a, b) => + stringCompare(a.name, b.name, this.hass.locale.language) + ); } ); @@ -349,7 +500,6 @@ class DialogAddAutomationElement !["helper", "entity"].includes(manifest?.integration_type || ""))) ) { result.push({ - group: true, icon: html` -
- - ${this._group - ? groupName - : this.hass.localize( - `ui.panel.config.automation.editor.${this._params.type}s.add` - )} - ${this._group && this._group !== this._params.group - ? html`` - : html``} - - -
+
+ + ${this._narrow && this._selectedGroup + ? groupName + : typeTitle} + + ${this._narrow && this._selectedGroup + ? html`${typeTitle}` + : nothing} + ${this._narrow && this._selectedGroup + ? html`` + : html``} + + ${!this._narrow || !this._selectedGroup + ? html` + + ` + : nothing} + ${this._params?.type !== "trigger" && + !this._filter && + (!this._narrow || !this._selectedGroup) + ? html`` + : nothing} +
+
- ${this._params.clipboardItem && - !this._filter && - (!this._group || - items.find((item) => item.key === this._params!.clipboardItem)) + ${this._params!.clipboardItem && !this._filter ? html`
${this.hass.localize( - `ui.panel.config.automation.editor.${this._params.type}s.paste` + `ui.panel.config.automation.editor.${automationElementType}s.paste` )}
${this.hass.localize( // @ts-ignore - `ui.panel.config.automation.editor.${this._params.type}s.type.${this._params.clipboardItem}.label` + `ui.panel.config.automation.editor.${automationElementType}s.type.${this._params.clipboardItem}.label` )}
@@ -611,74 +799,213 @@ class DialogAddAutomationElement slot="start" .path=${mdiContentPaste} > + > ` : nothing} - ${repeat( - items, - (item) => item.key, - (item) => html` - -
${item.name}
-
${item.description}
- ${item.icon - ? html`${item.icon}` - : item.iconPath - ? html`` - : nothing} - ${item.group - ? html`` - : html``} -
+ ${collections.map( + (collection, index) => html` + ${collection.titleKey + ? html`
+ ${this.hass.localize(collection.titleKey)} +
` + : nothing} + ${repeat( + collection.groups, + (item) => item.key, + (item) => html` + +
${item.name}
+ ${item.icon + ? html`${item.icon}` + : item.iconPath + ? html`` + : nothing} +
+ ` + )} ` )} - +
+ ${filteredBlockItems && filteredBlockItems.length + ? this._renderItemList( + this.hass.localize(`ui.panel.config.automation.editor.blocks`), + filteredBlockItems + ) + : nothing} + ${this._tab === "groups" && !this._selectedGroup && !this._filter + ? this.hass.localize( + `ui.panel.config.automation.editor.${automationElementType}s.select` + ) + : !items?.length && + this._filter && + (!filteredBlockItems || !filteredBlockItems.length) + ? html`${this.hass.localize( + `ui.panel.config.automation.editor.${automationElementType}s.empty_search`, + { + term: html`‘${this._filter}’`, + } + )}` + : this._renderItemList( + this.hass.localize( + `ui.panel.config.automation.editor.${automationElementType}s.name` + ), + items + )} +
+ `; } + private _renderItemList(title, items?: ListItem[]) { + if (!items) { + return nothing; + } + + return html` +
+ ${title} +
+ + ${repeat( + items, + (item) => item.key, + (item) => html` + +
${item.name}
+
${item.description}
+ ${item.icon + ? html`${item.icon}` + : item.iconPath + ? html`` + : nothing} + ${item.group + ? html`` + : html``} +
+ ` + )} +
+ `; + } + + protected render() { + if (!this._params) { + return nothing; + } + + if (this._bottomSheetMode) { + return html` + + ${this._renderContent()} + + `; + } + + return html` + + ${this._renderContent()} + + `; + } + + public disconnectedCallback(): void { + super.disconnectedCallback(); + window.removeEventListener("resize", this._updateNarrow); + this._removeSearchKeybindings(); + } + + private _updateNarrow = () => { + this._narrow = + window.matchMedia("(max-width: 870px)").matches || + window.matchMedia("(max-height: 500px)").matches; + }; + + private _close() { + this._open = false; + } + private _back() { - this._dialog!.scrollToPos(0, 0); - if (this._filter) { - this._filter = ""; + this._selectedGroup = undefined; + } + + private _groupSelected(ev) { + const group = ev.currentTarget; + if (this._selectedGroup === group.value) { + this._selectedGroup = undefined; + this._selectedCollectionIndex = undefined; return; } - if (this._prev) { - this._group = this._prev; - this._prev = undefined; - return; - } - this._group = undefined; + this._selectedGroup = group.value; + this._selectedCollectionIndex = ev.currentTarget.index; + requestAnimationFrame(() => { + this._itemsListElement?.scrollTo(0, 0); + }); } private _selected(ev) { - this._dialog!.scrollToPos(0, 0); const item = ev.currentTarget; - if (item.group) { - this._prev = this._group; - this._group = item.value; - return; - } this._params!.add(item.value); this.closeDialog(); } - private _filterChanged(ev) { + private _debounceFilterChanged = debounce( + (ev) => this._filterChanged(ev), + 200 + ); + + private _filterChanged = (ev) => { this._filter = ev.detail.value; - } + }; private _addClipboard = () => { if (this._params?.clipboardItem) { @@ -704,37 +1031,220 @@ class DialogAddAutomationElement }; } + private _switchTab(ev) { + this._tab = ev.detail.value; + } + + @eventOptions({ passive: true }) + private _onItemsScroll(ev) { + const top = ev.target.scrollTop ?? 0; + this._itemsScrolled = top > 0; + } + + private _onSearchFocus(ev) { + this._removeKeyboardShortcuts = tinykeys(ev.target, { + ArrowDown: this._focusSearchList, + }); + } + + private _removeSearchKeybindings() { + this._removeKeyboardShortcuts?.(); + } + + private _focusSearchList = (ev) => { + if (!this._filter || !this._itemsListFirstElement) { + return; + } + + ev.preventDefault(); + this._itemsListFirstElement.focus(); + }; + static get styles(): CSSResultGroup { return [ - haStyle, - haStyleDialog, css` - ha-dialog { - --dialog-content-padding: 0; - --mdc-dialog-max-height: 60vh; - --mdc-dialog-max-height: 60dvh; + ha-bottom-sheet { + --ha-bottom-sheet-height: 90vh; + --ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12)); + --ha-bottom-sheet-max-height: var(--ha-bottom-sheet-height); + --ha-bottom-sheet-max-width: 888px; + --ha-bottom-sheet-padding: var(--ha-space-0); + --ha-bottom-sheet-surface-background: var(--card-background-color); } - @media all and (min-width: 550px) { - ha-dialog { - --mdc-dialog-min-width: 500px; - } - } - ha-icon-next { - width: 24px; - } - ha-md-list { - max-height: 468px; - max-width: 100vw; - --md-list-item-leading-space: 24px; - --md-list-item-trailing-space: 24px; - --md-list-item-supporting-text-font: var(--ha-font-size-s); - } - ha-md-list-item img { - width: 24px; + + ha-wa-dialog { + --dialog-content-padding: var(--ha-space-0); + --ha-dialog-width-md: 888px; + --ha-dialog-min-height: min( + 648px, + calc( + 100vh - max( + var(--safe-area-inset-bottom), + var(--ha-space-4) + ) - max(var(--safe-area-inset-top), var(--ha-space-4)) + ) + ); + --ha-dialog-min-height: min( + 648px, + calc( + 100dvh - max( + var(--safe-area-inset-bottom), + var(--ha-space-4) + ) - max(var(--safe-area-inset-top), var(--ha-space-4)) + ) + ); + --ha-dialog-max-height: var(--ha-dialog-min-height); } + search-input { display: block; - margin: 0 16px; + margin: var(--ha-space-0) var(--ha-space-4); + } + + ha-button-toggle-group { + --ha-button-toggle-group-padding: var(--ha-space-3) var(--ha-space-4) + 0; + } + + .content { + flex: 1; + min-height: 0; + height: 100%; + display: flex; + } + + ha-md-list { + padding: 0; + } + + .items ha-md-list, + .groups { + padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-3)); + } + + .groups { + overflow: auto; + flex: 3; + border-radius: var(--ha-border-radius-xl); + border: 1px solid var(--ha-color-border-neutral-quiet); + margin: var(--ha-space-3); + margin-inline-end: var(--ha-space-0); + --md-list-item-leading-space: var(--ha-space-3); + --md-list-item-trailing-space: var(--md-list-item-leading-space); + --md-list-item-bottom-space: var(--ha-space-1); + --md-list-item-top-space: var(--md-list-item-bottom-space); + --md-list-item-supporting-text-font: var(--ha-font-size-s); + --md-list-item-one-line-container-height: var(--ha-space-10); + } + ha-bottom-sheet .groups { + margin: var(--ha-space-3); + } + .groups .selected { + background-color: var(--ha-color-fill-primary-normal-active); + --md-list-item-label-text-color: var(--primary-color); + --icon-primary-color: var(--primary-color); + } + .groups .selected ha-svg-icon { + color: var(--primary-color); + } + + .collection-title { + background-color: var(--ha-color-fill-neutral-quiet-resting); + padding: var(--ha-space-1) var(--ha-space-2); + font-weight: var(--ha-font-weight-bold); + color: var(--secondary-text-color); + top: 0; + position: sticky; + min-height: var(--ha-space-6); + display: flex; + align-items: center; + z-index: 1; + } + + .items { + display: flex; + flex-direction: column; + overflow: auto; + flex: 7; + } + + ha-wa-dialog .items { + margin-top: var(--ha-space-3); + } + + ha-bottom-sheet .groups { + padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-4)); + } + + .items.hidden, + .groups.hidden { + display: none; + } + .items.blank, + .items.empty-search { + border-radius: var(--ha-border-radius-xl); + background-color: var(--ha-color-surface-default); + align-items: center; + color: var(--ha-color-text-secondary); + padding: var(--ha-space-0); + margin: var(--ha-space-3) var(--ha-space-4) + max(var(--safe-area-inset-bottom), var(--ha-space-3)); + } + + .items ha-md-list { + --md-list-item-two-line-container-height: var(--ha-space-12); + --md-list-item-leading-space: var(--ha-space-3); + --md-list-item-trailing-space: var(--md-list-item-leading-space); + --md-list-item-bottom-space: var(--ha-space-2); + --md-list-item-top-space: var(--md-list-item-bottom-space); + --md-list-item-supporting-text-font: var(--ha-font-size-s); + gap: var(--ha-space-2); + padding: var(--ha-space-0) var(--ha-space-4); + } + .items ha-md-list ha-md-list-item { + border-radius: var(--ha-border-radius-lg); + border: 1px solid var(--ha-color-border-neutral-quiet); + } + + .items.blank { + justify-content: center; + } + .items.empty-search { + padding-top: var(--ha-space-6); + justify-content: start; + } + + .items-title { + position: sticky; + display: flex; + align-items: center; + font-weight: var(--ha-font-weight-medium); + padding-top: var(--ha-space-2); + padding-bottom: var(--ha-space-2); + padding-inline-start: var(--ha-space-8); + padding-inline-end: var(--ha-space-3); + top: 0; + z-index: 1; + background-color: var(--card-background-color); + } + ha-bottom-sheet .items-title { + padding-top: var(--ha-space-3); + } + .items-title.scrolled:first-of-type { + box-shadow: var(--bar-box-shadow); + border-bottom: 1px solid var(--ha-color-border-neutral-quiet); + } + + ha-icon-next { + width: var(--ha-space-6); + } + + ha-md-list-item.paste { + border-bottom: 1px solid var(--ha-color-border-neutral-quiet); + } + + ha-svg-icon.plus { + color: var(--primary-color); } .shortcut-label { display: flex; @@ -746,7 +1256,7 @@ class DialogAddAutomationElement font-size: var(--ha-font-size-s); } .shortcut-label .shortcut { - --mdc-icon-size: 12px; + --mdc-icon-size: var(--ha-space-3); display: inline-flex; flex-direction: row; align-items: center; diff --git a/src/panels/config/automation/condition/ha-automation-condition.ts b/src/panels/config/automation/condition/ha-automation-condition.ts index c7cb37057f..755abe29da 100644 --- a/src/panels/config/automation/condition/ha-automation-condition.ts +++ b/src/panels/config/automation/condition/ha-automation-condition.ts @@ -214,17 +214,6 @@ export default class HaAutomationCondition extends LitElement { "ui.panel.config.automation.editor.conditions.add" )} - - - ${this.hass.localize( - "ui.panel.config.automation.editor.conditions.add_building_block" - )} - @@ -242,15 +231,6 @@ export default class HaAutomationCondition extends LitElement { }); } - private _addConditionBuildingBlockDialog() { - showAddAutomationElementDialog(this, { - type: "condition", - add: this._addCondition, - clipboardItem: this._clipboard?.condition?.condition, - group: "building_blocks", - }); - } - private _addCondition = (value) => { let conditions: Condition[]; if (value === PASTE_VALUE) { diff --git a/src/panels/config/automation/show-add-automation-element-dialog.ts b/src/panels/config/automation/show-add-automation-element-dialog.ts index 99c014c748..90a4ab417c 100644 --- a/src/panels/config/automation/show-add-automation-element-dialog.ts +++ b/src/panels/config/automation/show-add-automation-element-dialog.ts @@ -1,45 +1,11 @@ import { fireEvent } from "../../../common/dom/fire_event"; -import type { ACTION_GROUPS } from "../../../data/action"; -import type { ActionType } from "../../../data/script"; export const PASTE_VALUE = "__paste__"; -// These will be replaced with the correct action -export const VIRTUAL_ACTIONS: Record< - keyof (typeof ACTION_GROUPS)["building_blocks"]["members"], - ActionType -> = { - repeat_count: { - repeat: { - count: 2, - sequence: [], - }, - }, - repeat_while: { - repeat: { - while: [], - sequence: [], - }, - }, - repeat_until: { - repeat: { - until: [], - sequence: [], - }, - }, - repeat_for_each: { - repeat: { - for_each: {}, - sequence: [], - }, - }, -} as const; - export interface AddAutomationElementDialogParams { type: "trigger" | "condition" | "action"; add: (key: string) => void; clipboardItem: string | undefined; - group?: string; } const loadDialog = () => import("./add-automation-element-dialog"); diff --git a/src/translations/en.json b/src/translations/en.json index 7eed0ebf66..cd87c5f678 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -3918,7 +3918,6 @@ "edit_yaml": "Edit in YAML", "edit_ui": "Edit in visual editor", "copy_to_clipboard": "Copy to clipboard", - "search_in": "Search · {group}", "unknown_entity": "unknown entity", "edit_unknown_device": "Editor not available for unknown device", "switch_ui_yaml_error": "There are currently YAML errors in the automation, and it cannot be parsed. Switching to UI mode may cause pending changes to be lost. Press cancel to correct any errors before proceeding to prevent loss of pending changes, or continue if you are sure.", @@ -3933,6 +3932,7 @@ "item_pasted": "{item} pasted", "ctrl": "Ctrl", "del": "Del", + "blocks": "Blocks", "triggers": { "name": "Triggers", "header": "When", @@ -3940,7 +3940,7 @@ "learn_more": "Learn more about triggers", "triggered": "Triggered", "add": "Add trigger", - "search": "Search trigger", + "empty_search": "No triggers found for {term}", "id": "Trigger ID", "id_helper": "Helps identify each run based on which trigger fired.", "optional": "Optional", @@ -3961,14 +3961,16 @@ "trigger": "Trigger", "copied_to_clipboard": "Trigger copied to clipboard", "cut_to_clipboard": "Trigger cut to clipboard", + "select": "Select a trigger", "groups": { + "device": { + "label": "Device" + }, "entity": { - "label": "Entity", - "description": "When something happens to an entity." + "label": "Entity" }, "time_location": { - "label": "Time and location", - "description": "When someone enters or leaves a zone, or at a specific time." + "label": "Time and location" }, "other": { "label": "Other triggers" @@ -4201,7 +4203,7 @@ "description": "All conditions added here need to be satisfied for the automation to run. A condition can be satisfied or not at any given time, for example: ''If {user} is home''. You can use building blocks to create more complex conditions.", "learn_more": "Learn more about conditions", "add": "Add condition", - "search": "Search condition", + "empty_search": "No conditions and blocks found for {term}", "add_building_block": "Add building block", "test": "Test", "testing_error": "Condition did not pass", @@ -4223,21 +4225,22 @@ "condition": "Condition", "copied_to_clipboard": "Condition copied to clipboard", "cut_to_clipboard": "Condition cut to clipboard", + "select": "Select a condition", "groups": { + "device": { + "label": "Device" + }, "entity": { - "label": "Entity", - "description": "If an entity is in a specific state." + "label": "Entity" }, "time_location": { - "label": "Time and location", - "description": "If someone is in a zone or if the current time is before or after a specified time." + "label": "Time and location" }, "other": { "label": "Other conditions" }, "building_blocks": { - "label": "Building blocks", - "description": "Build more complex conditions." + "label": "Building blocks" } }, "type": { @@ -4368,7 +4371,7 @@ "description": "All actions added here will be performed in sequence when the automation runs. An action usually controls one of your areas, devices, or entities, for example: 'Turn on the lights'. You can use building blocks to create more complex sequences of actions.", "learn_more": "Learn more about actions", "add": "Add action", - "search": "Search action", + "empty_search": "No actions and blocks found for {term}", "add_building_block": "Add building block", "invalid_action": "Invalid action", "run": "Run action", @@ -4392,7 +4395,11 @@ "action": "Action", "copied_to_clipboard": "Action copied to clipboard", "cut_to_clipboard": "Action cut to clipboard", + "select": "Select an action", "groups": { + "device_id": { + "label": "Device" + }, "helpers": { "label": "Helpers" }, @@ -4400,8 +4407,7 @@ "label": "Other actions" }, "building_blocks": { - "label": "Building blocks", - "description": "Build more complex sequences of actions." + "label": "Building blocks" } }, "type": {