Improve tile card interactions (#24175)

* Use none instead of more info for icon

* Improve tile icon accessibility

* Remove background shape for tile card icon when no action

* Add hover opacity

* Fix wrong type

* Remove padding around icon and increase hover opacity
This commit is contained in:
Paul Bottein 2025-02-12 10:49:31 +01:00 committed by GitHub
parent f25dac7f68
commit 10abaa538d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 105 additions and 141 deletions

View File

@ -1,25 +1,81 @@
import type { TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import { LitElement, css, html } from "lit"; import { LitElement, css, html } from "lit";
import { customElement } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import "../ha-icon"; import "../ha-icon";
import "../ha-svg-icon"; import "../ha-svg-icon";
import { classMap } from "lit/directives/class-map";
export type TileIconImageStyle = "square" | "rounded-square" | "circle";
export const DEFAULT_TILE_ICON_BORDER_STYLE = "circle";
@customElement("ha-tile-icon") @customElement("ha-tile-icon")
export class HaTileIcon extends LitElement { export class HaTileIcon extends LitElement {
@property({ type: Boolean, reflect: true })
public interactive = false;
@property({ attribute: "border-style", type: String })
public imageStyle?: TileIconImageStyle;
@property({ attribute: false })
public imageUrl?: string;
protected render(): TemplateResult { protected render(): TemplateResult {
return html` if (this.imageUrl) {
<div class="shape"> const imageStyle = this.imageStyle || DEFAULT_TILE_ICON_BORDER_STYLE;
return html`
<div class="container ${classMap({ [imageStyle]: this.imageUrl })}">
<img alt="" src=${this.imageUrl} />
</div>
<slot></slot> <slot></slot>
`;
}
return html`
<div class="container ${this.interactive ? "background" : ""}">
<slot name="icon"></slot>
</div> </div>
<slot></slot>
`; `;
} }
static styles = css` static styles = css`
:host { :host {
--tile-icon-color: var(--disabled-color); --tile-icon-color: var(--disabled-color);
--mdc-icon-size: 22px; --tile-icon-opacity: 0.2;
--tile-icon-hover-opacity: 0.35;
--mdc-icon-size: 24px;
position: relative;
user-select: none;
transition: transform 180ms ease-in-out;
} }
.shape::before { :host([interactive]:active) {
transform: scale(1.2);
}
:host([interactive]:hover) {
--tile-icon-opacity: var(--tile-icon-hover-opacity);
}
.container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 18px;
overflow: hidden;
transition: box-shadow 180ms ease-in-out;
}
:host([interactive]:focus-visible) .container {
box-shadow: 0 0 0 2px var(--tile-icon-color);
}
.container.rounded-square {
border-radius: 8px;
}
.container.square {
border-radius: 0;
}
.container.background::before {
content: ""; content: "";
position: absolute; position: absolute;
top: 0; top: 0;
@ -27,24 +83,21 @@ export class HaTileIcon extends LitElement {
height: 100%; height: 100%;
width: 100%; width: 100%;
background-color: var(--tile-icon-color); background-color: var(--tile-icon-color);
transition: background-color 180ms ease-in-out; transition:
opacity: 0.2; background-color 180ms ease-in-out,
opacity 180ms ease-in-out;
opacity: var(--tile-icon-opacity);
} }
.shape { .container ::slotted([slot="icon"]) {
position: relative;
width: 36px;
height: 36px;
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
transition: color 180ms ease-in-out;
overflow: hidden;
}
.shape ::slotted(*) {
display: flex; display: flex;
color: var(--tile-icon-color); color: var(--tile-icon-color);
transition: color 180ms ease-in-out; transition: color 180ms ease-in-out;
pointer-events: none;
}
.container img {
width: 100%;
height: 100%;
object-fit: cover;
} }
`; `;
} }

View File

@ -1,53 +0,0 @@
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
export type TileImageStyle = "square" | "rounded-square" | "circle";
@customElement("ha-tile-image")
export class HaTileImage extends LitElement {
@property({ attribute: false }) public imageUrl?: string;
@property({ attribute: false }) public imageAlt?: string;
@property({ attribute: false }) public imageStyle: TileImageStyle = "circle";
protected render() {
return html`
<div class="image ${this.imageStyle}">
${this.imageUrl
? html`<img alt=${ifDefined(this.imageAlt)} src=${this.imageUrl} />`
: nothing}
</div>
`;
}
static styles = css`
.image {
position: relative;
width: 36px;
height: 36px;
border-radius: 18px;
display: flex;
flex: none;
align-items: center;
justify-content: center;
overflow: hidden;
}
.image.rounded-square {
border-radius: 8%;
}
.image.square {
border-radius: 0;
}
.image img {
width: 100%;
height: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-tile-image": HaTileImage;
}
}

View File

@ -18,8 +18,7 @@ import "../../../components/ha-state-icon";
import "../../../components/ha-svg-icon"; import "../../../components/ha-svg-icon";
import "../../../components/tile/ha-tile-badge"; import "../../../components/tile/ha-tile-badge";
import "../../../components/tile/ha-tile-icon"; import "../../../components/tile/ha-tile-icon";
import "../../../components/tile/ha-tile-image"; import type { TileIconImageStyle } from "../../../components/tile/ha-tile-icon";
import type { TileImageStyle } from "../../../components/tile/ha-tile-image";
import "../../../components/tile/ha-tile-info"; import "../../../components/tile/ha-tile-info";
import { cameraUrlWithWidthHeight } from "../../../data/camera"; import { cameraUrlWithWidthHeight } from "../../../data/camera";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler"; import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
@ -36,7 +35,7 @@ import type {
LovelaceGridOptions, LovelaceGridOptions,
} from "../types"; } from "../types";
import { renderTileBadge } from "./tile/badges/tile-badge"; import { renderTileBadge } from "./tile/badges/tile-badge";
import type { ThermostatCardConfig, TileCardConfig } from "./types"; import type { TileCardConfig } from "./types";
export const getEntityDefaultTileIconAction = (entityId: string) => { export const getEntityDefaultTileIconAction = (entityId: string) => {
const domain = computeDomain(entityId); const domain = computeDomain(entityId);
@ -44,10 +43,10 @@ export const getEntityDefaultTileIconAction = (entityId: string) => {
DOMAINS_TOGGLE.has(domain) || DOMAINS_TOGGLE.has(domain) ||
["button", "input_button", "scene"].includes(domain); ["button", "input_button", "scene"].includes(domain);
return supportsIconAction ? "toggle" : "more-info"; return supportsIconAction ? "toggle" : "none";
}; };
const DOMAIN_IMAGE_STYLE: Record<string, TileImageStyle> = { const DOMAIN_IMAGE_SHAPE: Record<string, TileIconImageStyle> = {
update: "square", update: "square",
media_player: "rounded-square", media_player: "rounded-square",
}; };
@ -84,7 +83,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
@state() private _config?: TileCardConfig; @state() private _config?: TileCardConfig;
public setConfig(config: ThermostatCardConfig): void { public setConfig(config: TileCardConfig): void {
if (!config.entity) { if (!config.entity) {
throw new Error("Specify an entity"); throw new Error("Specify an entity");
} }
@ -196,7 +195,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
} }
); );
get hasCardAction() { private get _hasCardAction() {
return ( return (
!this._config?.tap_action || !this._config?.tap_action ||
hasAction(this._config?.tap_action) || hasAction(this._config?.tap_action) ||
@ -205,7 +204,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
); );
} }
get hasIconAction() { private get _hasIconAction() {
return ( return (
!this._config?.icon_tap_action || hasAction(this._config?.icon_tap_action) !this._config?.icon_tap_action || hasAction(this._config?.icon_tap_action)
); );
@ -224,14 +223,12 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
return html` return html`
<ha-card> <ha-card>
<div class="content ${classMap(contentClasses)}"> <div class="content ${classMap(contentClasses)}">
<div class="icon-container"> <ha-tile-icon>
<ha-tile-icon> <ha-svg-icon slot="icon" .path=${mdiHelp}></ha-svg-icon>
<ha-svg-icon .path=${mdiHelp}></ha-svg-icon>
</ha-tile-icon>
<ha-tile-badge class="not-found"> <ha-tile-badge class="not-found">
<ha-svg-icon .path=${mdiExclamationThick}></ha-svg-icon> <ha-svg-icon .path=${mdiExclamationThick}></ha-svg-icon>
</ha-tile-badge> </ha-tile-badge>
</div> </ha-tile-icon>
<ha-tile-info <ha-tile-info
.primary=${entityId} .primary=${entityId}
secondary=${this.hass.localize("ui.card.tile.not_found")} secondary=${this.hass.localize("ui.card.tile.not_found")}
@ -275,45 +272,36 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
hasHold: hasAction(this._config!.hold_action), hasHold: hasAction(this._config!.hold_action),
hasDoubleClick: hasAction(this._config!.double_tap_action), hasDoubleClick: hasAction(this._config!.double_tap_action),
})} })}
role=${ifDefined(this.hasCardAction ? "button" : undefined)} role=${ifDefined(this._hasCardAction ? "button" : undefined)}
tabindex=${ifDefined(this.hasCardAction ? "0" : undefined)} tabindex=${ifDefined(this._hasCardAction ? "0" : undefined)}
aria-labelledby="info" aria-labelledby="info"
> >
<ha-ripple .disabled=${!this.hasCardAction}></ha-ripple> <ha-ripple .disabled=${!this._hasCardAction}></ha-ripple>
</div> </div>
<div class="container"> <div class="container">
<div class="content ${classMap(contentClasses)}"> <div class="content ${classMap(contentClasses)}">
<div <ha-tile-icon
class="icon-container" role=${ifDefined(this._hasIconAction ? "button" : undefined)}
role=${ifDefined(this.hasIconAction ? "button" : undefined)} tabindex=${ifDefined(this._hasIconAction ? "0" : undefined)}
tabindex=${ifDefined(this.hasIconAction ? "0" : undefined)}
@action=${this._handleIconAction} @action=${this._handleIconAction}
.actionHandler=${actionHandler({ .actionHandler=${actionHandler({
hasHold: hasAction(this._config!.icon_hold_action), hasHold: hasAction(this._config!.icon_hold_action),
hasDoubleClick: hasAction(this._config!.icon_double_tap_action), hasDoubleClick: hasAction(this._config!.icon_double_tap_action),
})} })}
.interactive=${this._hasIconAction}
.imageStyle=${DOMAIN_IMAGE_SHAPE[domain]}
.imageUrl=${imageUrl}
data-domain=${ifDefined(domain)}
data-state=${ifDefined(stateObj?.state)}
> >
${imageUrl <ha-state-icon
? html` slot="icon"
<ha-tile-image .icon=${this._config.icon}
.imageStyle=${DOMAIN_IMAGE_STYLE[domain] || "circle"} .stateObj=${stateObj}
.imageUrl=${imageUrl} .hass=${this.hass}
></ha-tile-image> ></ha-state-icon>
`
: html`
<ha-tile-icon
data-domain=${ifDefined(domain)}
data-state=${ifDefined(stateObj?.state)}
>
<ha-state-icon
.icon=${this._config.icon}
.stateObj=${stateObj}
.hass=${this.hass}
></ha-state-icon>
</ha-tile-icon>
`}
${renderTileBadge(stateObj, this.hass)} ${renderTileBadge(stateObj, this.hass)}
</div> </ha-tile-icon>
<ha-tile-info <ha-tile-info
id="info" id="info"
.primary=${name} .primary=${name}
@ -363,6 +351,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
} }
[role="button"] { [role="button"] {
cursor: pointer; cursor: pointer;
pointer-events: auto;
} }
[role="button"]:focus { [role="button"]:focus {
outline: none; outline: none;
@ -392,53 +381,28 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
flex: 1; flex: 1;
box-sizing: border-box; box-sizing: border-box;
pointer-events: none; pointer-events: none;
gap: 10px;
} }
.vertical { .vertical {
flex-direction: column; flex-direction: column;
text-align: center; text-align: center;
justify-content: center; justify-content: center;
} }
.vertical .icon-container {
margin-bottom: 10px;
margin-right: 0;
margin-inline-start: initial;
margin-inline-end: initial;
}
.vertical ha-tile-info { .vertical ha-tile-info {
width: 100%; width: 100%;
flex: none; flex: none;
} }
.icon-container { ha-tile-icon {
position: relative;
flex: none;
margin-right: 10px;
margin-inline-start: initial;
margin-inline-end: 10px;
direction: var(--direction);
transition: transform 180ms ease-in-out;
}
.icon-container ha-tile-icon,
.icon-container ha-tile-image {
--tile-icon-color: var(--tile-color); --tile-icon-color: var(--tile-color);
user-select: none; position: relative;
-ms-user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
} }
.icon-container ha-tile-badge { ha-tile-badge {
position: absolute; position: absolute;
top: -3px; top: -3px;
right: -3px; right: -3px;
inset-inline-end: -3px; inset-inline-end: -3px;
inset-inline-start: initial; inset-inline-start: initial;
} }
.icon-container[role="button"] {
pointer-events: auto;
}
.icon-container[role="button"]:focus-visible,
.icon-container[role="button"]:active {
transform: scale(1.2);
}
ha-tile-info { ha-tile-info {
position: relative; position: relative;
min-width: 0; min-width: 0;