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 { LitElement, css, html } from "lit";
import { customElement } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import "../ha-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")
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 {
return html`
<div class="shape">
if (this.imageUrl) {
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>
`;
}
return html`
<div class="container ${this.interactive ? "background" : ""}">
<slot name="icon"></slot>
</div>
<slot></slot>
`;
}
static styles = css`
:host {
--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: "";
position: absolute;
top: 0;
@ -27,24 +83,21 @@ export class HaTileIcon extends LitElement {
height: 100%;
width: 100%;
background-color: var(--tile-icon-color);
transition: background-color 180ms ease-in-out;
opacity: 0.2;
transition:
background-color 180ms ease-in-out,
opacity 180ms ease-in-out;
opacity: var(--tile-icon-opacity);
}
.shape {
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(*) {
.container ::slotted([slot="icon"]) {
display: flex;
color: var(--tile-icon-color);
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/tile/ha-tile-badge";
import "../../../components/tile/ha-tile-icon";
import "../../../components/tile/ha-tile-image";
import type { TileImageStyle } from "../../../components/tile/ha-tile-image";
import type { TileIconImageStyle } from "../../../components/tile/ha-tile-icon";
import "../../../components/tile/ha-tile-info";
import { cameraUrlWithWidthHeight } from "../../../data/camera";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
@ -36,7 +35,7 @@ import type {
LovelaceGridOptions,
} from "../types";
import { renderTileBadge } from "./tile/badges/tile-badge";
import type { ThermostatCardConfig, TileCardConfig } from "./types";
import type { TileCardConfig } from "./types";
export const getEntityDefaultTileIconAction = (entityId: string) => {
const domain = computeDomain(entityId);
@ -44,10 +43,10 @@ export const getEntityDefaultTileIconAction = (entityId: string) => {
DOMAINS_TOGGLE.has(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",
media_player: "rounded-square",
};
@ -84,7 +83,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
@state() private _config?: TileCardConfig;
public setConfig(config: ThermostatCardConfig): void {
public setConfig(config: TileCardConfig): void {
if (!config.entity) {
throw new Error("Specify an entity");
}
@ -196,7 +195,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
}
);
get hasCardAction() {
private get _hasCardAction() {
return (
!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 (
!this._config?.icon_tap_action || hasAction(this._config?.icon_tap_action)
);
@ -224,14 +223,12 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
return html`
<ha-card>
<div class="content ${classMap(contentClasses)}">
<div class="icon-container">
<ha-tile-icon>
<ha-svg-icon .path=${mdiHelp}></ha-svg-icon>
</ha-tile-icon>
<ha-tile-icon>
<ha-svg-icon slot="icon" .path=${mdiHelp}></ha-svg-icon>
<ha-tile-badge class="not-found">
<ha-svg-icon .path=${mdiExclamationThick}></ha-svg-icon>
</ha-tile-badge>
</div>
</ha-tile-icon>
<ha-tile-info
.primary=${entityId}
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),
hasDoubleClick: hasAction(this._config!.double_tap_action),
})}
role=${ifDefined(this.hasCardAction ? "button" : undefined)}
tabindex=${ifDefined(this.hasCardAction ? "0" : undefined)}
role=${ifDefined(this._hasCardAction ? "button" : undefined)}
tabindex=${ifDefined(this._hasCardAction ? "0" : undefined)}
aria-labelledby="info"
>
<ha-ripple .disabled=${!this.hasCardAction}></ha-ripple>
<ha-ripple .disabled=${!this._hasCardAction}></ha-ripple>
</div>
<div class="container">
<div class="content ${classMap(contentClasses)}">
<div
class="icon-container"
role=${ifDefined(this.hasIconAction ? "button" : undefined)}
tabindex=${ifDefined(this.hasIconAction ? "0" : undefined)}
<ha-tile-icon
role=${ifDefined(this._hasIconAction ? "button" : undefined)}
tabindex=${ifDefined(this._hasIconAction ? "0" : undefined)}
@action=${this._handleIconAction}
.actionHandler=${actionHandler({
hasHold: hasAction(this._config!.icon_hold_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
? html`
<ha-tile-image
.imageStyle=${DOMAIN_IMAGE_STYLE[domain] || "circle"}
.imageUrl=${imageUrl}
></ha-tile-image>
`
: 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>
`}
<ha-state-icon
slot="icon"
.icon=${this._config.icon}
.stateObj=${stateObj}
.hass=${this.hass}
></ha-state-icon>
${renderTileBadge(stateObj, this.hass)}
</div>
</ha-tile-icon>
<ha-tile-info
id="info"
.primary=${name}
@ -363,6 +351,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
}
[role="button"] {
cursor: pointer;
pointer-events: auto;
}
[role="button"]:focus {
outline: none;
@ -392,53 +381,28 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
flex: 1;
box-sizing: border-box;
pointer-events: none;
gap: 10px;
}
.vertical {
flex-direction: column;
text-align: 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 {
width: 100%;
flex: none;
}
.icon-container {
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 {
ha-tile-icon {
--tile-icon-color: var(--tile-color);
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
position: relative;
}
.icon-container ha-tile-badge {
ha-tile-badge {
position: absolute;
top: -3px;
right: -3px;
inset-inline-end: -3px;
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 {
position: relative;
min-width: 0;