Add optional interaction for cards (hold, double tap) in the UI (#24754)

This commit is contained in:
Paul Bottein 2025-03-26 09:42:10 +01:00 committed by GitHub
parent 1cb71ed379
commit 1a076061da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 257 additions and 73 deletions

View File

@ -0,0 +1,166 @@
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { stopPropagation } from "../../common/dom/stop_propagation";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { HomeAssistant } from "../../types";
import "./ha-form";
import type {
HaFormOptionalActionsSchema,
HaFormDataContainer,
HaFormElement,
HaFormSchema,
} from "./types";
const NO_ACTIONS = [];
@customElement("ha-form-optional_actions")
export class HaFormOptionalActions extends LitElement implements HaFormElement {
@property({ attribute: false }) public localize?: LocalizeFunc;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public data!: HaFormDataContainer;
@property({ attribute: false }) public schema!: HaFormOptionalActionsSchema;
@property({ type: Boolean }) public disabled = false;
@property({ attribute: false }) public computeLabel?: (
schema: HaFormSchema,
data?: HaFormDataContainer
) => string;
@property({ attribute: false }) public computeHelper?: (
schema: HaFormSchema
) => string;
@property({ attribute: false }) public localizeValue?: (
key: string
) => string;
@state() private _displayActions?: string[];
public async focus() {
await this.updateComplete;
this.renderRoot.querySelector("ha-form")?.focus();
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (changedProps.has("data")) {
const displayActions = this._displayActions ?? NO_ACTIONS;
const hiddenActions = this._hiddenActions(
this.schema.schema,
displayActions
);
this._displayActions = [
...displayActions,
...hiddenActions.filter((name) => name in this.data),
];
}
}
private _hiddenActions = memoizeOne(
(schema: readonly HaFormSchema[], displayActions: string[]): string[] =>
schema
.map((item) => item.name)
.filter((name) => !displayActions.includes(name))
);
private _displaySchema = memoizeOne(
(
schema: readonly HaFormSchema[],
displayActions: string[]
): HaFormSchema[] =>
schema.filter((item) => displayActions.includes(item.name))
);
public render(): TemplateResult {
const displayActions = this._displayActions ?? NO_ACTIONS;
const schema = this._displaySchema(
this.schema.schema,
this._displayActions ?? []
);
const hiddenActions = this._hiddenActions(
this.schema.schema,
displayActions
);
const schemaMap = new Map<string, HaFormSchema>(
this.computeLabel
? this.schema.schema.map((item) => [item.name, item])
: []
);
return html`
${schema.length > 0
? html`
<ha-form
.hass=${this.hass}
.data=${this.data}
.schema=${schema}
.disabled=${this.disabled}
.computeLabel=${this.computeLabel}
.computeHelper=${this.computeHelper}
.localizeValue=${this.localizeValue}
></ha-form>
`
: nothing}
${hiddenActions.length > 0
? html`
<ha-button-menu
@action=${this._handleAddAction}
fixed
@closed=${stopPropagation}
>
<ha-button slot="trigger">
${this.localize?.("ui.components.form-optional-actions.add") ||
"Add interaction"}
</ha-button>
${hiddenActions.map((action) => {
const actionSchema = schemaMap.get(action);
return html`
<ha-list-item>
${this.computeLabel && actionSchema
? this.computeLabel(actionSchema)
: action}
</ha-list-item>
`;
})}
</ha-button-menu>
`
: nothing}
`;
}
private _handleAddAction(ev: CustomEvent) {
const hiddenActions = this._hiddenActions(
this.schema.schema,
this._displayActions ?? NO_ACTIONS
);
const index = ev.detail.index;
const action = hiddenActions[index];
this._displayActions = [...(this._displayActions ?? []), action];
}
static styles = css`
:host {
display: flex !important;
flex-direction: column;
gap: 24px;
}
:host ha-form {
display: block;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-form-optional_actions": HaFormOptionalActions;
}
}

View File

@ -20,6 +20,7 @@ const LOAD_ELEMENTS = {
import("./ha-form-positive_time_period_dict"),
select: () => import("./ha-form-select"),
string: () => import("./ha-form-string"),
optional_actions: () => import("./ha-form-optional_actions"),
};
const getValue = (obj, item) =>

View File

@ -13,7 +13,8 @@ export type HaFormSchema =
| HaFormTimeSchema
| HaFormSelector
| HaFormGridSchema
| HaFormExpandableSchema;
| HaFormExpandableSchema
| HaFormOptionalActionsSchema;
export interface HaFormBaseSchema {
name: string;
@ -47,6 +48,12 @@ export interface HaFormExpandableSchema extends HaFormBaseSchema {
schema: readonly HaFormSchema[];
}
export interface HaFormOptionalActionsSchema extends HaFormBaseSchema {
type: "optional_actions";
flatten?: boolean;
schema: readonly HaFormSchema[];
}
export interface HaFormSelector extends HaFormBaseSchema {
type?: never;
selector: Selector;
@ -100,7 +107,10 @@ export interface HaFormTimeSchema extends HaFormBaseSchema {
export type SchemaUnion<
SchemaArray extends readonly HaFormSchema[],
Schema = SchemaArray[number],
> = Schema extends HaFormGridSchema | HaFormExpandableSchema
> = Schema extends
| HaFormGridSchema
| HaFormExpandableSchema
| HaFormOptionalActionsSchema
? SchemaUnion<Schema["schema"]> | Schema
: Schema;

View File

@ -48,6 +48,8 @@ const badgeConfigStruct = assign(
show_icon: optional(boolean()),
show_entity_picture: optional(boolean()),
tap_action: optional(actionConfigStruct),
hold_action: optional(actionConfigStruct),
double_tap_action: optional(actionConfigStruct),
image: optional(string()), // For old badge config support
})
);
@ -169,6 +171,21 @@ export class HuiEntityBadgeEditor
},
},
},
{
name: "",
type: "optional_actions",
flatten: true,
schema: (["hold_action", "double_tap_action"] as const).map(
(action) => ({
name: action,
selector: {
ui_action: {
default_action: "none" as const,
},
},
})
),
},
],
},
] as const satisfies readonly HaFormSchema[]

View File

@ -1,4 +1,5 @@
import { mdiGestureTap, mdiListBox, mdiTextShort } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
@ -14,7 +15,6 @@ import {
string,
union,
} from "superstruct";
import type { HassEntity } from "home-assistant-js-websocket";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
@ -61,15 +61,6 @@ const cardConfigStruct = assign(
})
);
const ADVANCED_ACTIONS = [
"hold_action",
"icon_hold_action",
"double_tap_action",
"icon_double_tap_action",
] as const;
type AdvancedActions = (typeof ADVANCED_ACTIONS)[number];
@customElement("hui-tile-card-editor")
export class HuiTileCardEditor
extends LitElement
@ -79,44 +70,16 @@ export class HuiTileCardEditor
@state() private _config?: TileCardConfig;
@state() private _displayActions?: AdvancedActions[];
public setConfig(config: TileCardConfig): void {
assert(config, cardConfigStruct);
this._config = config;
if (this._displayActions) return;
this._setDisplayActions(config);
}
private _setDisplayActions(config: TileCardConfig) {
this._displayActions = ADVANCED_ACTIONS.filter(
(action) => action in config
);
}
private _resetConfiguredActions() {
this._displayActions = undefined;
}
connectedCallback(): void {
super.connectedCallback();
if (this._config) {
this._setDisplayActions(this._config);
}
}
disconnectedCallback(): void {
super.disconnectedCallback();
this._resetConfiguredActions();
}
private _schema = memoizeOne(
(
localize: LocalizeFunc,
entityId: string | undefined,
hideState: boolean,
displayActions: AdvancedActions[] = []
hideState: boolean
) =>
[
{ name: "entity", selector: { entity: {} } },
@ -220,14 +183,26 @@ export class HuiTileCardEditor
},
},
},
...displayActions.map((action) => ({
name: action,
selector: {
ui_action: {
default_action: "none" as const,
{
name: "",
type: "optional_actions",
flatten: true,
schema: (
[
"hold_action",
"icon_hold_action",
"double_tap_action",
"icon_double_tap_action",
] as const
).map((action) => ({
name: action,
selector: {
ui_action: {
default_action: "none" as const,
},
},
},
})),
})),
},
],
},
] as const satisfies readonly HaFormSchema[]
@ -278,8 +253,7 @@ export class HuiTileCardEditor
const schema = this._schema(
this.hass.localize,
entityId,
this._config.hide_state ?? false,
this._displayActions
this._config.hide_state ?? false
);
const featuresSchema = this._featuresSchema(

View File

@ -156,6 +156,21 @@ export class HuiHeadingEntityEditor
},
},
},
{
name: "",
type: "optional_actions",
flatten: true,
schema: (["hold_action", "double_tap_action"] as const).map(
(action) => ({
name: action,
selector: {
ui_action: {
default_action: "none" as const,
},
},
})
),
},
],
},
] as const satisfies readonly HaFormSchema[]

View File

@ -25,6 +25,14 @@ import type {
} from "../types";
import type { EntityHeadingBadgeConfig } from "./types";
const DEFAULT_ACTIONS: Pick<
EntityHeadingBadgeConfig,
"tap_action" | "hold_action" | "double_tap_action"
> = {
tap_action: { action: "none" },
hold_action: { action: "none" },
double_tap_action: { action: "none" },
};
@customElement("hui-entity-heading-badge")
export class HuiEntityHeadingBadge
extends LitElement
@ -46,33 +54,21 @@ export class HuiEntityHeadingBadge
public setConfig(config): void {
this._config = {
...DEFAULT_CONFIG,
tap_action: {
action: "none",
},
hold_action: {
action: "none",
},
double_tap_action: {
action: "none",
},
...DEFAULT_ACTIONS,
...config,
};
}
get hasAction() {
return (
hasAction(this._config?.tap_action) ||
hasAction(this._config?.hold_action) ||
hasAction(this._config?.double_tap_action)
);
}
private _handleAction(ev: ActionHandlerEvent) {
const config: EntityHeadingBadgeConfig = {
tap_action: {
action: "none",
},
hold_action: {
action: "none",
},
double_tap_action: {
action: "none",
},
...this._config!,
};
handleAction(this, this.hass!, config, ev.detail.action!);
handleAction(this, this.hass!, this._config!, ev.detail.action!);
}
private _computeStateColor = memoizeOne(
@ -145,7 +141,7 @@ export class HuiEntityHeadingBadge
return html`
<ha-heading-badge
.type=${hasAction(config.tap_action) ? "button" : "text"}
.type=${this.hasAction ? "button" : "badge"}
@action=${this._handleAction}
.actionHandler=${actionHandler({
hasHold: hasAction(this._config!.hold_action),

View File

@ -23,4 +23,6 @@ export interface EntityHeadingBadgeConfig extends LovelaceHeadingBadgeConfig {
show_icon?: boolean;
color?: string;
tap_action?: ActionConfig;
hold_action?: ActionConfig;
double_tap_action?: ActionConfig;
}

View File

@ -1131,6 +1131,9 @@
"items-display-editor": {
"show": "Show {label}",
"hide": "Hide {label}"
},
"form-optional-actions": {
"add": "Add interaction"
}
},
"dialogs": {