mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-18 14:56:37 +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"),
|
import("./ha-form-positive_time_period_dict"),
|
||||||
select: () => import("./ha-form-select"),
|
select: () => import("./ha-form-select"),
|
||||||
string: () => import("./ha-form-string"),
|
string: () => import("./ha-form-string"),
|
||||||
|
optional_actions: () => import("./ha-form-optional_actions"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const getValue = (obj, item) =>
|
const getValue = (obj, item) =>
|
||||||
|
@ -13,7 +13,8 @@ export type HaFormSchema =
|
|||||||
| HaFormTimeSchema
|
| HaFormTimeSchema
|
||||||
| HaFormSelector
|
| HaFormSelector
|
||||||
| HaFormGridSchema
|
| HaFormGridSchema
|
||||||
| HaFormExpandableSchema;
|
| HaFormExpandableSchema
|
||||||
|
| HaFormOptionalActionsSchema;
|
||||||
|
|
||||||
export interface HaFormBaseSchema {
|
export interface HaFormBaseSchema {
|
||||||
name: string;
|
name: string;
|
||||||
@ -47,6 +48,12 @@ export interface HaFormExpandableSchema extends HaFormBaseSchema {
|
|||||||
schema: readonly HaFormSchema[];
|
schema: readonly HaFormSchema[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HaFormOptionalActionsSchema extends HaFormBaseSchema {
|
||||||
|
type: "optional_actions";
|
||||||
|
flatten?: boolean;
|
||||||
|
schema: readonly HaFormSchema[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface HaFormSelector extends HaFormBaseSchema {
|
export interface HaFormSelector extends HaFormBaseSchema {
|
||||||
type?: never;
|
type?: never;
|
||||||
selector: Selector;
|
selector: Selector;
|
||||||
@ -100,7 +107,10 @@ export interface HaFormTimeSchema extends HaFormBaseSchema {
|
|||||||
export type SchemaUnion<
|
export type SchemaUnion<
|
||||||
SchemaArray extends readonly HaFormSchema[],
|
SchemaArray extends readonly HaFormSchema[],
|
||||||
Schema = SchemaArray[number],
|
Schema = SchemaArray[number],
|
||||||
> = Schema extends HaFormGridSchema | HaFormExpandableSchema
|
> = Schema extends
|
||||||
|
| HaFormGridSchema
|
||||||
|
| HaFormExpandableSchema
|
||||||
|
| HaFormOptionalActionsSchema
|
||||||
? SchemaUnion<Schema["schema"]> | Schema
|
? SchemaUnion<Schema["schema"]> | Schema
|
||||||
: Schema;
|
: Schema;
|
||||||
|
|
||||||
|
@ -48,6 +48,8 @@ const badgeConfigStruct = assign(
|
|||||||
show_icon: optional(boolean()),
|
show_icon: optional(boolean()),
|
||||||
show_entity_picture: optional(boolean()),
|
show_entity_picture: optional(boolean()),
|
||||||
tap_action: optional(actionConfigStruct),
|
tap_action: optional(actionConfigStruct),
|
||||||
|
hold_action: optional(actionConfigStruct),
|
||||||
|
double_tap_action: optional(actionConfigStruct),
|
||||||
image: optional(string()), // For old badge config support
|
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[]
|
] as const satisfies readonly HaFormSchema[]
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { mdiGestureTap, mdiListBox, mdiTextShort } from "@mdi/js";
|
import { mdiGestureTap, mdiListBox, mdiTextShort } from "@mdi/js";
|
||||||
|
import type { HassEntity } from "home-assistant-js-websocket";
|
||||||
import { LitElement, css, html, nothing } from "lit";
|
import { LitElement, css, html, nothing } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators";
|
import { customElement, property, state } from "lit/decorators";
|
||||||
import memoizeOne from "memoize-one";
|
import memoizeOne from "memoize-one";
|
||||||
@ -14,7 +15,6 @@ import {
|
|||||||
string,
|
string,
|
||||||
union,
|
union,
|
||||||
} from "superstruct";
|
} from "superstruct";
|
||||||
import type { HassEntity } from "home-assistant-js-websocket";
|
|
||||||
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
|
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
|
||||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
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")
|
@customElement("hui-tile-card-editor")
|
||||||
export class HuiTileCardEditor
|
export class HuiTileCardEditor
|
||||||
extends LitElement
|
extends LitElement
|
||||||
@ -79,44 +70,16 @@ export class HuiTileCardEditor
|
|||||||
|
|
||||||
@state() private _config?: TileCardConfig;
|
@state() private _config?: TileCardConfig;
|
||||||
|
|
||||||
@state() private _displayActions?: AdvancedActions[];
|
|
||||||
|
|
||||||
public setConfig(config: TileCardConfig): void {
|
public setConfig(config: TileCardConfig): void {
|
||||||
assert(config, cardConfigStruct);
|
assert(config, cardConfigStruct);
|
||||||
this._config = config;
|
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(
|
private _schema = memoizeOne(
|
||||||
(
|
(
|
||||||
localize: LocalizeFunc,
|
localize: LocalizeFunc,
|
||||||
entityId: string | undefined,
|
entityId: string | undefined,
|
||||||
hideState: boolean,
|
hideState: boolean
|
||||||
displayActions: AdvancedActions[] = []
|
|
||||||
) =>
|
) =>
|
||||||
[
|
[
|
||||||
{ name: "entity", selector: { entity: {} } },
|
{ name: "entity", selector: { entity: {} } },
|
||||||
@ -220,14 +183,26 @@ export class HuiTileCardEditor
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...displayActions.map((action) => ({
|
{
|
||||||
name: action,
|
name: "",
|
||||||
selector: {
|
type: "optional_actions",
|
||||||
ui_action: {
|
flatten: true,
|
||||||
default_action: "none" as const,
|
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[]
|
] as const satisfies readonly HaFormSchema[]
|
||||||
@ -278,8 +253,7 @@ export class HuiTileCardEditor
|
|||||||
const schema = this._schema(
|
const schema = this._schema(
|
||||||
this.hass.localize,
|
this.hass.localize,
|
||||||
entityId,
|
entityId,
|
||||||
this._config.hide_state ?? false,
|
this._config.hide_state ?? false
|
||||||
this._displayActions
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const featuresSchema = this._featuresSchema(
|
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[]
|
] as const satisfies readonly HaFormSchema[]
|
||||||
|
@ -25,6 +25,14 @@ import type {
|
|||||||
} from "../types";
|
} from "../types";
|
||||||
import type { EntityHeadingBadgeConfig } 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")
|
@customElement("hui-entity-heading-badge")
|
||||||
export class HuiEntityHeadingBadge
|
export class HuiEntityHeadingBadge
|
||||||
extends LitElement
|
extends LitElement
|
||||||
@ -46,33 +54,21 @@ export class HuiEntityHeadingBadge
|
|||||||
public setConfig(config): void {
|
public setConfig(config): void {
|
||||||
this._config = {
|
this._config = {
|
||||||
...DEFAULT_CONFIG,
|
...DEFAULT_CONFIG,
|
||||||
tap_action: {
|
...DEFAULT_ACTIONS,
|
||||||
action: "none",
|
|
||||||
},
|
|
||||||
hold_action: {
|
|
||||||
action: "none",
|
|
||||||
},
|
|
||||||
double_tap_action: {
|
|
||||||
action: "none",
|
|
||||||
},
|
|
||||||
...config,
|
...config,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get hasAction() {
|
||||||
|
return (
|
||||||
|
hasAction(this._config?.tap_action) ||
|
||||||
|
hasAction(this._config?.hold_action) ||
|
||||||
|
hasAction(this._config?.double_tap_action)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private _handleAction(ev: ActionHandlerEvent) {
|
private _handleAction(ev: ActionHandlerEvent) {
|
||||||
const config: EntityHeadingBadgeConfig = {
|
handleAction(this, this.hass!, this._config!, ev.detail.action!);
|
||||||
tap_action: {
|
|
||||||
action: "none",
|
|
||||||
},
|
|
||||||
hold_action: {
|
|
||||||
action: "none",
|
|
||||||
},
|
|
||||||
double_tap_action: {
|
|
||||||
action: "none",
|
|
||||||
},
|
|
||||||
...this._config!,
|
|
||||||
};
|
|
||||||
handleAction(this, this.hass!, config, ev.detail.action!);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _computeStateColor = memoizeOne(
|
private _computeStateColor = memoizeOne(
|
||||||
@ -145,7 +141,7 @@ export class HuiEntityHeadingBadge
|
|||||||
|
|
||||||
return html`
|
return html`
|
||||||
<ha-heading-badge
|
<ha-heading-badge
|
||||||
.type=${hasAction(config.tap_action) ? "button" : "text"}
|
.type=${this.hasAction ? "button" : "badge"}
|
||||||
@action=${this._handleAction}
|
@action=${this._handleAction}
|
||||||
.actionHandler=${actionHandler({
|
.actionHandler=${actionHandler({
|
||||||
hasHold: hasAction(this._config!.hold_action),
|
hasHold: hasAction(this._config!.hold_action),
|
||||||
|
@ -23,4 +23,6 @@ export interface EntityHeadingBadgeConfig extends LovelaceHeadingBadgeConfig {
|
|||||||
show_icon?: boolean;
|
show_icon?: boolean;
|
||||||
color?: string;
|
color?: string;
|
||||||
tap_action?: ActionConfig;
|
tap_action?: ActionConfig;
|
||||||
|
hold_action?: ActionConfig;
|
||||||
|
double_tap_action?: ActionConfig;
|
||||||
}
|
}
|
||||||
|
@ -1131,6 +1131,9 @@
|
|||||||
"items-display-editor": {
|
"items-display-editor": {
|
||||||
"show": "Show {label}",
|
"show": "Show {label}",
|
||||||
"hide": "Hide {label}"
|
"hide": "Hide {label}"
|
||||||
|
},
|
||||||
|
"form-optional-actions": {
|
||||||
|
"add": "Add interaction"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dialogs": {
|
"dialogs": {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user