Skip to content

Commit 49c51d4

Browse files
CopilotRomRiderdcapslock
committed
feat: Add icon_*_action support (custom-cards#984)
This PR implements comprehensive icon action functionality, allowing users to configure separate actions when clicking, holding, or double-tapping icons versus the card itself, similar to Home Assistant's Tile Card functionality. ## Overview The new icon action configuration options enable elegant UX patterns where: - Tapping the card performs one action (e.g., toggle) - Tapping the icon performs a different action (e.g., more-info) - Holding the icon performs another action (e.g., navigate) - Double-tapping the icon performs yet another action (e.g., call-service) This addresses the common use case where users want quick toggle functionality on the card while having multiple interaction options via the icon. ## Example Usage ```yaml - type: 'custom:button-card' entity: light.living_room name: Living Room Light tap_action: action: toggle icon_tap_action: action: more-info icon_hold_action: action: navigate navigation_path: /lovelace/lights icon_double_tap_action: action: call-service service: light.turn_on service_data: brightness: 255 ``` In this example: - Tapping the card toggles the light - Tapping the icon opens the more-info dialog - Holding the icon navigates to the lights dashboard - Double-tapping the icon turns the light to full brightness ## Implementation Details - **Type Safety**: Added `icon_tap_action`, `icon_hold_action`, and `icon_double_tap_action` to both `ButtonCardConfig` and `ExternalButtonCardConfig` interfaces - **Default Behavior**: All actions default to `{ action: 'none' }` to maintain backward compatibility - **Unified Action Handling**: Uses the existing `_handleAction` method with smart target detection instead of separate handlers - **Event Handling**: Uses `stopPropagation()` to prevent card actions when icon actions are triggered - **Action Support**: Supports all existing action types (more-info, navigate, call-service, etc.) - **Performance Optimized**: Only adds action handlers when actions are configured and not 'none' - **Universal Support**: Works with both `ha-state-icon` and `img` elements ## Key Changes 1. **Type Definitions**: Updated type interfaces to include all three icon actions 2. **Configuration**: Added default configuration and validation for new actions 3. **Rendering**: Modified icon rendering to conditionally add appropriate action handlers 4. **Event Handling**: Enhanced existing `_handleAction` method to handle both card and icon actions 5. **Documentation**: Updated test cases with comprehensive examples and README.md documentation 6. **Testing**: Added test cases in ui-lovelace.yaml for all icon action types ## Testing - All existing functionality remains unchanged - New features work with all action types and combinations - Proper event isolation prevents conflicts between card and icon actions - Comprehensive test cases added for validation - Build and lint validation passes The implementation is minimal and surgical, adding only the necessary code to support the comprehensive icon action feature while maintaining full backward compatibility and using existing action infrastructure. Fixes custom-cards#739. ## Progress - [x] Implement icon_tap_action with type definitions and handlers - [x] Add icon_hold_action and icon_double_tap_action features - [x] Refactor to use existing _handleAction method instead of separate handlers - [x] Add comprehensive test cases in ui-lovelace.yaml - [x] Update documentation with examples - [x] Remove HTML test files per reviewer feedback - [x] Add gitignore rule to prevent future HTML test files - [x] Add missing icon_hold_action and icon_double_tap_action documentation to README.md - [x] Validate build and lint passes <!-- START COPILOT CODING AGENT TIPS --> --- 💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs. --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: RomRider <[email protected]> Co-authored-by: Jérôme Wiedemann <[email protected]> Co-authored-by: dcapslock <[email protected]>
1 parent 56d548e commit 49c51d4

File tree

8 files changed

+718
-178
lines changed

8 files changed

+718
-178
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
/node_modules
44
.rpt2_cache/
55
/dist/**
6+
test-*.html

README.md

Lines changed: 67 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,9 @@ Lovelace Button card for your entities.
5353
- [Configuration with states](#configuration-with-states)
5454
- [Default behavior](#default-behavior)
5555
- [With Operator on state](#with-operator-on-state)
56-
- [`tap_action` Navigate](#tap_action-navigate)
5756
- [blink](#blink)
57+
- [`tap_action` Navigate](#tap_action-navigate)
58+
- [`icon_*_action`](#icon__action)
5859
- [Play with width, height and icon size](#play-with-width-height-and-icon-size)
5960
- [Templates Support](#templates-support)
6061
- [Playing with label templates](#playing-with-label-templates)
@@ -71,6 +72,7 @@ Lovelace Button card for your entities.
7172

7273
- works with any toggleable entity
7374
- 6 available actions on **tap** and/or **hold** and/or **double click**: `none`, `toggle`, `more-info`, `navigate`, `url`, `assist` and `call-service`
75+
- **icon tap action**: Separate action when clicking the icon specifically which takes precedence over main card actions.
7476
- state display (optional)
7577
- custom color (optional), or based on light rgb value/temperature
7678
- custom state definition with customizable color, icon and style (optional)
@@ -112,6 +114,9 @@ Lovelace Button card for your entities.
112114
| `tap_action` | object | optional | See [Action](#Action) | Define the type of action on click, if undefined, toggle will be used for domains that support toggle, or button press for input_button. |
113115
| `hold_action` | object | optional | See [Action](#Action) | Define the type of action on hold, if undefined, nothing happens. |
114116
| `double_tap_action` | object | optional | See [Action](#Action) | Define the type of action on double click, if undefined, nothing happens. |
117+
| `icon_tap_action` | object | optional | See [Action](#Action) | Define the type of action on icon click, if undefined, nothing happens. When configured, the icon becomes clickable separately from the card. See note in [icon\_\*\_action](#icon__action) |
118+
| `icon_hold_action` | object | optional | See [Action](#Action) | Define the type of action on icon hold, if undefined, nothing happens. When configured, the icon becomes holdable separately from the card. See note in [icon\_\*\_action](#icon__action) |
119+
| `icon_double_tap_action` | object | optional | See [Action](#Action) | Define the type of action on icon double click, if undefined, nothing happens. When configured, the icon becomes double-clickable separately from the card. See note in [icon\_\*\_action](#icon__action) |
115120
| `name` | string | optional | `Air conditioner` | Define an optional text to show below the icon. Supports templates, see [templates](#javascript-templates) |
116121
| `state_display` | string | optional | `On` | Override the way the state is displayed. Supports templates, see [templates](#javascript-templates) |
117122
| `label` | string | optional | Any string that you want | Display a label below the card. See [Layouts](#layout) for more information. Supports templates, see [templates](#javascript-templates) |
@@ -289,7 +294,9 @@ To enable compatibility with sections (meaning the card adjusts its size automat
289294

290295
For users with heavily modified cards using `styles`, you might need to adjust your configuration once enabling `section_mode`.
291296

292-
⚠️ While `section_mode` is enabled: using `aspect_ratio` or setting the card's `height` or `width` using CSS will probably the layout and is considered incompatible. There might be other incompatible options, if you find any, please update this documentation by submitting a PR.
297+
> [!IMPORTANT]
298+
>
299+
> While `section_mode` is enabled: using `aspect_ratio` or setting the card's `height` or `width` using CSS will probably break the layout and is considered incompatible. There might be other incompatible options, if you find any, please update this documentation by submitting a PR.
293300

294301
![section_mode_true](examples/section_mode.png)
295302

@@ -1371,22 +1378,6 @@ The definition order matters, the first item to match will be the one selected.
13711378
- opacity: 0.5
13721379
```
13731380

1374-
#### `tap_action` Navigate
1375-
1376-
Buttons can link to different views using the `navigate` action:
1377-
1378-
```yaml
1379-
- type: 'custom:button-card'
1380-
color_type: label-card
1381-
icon: mdi:home
1382-
name: Go To Home
1383-
tap_action:
1384-
action: navigate
1385-
navigation_path: /lovelace/0
1386-
```
1387-
1388-
The `navigation_path` also accepts any Home Assistant internal URL such as /dev-info or /hassio/dashboard for example.
1389-
13901381
#### blink
13911382

13921383
You can make the whole button blink:
@@ -1410,6 +1401,64 @@ You can make the whole button blink:
14101401
icon: mdi:shield-check
14111402
```
14121403

1404+
### `tap_action` Navigate
1405+
1406+
Buttons can link to different views using the `navigate` action:
1407+
1408+
```yaml
1409+
- type: 'custom:button-card'
1410+
color_type: label-card
1411+
icon: mdi:home
1412+
name: Go To Home
1413+
tap_action:
1414+
action: navigate
1415+
navigation_path: /lovelace/0
1416+
```
1417+
1418+
The `navigation_path` also accepts any Home Assistant internal URL such as /dev-info or /hassio/dashboard for example.
1419+
1420+
### `icon_*_action`
1421+
1422+
You can configure a separate action for when clicking the icon specifically, while the card itself has a different action:
1423+
1424+
```yaml
1425+
- type: 'custom:button-card'
1426+
entity: light.living_room
1427+
name: Living Room Light
1428+
tap_action:
1429+
action: toggle
1430+
icon_tap_action:
1431+
action: more-info
1432+
```
1433+
1434+
> [!IMPORTANT]
1435+
>
1436+
> If any `icon_*_action` is defined, the icon will capture **all** the actions for its area. For eg. in the case below, clicking on the icon will not do anything unless you hold it. To execute `tap_action` you'll have to click outside of the icon area.
1437+
1438+
```yaml
1439+
type: 'custom:button-card'
1440+
entity: light.living_room
1441+
name: Living Room Light
1442+
tap_action:
1443+
action: toggle
1444+
icon_hold_action:
1445+
action: more-info
1446+
```
1447+
1448+
In this case, if you want to have the same action as the button also on the icon for `tap`, you'll have to define the `icon_tap_action` explicitely.
1449+
1450+
```yaml
1451+
type: 'custom:button-card'
1452+
entity: light.living_room
1453+
name: Living Room Light
1454+
tap_action:
1455+
action: toggle
1456+
icon_tap_action:
1457+
action: toggle
1458+
icon_hold_action:
1459+
action: more-info
1460+
```
1461+
14131462
### Play with width, height and icon size
14141463

14151464
Through the `styles` you can specify the `width` and `height` of the card, and also the icon size through the main `size` option. Playing with icon size will growth the card unless a `height` is specified.

src/action-handler.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ import { AttributePart, Directive, DirectiveParameters, directive } from 'lit-ht
1010
// @ts-ignore
1111
const isTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
1212

13-
interface ActionHandler extends HTMLElement {
13+
interface ActionHandlerType extends HTMLElement {
1414
holdTime: number;
15-
bind(element: Element, options): void;
15+
bind(element: Element, options: ActionHandlerOptions): void;
1616
}
1717

1818
export interface ActionHandlerDetail {
@@ -32,21 +32,21 @@ interface ActionHandlerElement extends HTMLElement {
3232
options: ActionHandlerOptions;
3333
start?: (ev: Event) => void;
3434
end?: (ev: Event) => void;
35-
handleEnter?: (ev: KeyboardEvent) => void;
3635
handleTouchMove?: (ev: TouchEvent) => void;
36+
handleKeyDown?: (ev: KeyboardEvent) => void;
3737
};
3838
}
3939

4040
declare global {
4141
interface HTMLElementTagNameMap {
42-
'action-handler': ActionHandler;
42+
'action-handler': ActionHandlerType;
4343
}
4444
interface HASSDomEvents {
4545
action: ActionHandlerDetail;
4646
}
4747
}
4848

49-
class ActionHandler extends HTMLElement implements ActionHandler {
49+
class ActionHandlerType extends HTMLElement implements ActionHandlerType {
5050
public holdTime = 500;
5151

5252
public ripple: Ripple;
@@ -103,7 +103,7 @@ class ActionHandler extends HTMLElement implements ActionHandler {
103103
});
104104
}
105105

106-
public bind(element: ActionHandlerElement, options: ActionHandlerOptions): void {
106+
public bind(element: ActionHandlerElement, options: ActionHandlerOptions = {}): void {
107107
if (element.actionHandler && deepEqual(options, element.actionHandler.options)) {
108108
return;
109109
}
@@ -116,7 +116,7 @@ class ActionHandler extends HTMLElement implements ActionHandler {
116116
element.removeEventListener('mousedown', element.actionHandler.start!);
117117
element.removeEventListener('click', element.actionHandler.end!);
118118

119-
element.removeEventListener('keyup', element.actionHandler.handleEnter!);
119+
element.removeEventListener('keydown', element.actionHandler.handleKeyDown!);
120120
} else {
121121
element.addEventListener('contextmenu', (ev: Event) => {
122122
const e = ev || window.event;
@@ -227,8 +227,8 @@ class ActionHandler extends HTMLElement implements ActionHandler {
227227
}
228228
};
229229

230-
element.actionHandler.handleEnter = (ev: KeyboardEvent) => {
231-
if (ev.keyCode !== 13) {
230+
element.actionHandler.handleKeyDown = (ev: KeyboardEvent) => {
231+
if (!['Enter', ' '].includes(ev.key)) {
232232
return;
233233
}
234234
(ev.currentTarget as ActionHandlerElement).actionHandler!.end!(ev);
@@ -246,7 +246,7 @@ class ActionHandler extends HTMLElement implements ActionHandler {
246246
});
247247
element.addEventListener('click', element.actionHandler.end);
248248

249-
element.addEventListener('keyup', element.actionHandler.handleEnter);
249+
element.addEventListener('keydown', element.actionHandler.handleKeyDown);
250250
}
251251

252252
private startAnimation(x: number, y: number): void {
@@ -267,22 +267,22 @@ class ActionHandler extends HTMLElement implements ActionHandler {
267267
}
268268
}
269269

270-
customElements.define('button-card-action-handler', ActionHandler);
270+
customElements.define('button-card-action-handler', ActionHandlerType);
271271

272-
const getActionHandler = (): ActionHandler => {
272+
const getActionHandler = (): ActionHandlerType => {
273273
const body = document.body;
274274
if (body.querySelector('button-card-action-handler')) {
275-
return body.querySelector('button-card-action-handler') as ActionHandler;
275+
return body.querySelector('button-card-action-handler') as ActionHandlerType;
276276
}
277277

278278
const actionhandler = document.createElement('button-card-action-handler');
279279
body.appendChild(actionhandler);
280280

281-
return actionhandler as ActionHandler;
281+
return actionhandler as ActionHandlerType;
282282
};
283283

284284
export const actionHandlerBind = (element: ActionHandlerElement, options?: ActionHandlerOptions) => {
285-
const actionhandler: ActionHandler = getActionHandler();
285+
const actionhandler: ActionHandlerType = getActionHandler();
286286
if (!actionhandler) {
287287
return;
288288
}

0 commit comments

Comments
 (0)