mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-16 13:56:35 +00:00
Add optional interaction for cards (hold, double tap) in the UI (#24754)
This commit is contained in:
parent
1cb71ed379
commit
1a076061da
166
src/components/ha-form/ha-form-optional_actions.ts
Normal file
166
src/components/ha-form/ha-form-optional_actions.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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) =>
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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[]
|
||||
|
@ -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,7 +183,18 @@ export class HuiTileCardEditor
|
||||
},
|
||||
},
|
||||
},
|
||||
...displayActions.map((action) => ({
|
||||
{
|
||||
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: {
|
||||
@ -228,6 +202,7 @@ export class HuiTileCardEditor
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
] 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(
|
||||
|
@ -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[]
|
||||
|
@ -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),
|
||||
|
@ -23,4 +23,6 @@ export interface EntityHeadingBadgeConfig extends LovelaceHeadingBadgeConfig {
|
||||
show_icon?: boolean;
|
||||
color?: string;
|
||||
tap_action?: ActionConfig;
|
||||
hold_action?: ActionConfig;
|
||||
double_tap_action?: ActionConfig;
|
||||
}
|
||||
|
@ -1131,6 +1131,9 @@
|
||||
"items-display-editor": {
|
||||
"show": "Show {label}",
|
||||
"hide": "Hide {label}"
|
||||
},
|
||||
"form-optional-actions": {
|
||||
"add": "Add interaction"
|
||||
}
|
||||
},
|
||||
"dialogs": {
|
||||
|
Loading…
x
Reference in New Issue
Block a user