More example images than you could poke a stick at, I know...
## Features
- Create icon, text, or icon-text buttons.
- Add css styling to each button per state!
- Style the icon, name, state, and ripple effect separately.
- Change the icon alignment and layout of the icon, name and state.
- Add actions for `tap_action`, `double_tap_action` and `hold_action`.
- Create multiple rows of buttons.
- Embed buttons in other entity rows.
- Tooltip support, configure custom tooltips.
- Templating support.
================================================
FILE: package.json
================================================
{
"name": "paper-buttons-row",
"version": "0.0.0-dev",
"type": "module",
"private": true,
"scripts": {
"prepare": "husky",
"dev": "vite build --watch",
"build": "vite build",
"build:tsc": "tsc && vite build",
"typecheck": "tsc",
"lint": "eslint . --cache --max-warnings=0 --ext js,cjs,mjs,jsx,ts,tsx",
"lint:fix": "pnpm run lint --fix",
"format": "prettier --cache --write .",
"format:check": "prettier --cache --check ."
},
"lint-staged": {
"*": "biome check --write --no-errors-on-unmatched --files-ignore-unknown=true"
},
"dependencies": {
"card-tools": "github:thomasloven/lovelace-card-tools#477f3d4",
"custom-card-helpers": "1.9.0",
"deepmerge": "4.3.1",
"fast-deep-equal": "3.1.3",
"home-assistant-js-websocket": "9.5.0",
"lit": "2.8.0",
"vite": "6.1.6"
},
"devDependencies": {
"@biomejs/biome": "2.1.3",
"@types/node": "22.17.0",
"husky": "9.1.7",
"lint-staged": "15.5.2",
"typescript": "5.8.3",
"vite-plugin-compression": "0.5.1"
},
"packageManager": "pnpm@10.13.1",
"engines": {
"node": ">=20.x"
}
}
================================================
FILE: pnpm-workspace.yaml
================================================
onlyBuiltDependencies:
- '@biomejs/biome'
- esbuild
================================================
FILE: src/action-handler.ts
================================================
import {
type ActionHandlerDetail,
type ActionHandlerOptions,
fireEvent,
} from "custom-card-helpers";
import deepEqual from "fast-deep-equal";
import { noChange } from "lit";
import {
type AttributePart,
Directive,
type DirectiveParameters,
directive,
} from "lit/directive.js";
declare global {
interface Navigator {
msMaxTouchPoints: number;
}
}
const isTouch =
"ontouchstart" in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0;
export interface CustomActionHandlerOptions extends ActionHandlerOptions {
disabled?: boolean;
repeat?: number;
stopPropagation?: boolean;
}
interface Ripple extends HTMLElement {
primary: boolean;
disabled: boolean;
unbounded: boolean;
startPress: () => void;
endPress: () => void;
}
interface IActionHandler extends HTMLElement {
holdTime: number;
bind: (
element: ActionHandlerElement,
options?: CustomActionHandlerOptions,
) => void;
}
interface ActionHandlerElement extends HTMLElement {
actionHandler?: {
options: ActionHandlerOptions;
start?: (ev: Event) => void;
end?: (ev: Event) => void;
handleKeyDown?: (ev: KeyboardEvent) => void;
};
}
declare global {
interface HTMLElementTagNameMap {
"action-handler": ActionHandler;
}
interface HASSDomEvents {
action: ActionHandlerDetail;
}
}
class ActionHandler extends HTMLElement implements IActionHandler {
public holdTime = 500;
public ripple: Ripple;
protected timer?: number;
protected held = false;
private cancelled = false;
private dblClickTimeout?: number;
private repeatTimeout: NodeJS.Timeout | undefined;
private isRepeating = false;
constructor() {
super();
this.ripple = document.createElement("mwc-ripple") as Ripple;
}
public connectedCallback(): void {
Object.assign(this.style, {
position: "fixed",
width: isTouch ? "100px" : "50px",
height: isTouch ? "100px" : "50px",
transform: "translate(-50%, -50%) scale(0)",
pointerEvents: "none",
zIndex: "999",
background: "var(--primary-color)",
display: null,
opacity: "0.2",
borderRadius: "50%",
transition: "transform 180ms ease-in-out",
});
this.appendChild(this.ripple);
this.ripple.primary = true;
for (const ev of [
"touchcancel",
"mouseout",
"mouseup",
"touchmove",
"mousewheel",
"wheel",
"scroll",
]) {
document.addEventListener(
ev,
() => {
this.cancelled = true;
if (this.timer) {
this._stopAnimation();
clearTimeout(this.timer);
this.timer = undefined;
if (this.isRepeating && this.repeatTimeout) {
clearInterval(this.repeatTimeout);
this.isRepeating = false;
}
}
},
{ passive: true },
);
}
}
public bind(
element: ActionHandlerElement,
options: CustomActionHandlerOptions = {},
): void {
if (
element.actionHandler &&
deepEqual(options, element.actionHandler.options)
) {
return;
}
if (element.actionHandler) {
if (element.actionHandler.start) {
element.removeEventListener("touchstart", element.actionHandler.start);
element.removeEventListener("mousedown", element.actionHandler.start);
}
if (element.actionHandler.end) {
element.removeEventListener("touchend", element.actionHandler.end);
element.removeEventListener("touchcancel", element.actionHandler.end);
element.removeEventListener("click", element.actionHandler.end);
}
if (element.actionHandler.handleKeyDown) {
element.removeEventListener(
"keydown",
element.actionHandler.handleKeyDown,
);
}
} else {
element.addEventListener("contextmenu", (ev: Event) => {
const e = ev || window.event;
if (e.preventDefault) {
e.preventDefault();
}
if (e.stopPropagation) {
e.stopPropagation();
}
e.cancelBubble = true;
e.returnValue = false;
return false;
});
}
element.actionHandler = { options };
if (options.disabled) {
return;
}
element.actionHandler.start = (ev: Event) => {
if (options.stopPropagation) {
ev.stopPropagation();
}
this.cancelled = false;
let x: number;
let y: number;
if ((ev as TouchEvent).touches) {
x = (ev as TouchEvent).touches[0].clientX;
y = (ev as TouchEvent).touches[0].clientY;
} else {
x = (ev as MouseEvent).clientX;
y = (ev as MouseEvent).clientY;
}
if (options.hasHold) {
this.held = false;
this.timer = window.setTimeout(() => {
this._startAnimation(x, y);
this.held = true;
if (options.repeat && !this.isRepeating) {
this.isRepeating = true;
this.repeatTimeout = setInterval(() => {
fireEvent(element, "action", { action: "hold" });
}, options.repeat);
}
}, this.holdTime);
}
};
element.actionHandler.end = (ev: Event) => {
if (options.stopPropagation) {
ev.stopPropagation();
}
// Don't respond when moved or scrolled while touch
if (
ev.type === "touchcancel" ||
(ev.type === "touchend" && this.cancelled)
) {
if (this.isRepeating && this.repeatTimeout) {
clearInterval(this.repeatTimeout);
this.isRepeating = false;
}
return;
}
const target = ev.target as HTMLElement;
// Prevent mouse event if touch event
if (ev.cancelable) {
ev.preventDefault();
}
if (options.hasHold) {
clearTimeout(this.timer);
if (this.isRepeating && this.repeatTimeout) {
clearInterval(this.repeatTimeout);
}
this.isRepeating = false;
this._stopAnimation();
this.timer = undefined;
}
if (options.hasHold && this.held) {
if (!options.repeat) {
fireEvent(target, "action", { action: "hold" });
}
} else if (options.hasDoubleClick) {
if (
(ev.type === "click" && (ev as MouseEvent).detail < 2) ||
!this.dblClickTimeout
) {
this.dblClickTimeout = window.setTimeout(() => {
this.dblClickTimeout = undefined;
fireEvent(target, "action", { action: "tap" });
}, 250);
} else {
clearTimeout(this.dblClickTimeout);
this.dblClickTimeout = undefined;
fireEvent(target, "action", { action: "double_tap" });
}
} else {
fireEvent(target, "action", { action: "tap" });
}
};
element.actionHandler.handleKeyDown = (ev: KeyboardEvent) => {
if (!["Enter", " "].includes(ev.key)) {
return;
}
(ev.currentTarget as ActionHandlerElement).actionHandler?.end?.(ev);
};
element.addEventListener("touchstart", element.actionHandler.start, {
passive: true,
});
element.addEventListener("touchend", element.actionHandler.end);
element.addEventListener("touchcancel", element.actionHandler.end);
element.addEventListener("mousedown", element.actionHandler.start, {
passive: true,
});
element.addEventListener("click", element.actionHandler.end);
element.addEventListener("keydown", element.actionHandler.handleKeyDown);
}
private _startAnimation(x: number, y: number) {
Object.assign(this.style, {
left: `${x}px`,
top: `${y}px`,
transform: "translate(-50%, -50%) scale(1)",
});
this.ripple.disabled = false;
this.ripple.startPress();
this.ripple.unbounded = true;
}
private _stopAnimation() {
this.ripple.endPress();
this.ripple.disabled = true;
Object.assign(this.style, {
left: null,
top: null,
transform: "translate(-50%, -50%) scale(0)",
});
}
}
customElements.define("paper-buttons-row-action-handler", ActionHandler);
const getActionHandler = (): ActionHandler => {
const body = document.body;
if (body.querySelector("paper-buttons-row-action-handler")) {
return body.querySelector(
"paper-buttons-row-action-handler",
) as ActionHandler;
}
const actionHandler = document.createElement(
"paper-buttons-row-action-handler",
);
body.appendChild(actionHandler);
return actionHandler as ActionHandler;
};
export const actionHandlerBind = (
element: ActionHandlerElement,
options?: CustomActionHandlerOptions,
): void => {
const actionHandler: ActionHandler = getActionHandler();
if (!actionHandler) {
return;
}
actionHandler.bind(element, options);
};
export const actionHandler = directive(
class extends Directive {
update(part: AttributePart, [options]: DirectiveParameters