Compare commits

..
1 Commits
Author SHA1 Message Date
GeorgeSG a45b534569 0.1.2 2020-07-17 20:22:22 +03:00
45 changed files with 4445 additions and 5021 deletions
+20
View File
@@ -0,0 +1,20 @@
{
"parser": "@typescript-eslint/parser", // Specifies the ESLint parser
"extends": [
"plugin:@typescript-eslint/recommended", // Uses the recommended rules from the @typescript-eslint/eslint-plugin
"prettier/@typescript-eslint", // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier
"plugin:prettier/recommended" // Enables eslint-plugin-prettier and displays prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array.
],
"parserOptions": {
"ecmaVersion": 2018, // Allows for the parsing of modern ECMAScript features
"sourceType": "module", // Allows for the use of imports
"experimentalDecorators": true
},
"rules": {
"@typescript-eslint/camelcase": 0,
"@typescript-eslint/no-non-null-assertion": 0,
"@typescript-eslint/explicit-function-return-type": [1, { "allowExpressions": true }],
"@typescript-eslint/no-namespace": 0
},
"ignorePatterns": ["dist/", "node_modules/", "*.js"]
}
+7 -17
View File
@@ -1,4 +1,4 @@
name: CI
name: 'CI'
on:
push:
@@ -12,20 +12,10 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
cache: npm
- name: Install
run: npm ci
- name: Lint
run: npm run lint
- name: Format
run: npm run format:check
- name: Typecheck
run: npm run typecheck
- name: Test
run: npm test
- uses: actions/checkout@v2
- name: Build
run: npm run rollup
run: |
npm install
npm run build
- name: Test
run: npm run test
+9 -12
View File
@@ -4,24 +4,21 @@ on:
release:
types: [published]
permissions:
contents: write
jobs:
release:
name: Build and upload
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
cache: npm
- uses: actions/checkout@v2
- name: Build time-picker-card.js
run: |
npm ci
npm install
npm run build
- name: Upload to release
run: gh release upload "${{ github.event.release.tag_name }}" dist/time-picker-card.js --clobber
env:
GH_TOKEN: ${{ github.token }}
uses: shopify/upload-to-release@1.0.0
with:
name: time-picker-card.js
path: dist/time-picker-card.js
repo-token: ${{ secrets.GITHUB_TOKEN }}
-5
View File
@@ -1,7 +1,2 @@
node_modules/
dist/
devtools/homeassistant/*
!devtools/homeassistant/configuration.yaml
!devtools/homeassistant/ui-lovelace.yaml
!devtools/homeassistant/packages/
-1
View File
@@ -1 +0,0 @@
24
-3
View File
@@ -1,8 +1,5 @@
{
"files.exclude": {
"dist/**": true
},
"files.associations": {
"*.yaml": "home-assistant"
}
}
+26 -69
View File
@@ -76,7 +76,6 @@ entity: input_datetime.alarm_time
hour_mode: 12
layout:
hour_mode: single
align_controls: right
hide:
name: true
```
@@ -90,39 +89,26 @@ type: 'custom:time-picker-card'
entity: input_datetime.alarm_time
layout:
name: inside
align_controls: center
align_controls: right
```
### Dark theme, embedded layout
![Dark theme embedded](https://raw.githubusercontent.com/GeorgeSG/lovelace-time-picker-card/master/examples/dark_embedded.png)
### Dark theme, thin layout
![Dark theme thin layout](https://raw.githubusercontent.com/GeorgeSG/lovelace-time-picker-card/master/examples/dark_thin.png)
### With a custom lovelace theme
![Custom theme](https://raw.githubusercontent.com/GeorgeSG/lovelace-time-picker-card/master/examples/custom.png)
## Options
| Name | Type | Requirement | Description | Default |
| ----------------- | ------------- | ------------ | --------------------------------------------------------------------------------------------------------- | ------------- |
| type | string | **Required** | `custom:time-picker-card` | |
| entity | string | **Required** | [Input Datetime](https://www.home-assistant.io/integrations/input_datetime/) entity with `has_time: true` | |
| name | string / list | **Optional** | Card name. Accepts a [structured name](#structured-names) on Home Assistant 2026.4 and later. | Entity's name |
| link_values | boolean | **Optional** | If enabled, will change hour when minutes overflow. E.g. will go from 11:55 to 12:00, instead of 11:00 | `false` |
| hour_mode | `12` or `24` | **Optional** | Hour format. If `12`, card will show AM/PM picker | `24` |
| hour_step | number | **Optional** | Hour change when clicking arrows | `1` |
| minute_step | number | **Optional** | Minute change when clicking arrows | `5` |
| second_step | number | **Optional** | Second change when clicking arrows (only with `hide.seconds: false`) | `5` |
| delay | number | **Optional** | Delay in ms before updating entity | `0` |
| layout | object | **Optional** | Card Layout configuration | `none` |
| hide | object | **Optional** | Hide object | `none` |
| tap_action | action | **Optional** | Home assistant action to perform on tap | `more-info` |
| double_tap_action | action | **Optional** | Home assistant action to perform on tap | `more-info` |
| hold_action | action | **Optional** | Home assistant action to perform on tap | `more-info` |
| Name | Type | Requirement | Description | Default |
| ----------- | ------------ | ------------ | --------------------------------------------------------------------------------------------------------- | ------------------------ |
| type | string | **Required** | `custom:time-picker-card` | |
| entity | string | **Required** | [Input Datetime](https://www.home-assistant.io/integrations/input_datetime/) entity with `has_time: true` | |
| name | string | **Optional** | Card name | Entity's `friendly_name` |
| link_values | boolean | **Optional** | If enabled, will change hour when minutes overflow. E.g. will go from 11:55 to 12:00, instead of 11:00 | `false` |
| hour_mode | `12` or `24` | **Optional** | Hour format. If `12`, card will show AM/PM picker | `24` |
| hour_step | number | **Optional** | Hour change when clicking arrows | `1` |
| minute_step | number | **Optional** | Minute change when clicking arrows | `5` |
| layout | object | **Optional** | Card Layout configuration | `none` |
| hide | object | **Optional** | Hide object | `none` |
### Layout Object
@@ -131,55 +117,26 @@ layout:
| hour_mode | `single`, `double` | **Optional** | Whether to show both AM/PM or just the current mode. In `single` mode, tap the value to change it. | `double` |
| align_controls | `left`, `center`, `right` | **Optional** | Horizontal alignment of the controls | `center` |
| name | `header`, `inside` | **Optional** | Whether to show the name as a header or inside the card | `header` |
| embedded | boolean | **Optional** | Render with embedded style - disables padding, box shadow, and card header | `false` |
| thin | boolean | **Optional** | Render with reduced paddings | `false` |
### Hide Object
| Name | Type | Requirement | Description | Default |
| ------- | ------- | ------------ | ------------------------------------------------- | ------- |
| name | boolean | **Optional** | Hides the card name | `false` |
| icon | boolean | **Optional** | Hides the card icon (only with controls "inside") | `false` |
| seconds | boolean | **Optional** | Hides seconds input | `true` |
| Name | Type | Requirement | Description | Default |
| ---- | ------- | ------------ | ------------------- | ------- |
| name | boolean | **Optional** | Hides the card name | `false` |
### Theme Variables
Time Picker Card will automatically pick up colors from your lovelace theme, but if you want to customize some of them,
you can use the following variables in your theme's config file:
| Name | Default | Description |
| ------------------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------- |
| time-picker-elements-background-color | `var(--primary-color)` | Background color for header and inputs |
| time-picker-icon-color | `var(--primary-text-color)` | Arrow color |
| time-picker-text-color | `white` | Text color |
| time-picker-accent-color | `var(--primary-color)` | AM / PM active color |
| time-picker-off-color | `var(--disabled-text-color)` | AM / PM inactive color |
| time-picker-border-radius | `var(--ha-card-border-radius)` | Border radius of the card |
| time-picker-control-padding | `8px` | Padding for interactive elements. Increase for larger hitboxes of the controls. Example: `10px 12px` |
### Structured names
_Requires Home Assistant 2026.4 or later. On earlier versions a structured `name` falls back to the entity's friendly name._
Home Assistant composes an entity's display name out of its registry context
(entity, device, area, floor) rather than one `friendly_name` string. `name` can
be a list of those parts instead of a plain string, so it keeps following renames
and matches what the built-in cards show:
```yaml
type: custom:time-picker-card
entity: input_datetime.alarm
name:
- type: area
- type: entity
```
Available part types are `entity`, `device`, `parent_device`, `area`, `floor`, and
`text` (a literal, written as `{type: text, text: 'Alarm'}`). Parts that resolve to
nothing are dropped. A plain string `name` keeps working exactly as before, and the
visual editor offers both modes on Home Assistant 2025.11 and later.
See the [Home Assistant developer documentation](https://developers.home-assistant.io/docs/frontend/data#hassformatentitynamestateobj-name-options) for details.
| Name | Default | Description |
| ------------------------------------- | ------------------------------ | -------------------------------------- |
| time-picker-elements-background-color | `var(--primary-color)` | Background color for header and inputs |
| time-picker-icon-color | `var(--primary-text-color)` | Arrow color |
| time-picker-text-color | `white` | Text color |
| time-picker-accent-color | `var(--primary-color)` | AM / PM active color |
| time-picker-off-color | `var(--disabled-text-color)` | AM / PM inactive color |
| time-picker-border-radius | `var(--ha-card-border-radius)` | Border radius of the card |
## Meta
@@ -190,14 +147,14 @@ See the [Home Assistant developer documentation](https://developers.home-assista
- [![Twitter][twitter-icon]][twitter-link] [@georgesg92][twitter-link]
[hacs-shield]: https://img.shields.io/badge/HACS-Default-brightgreen.svg
[hacs-link]: https://github.com/hacs/integration
[hacs-link]: https://github.com/custom-components/hacs
[downloads-shield]: https://img.shields.io/github/downloads/GeorgeSG/lovelace-time-picker-card/latest/total?color=brightgreen&logo=github
[downloads-link]: https://github.com/GeorgeSG/lovelace-time-picker-card/releases
[releases-shield]: https://img.shields.io/github/release/GeorgeSG/lovelace-time-picker-card.svg
[releases-link]: https://github.com/GeorgeSG/lovelace-time-picker-card/releases
[ci-shield]: https://img.shields.io/github/actions/workflow/status/GeorgeSG/lovelace-time-picker-card/ci.yaml?branch=master&label=CI&logo=github
[ci-shield]: https://img.shields.io/github/workflow/status/GeorgeSG/lovelace-time-picker-card/CI?label=CI&logo=github&
[ci-link]: https://github.com/GeorgeSG/lovelace-time-picker-card/actions?query=workflow%3ACI
[maintenance-shield]: https://img.shields.io/maintenance/yes/2026.svg
[maintenance-shield]: https://img.shields.io/maintenance/yes/2020.svg
[maintenance-link]: https://github.com/GeorgeSG/lovelace-time-picker-card
[license-shield]: https://img.shields.io/github/license/GeorgeSG/lovelace-time-picker-card?color=brightgreen
[license-link]: https://github.com/GeorgeSG/lovelace-time-picker-card/blob/master/LICENSE
-21
View File
@@ -1,21 +0,0 @@
# Configure a default setup of Home Assistant (frontend, api, etc)
default_config:
homeassistant:
packages: !include_dir_named packages/
logger:
default: info
lovelace:
resource_mode: yaml
resources:
- type: module
url: http://127.0.0.1:5100/time-picker-card.js
dashboards:
lovelace-home:
mode: yaml
title: Home
icon: mdi:home
show_in_sidebar: true
filename: ui-lovelace.yaml
@@ -1,12 +0,0 @@
# Test entities for the Time Picker Card dev dashboard (ui-lovelace.yaml).
input_datetime:
test_date_time:
name: Test date time
has_date: true
has_time: true
initial: '2026-01-01 07:30:00'
test_date:
name: Test date (no time)
has_date: true
has_time: false
initial: '2026-01-01'
-154
View File
@@ -1,154 +0,0 @@
title: Home
views:
- title: dev
path: dev
cards:
- type: vertical-stack
cards:
- type: markdown
content: '# 🕰 Time Picker Card dev'
- type: entities
entities:
- input_datetime.test_date_time
- type: horizontal-stack
cards:
- type: custom:time-picker-card
entity: input_datetime.test_date_time
name: With tap actions
hour_mode: 24
hour_step: 2
tap_action:
action: call-service
service: logbook.log
service_data:
name: 'Time picker card · Tap Action'
message: 'Debug'
double_tap_action:
action: call-service
service: logbook.log
service_data:
name: 'Time picker card · Double Tap Action'
message: 'Debug'
hold_action:
action: call-service
service: logbook.log
service_data:
name: 'Time picker card · Hold action'
message: 'Debug'
- type: custom:time-picker-card
name: Hour mode 12, thin, single, hidden name
entity: input_datetime.test_date_time
hour_mode: 12
layout:
hour_mode: single
thin: true
hide:
name: true
- type: custom:time-picker-card
name: delayed
entity: input_datetime.test_date_time
delay: 1000
hour_mode: 12
layout:
hour_mode: single
thin: true
- type: custom:time-picker-card
entity: input_datetime.test_date_time
name: Thin
layout:
thin: true
- type: custom:time-picker-card
entity: input_datetime.test_date_time
name: Shown seconds
hide:
seconds: false
- type: horizontal-stack
cards:
- type: custom:time-picker-card
entity: input_datetime.test_date_time
name: Hidden name
hour_mode: 12
hide:
name: true
- type: custom:time-picker-card
entity: input_datetime.test_date_time
name: Linked, single hour mode, hidden name
link_values: true
hour_mode: 12
hour_step: 2
minute_step: 3
layout:
hour_mode: single
hide:
name: false
- type: custom:time-picker-card
entity: input_datetime.test_date
name: Error
- title: name inside
path: name-inside
cards:
- type: custom:time-picker-card
entity: input_datetime.test_date_time
name: Inside, controls right
hour_mode: 12
layout:
name: inside
align_controls: right
- type: horizontal-stack
cards:
- type: custom:time-picker-card
entity: input_datetime.test_date_time
name: Inside, hidden name
layout:
name: inside
hide:
name: true
- type: custom:time-picker-card
entity: input_datetime.test_date_time
name: Inside, hidden icon
layout:
name: inside
hide:
icon: true
- title: embedded
path: embedded
cards:
- type: vertical-stack
cards:
- type: entities
entities:
- input_datetime.test_date_time
- type: custom:time-picker-card
entity: input_datetime.test_date_time
name: Embedded thin
hour_mode: 24
hour_step: 2
layout:
embedded: true
thin: true
- type: custom:time-picker-card
entity: input_datetime.test_date_time
name: Inside
hour_mode: 24
hour_step: 2
layout:
name: inside
- type: custom:time-picker-card
entity: input_datetime.test_date_time
hour_mode: 12
layout:
hour_mode: single
hide:
name: true
- type: custom:time-picker-card
entity: input_datetime.test_date_time
hour_mode: 12
layout:
hour_mode: single
-31
View File
@@ -1,31 +0,0 @@
import js from '@eslint/js';
import { defineConfig, globalIgnores } from 'eslint/config';
import prettier from 'eslint-config-prettier';
import lit from 'eslint-plugin-lit';
import wc from 'eslint-plugin-wc';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default defineConfig([
globalIgnores(['dist/', 'node_modules/', 'devtools/']),
js.configs.recommended,
tseslint.configs.recommended,
lit.configs['flat/recommended'],
wc.configs['flat/recommended'],
{
languageOptions: {
globals: globals.browser,
parserOptions: { experimentalDecorators: true },
},
rules: {
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-namespace': 'off',
'@typescript-eslint/explicit-function-return-type': ['warn', { allowExpressions: true }],
},
},
{
files: ['*.config.{js,ts}'],
languageOptions: { globals: globals.node },
},
prettier,
]);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 45 KiB

+3794 -3459
View File
File diff suppressed because it is too large Load Diff
+34 -36
View File
@@ -1,6 +1,6 @@
{
"name": "time-picker-card",
"version": "1.4.0",
"version": "0.1.2",
"author": "Georgi Gardev <georgi@gardev.com>",
"description": "Lovelace time-picker-card",
"keywords": [
@@ -13,50 +13,48 @@
"lovelace-custom-card"
],
"license": "MIT",
"type": "module",
"repository": "git@github.com:GeorgeSG/lovelace-time-picker-card.git",
"engines": {
"node": ">=24",
"npm": ">=11"
},
"scripts": {
"start": "rollup -c --watch",
"build": "npm run lint && npm run typecheck && npm run rollup",
"lint": "eslint .",
"build": "npm run lint && npm run rollup",
"lint": "eslint src/*.ts",
"rollup": "rollup -c",
"test": "vitest run",
"test": "./node_modules/karma/bin/karma start ./test/karma.conf.js",
"release-patch": "npm version patch && git push origin master --tags",
"release-minor": "npm version minor && git push origin master --tags",
"release-major": "npm version major && git push origin master --tags",
"start:ha": "docker run --rm -p8124:8123 -v $(pwd)/devtools/homeassistant:/config homeassistant/home-assistant:latest",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"format": "prettier --write .",
"format:check": "prettier --check ."
"release-major": "npm version major && git push origin master --tags"
},
"dependencies": {
"custom-card-helpers": "^2.0.0",
"home-assistant-js-websocket": "^9.7.0",
"lit": "^3.3.3"
"custom-card-helpers": "^1.6.4",
"home-assistant-js-websocket": "^5.1.0",
"lit-element": "^2.3.1",
"lit-html": "^1.2.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.3",
"@rollup/plugin-terser": "^1.0.0",
"@rollup/plugin-typescript": "^12.3.0",
"eslint": "^10.10.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-lit": "^2.3.1",
"eslint-plugin-wc": "^3.1.0",
"globals": "^17.12.0",
"happy-dom": "^20.14.5",
"prettier": "^3.9.6",
"rollup": "^4.63.3",
"rollup-plugin-serve": "^3.0.0",
"tslib": "^2.8.1",
"typescript": "~6.0.3",
"typescript-eslint": "^8.70.0",
"vitest": "^5.0.1"
"@babel/core": "^7.9.0",
"@open-wc/testing": "^2.5.16",
"@rollup/plugin-commonjs": "^11.1.0",
"@rollup/plugin-json": "^4.0.3",
"@rollup/plugin-node-resolve": "^7.1.1",
"@rollup/plugin-typescript": "^4.0.0",
"@types/chai": "^4.2.11",
"@typescript-eslint/eslint-plugin": "^2.30.0",
"@typescript-eslint/parser": "^2.30.0",
"eslint": "^6.8.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-prettier": "^3.1.3",
"karma": "^5.0.5",
"karma-chai": "^0.1.0",
"karma-chrome-launcher": "^3.1.0",
"karma-mocha": "^2.0.1",
"karma-rollup-preprocessor": "^7.0.5",
"mocha": "^7.1.2",
"prettier": "^2.0.5",
"rollup": "^1.32.1",
"rollup-plugin-babel": "^4.4.0",
"rollup-plugin-serve": "^1.0.1",
"rollup-plugin-terser": "^5.3.0",
"sinon": "^9.0.2",
"typescript": "^3.8.3"
}
}
+8 -4
View File
@@ -1,15 +1,16 @@
import nodeResolve from '@rollup/plugin-node-resolve';
import json from '@rollup/plugin-json';
import typescript from '@rollup/plugin-typescript';
import terser from '@rollup/plugin-terser';
import { terser } from 'rollup-plugin-terser';
import babel from 'rollup-plugin-babel';
import serve from 'rollup-plugin-serve';
const dev = process.env.ROLLUP_WATCH;
const serveOptions = {
contentBase: ['./dist'],
host: '127.0.0.1', // 'localhost' resolves to ::1 on Node 17+, but HA loads the card from 127.0.0.1
port: 5100, // 5000 is taken by the macOS AirPlay Receiver
host: '0.0.0.0',
port: 5000,
allowCrossOrigin: true,
headers: {
'Access-Control-Allow-Origin': '*',
@@ -23,9 +24,12 @@ export default {
format: 'es',
},
plugins: [
typescript({ exclude: ['test/**/*'] }),
nodeResolve(),
json(),
typescript(),
babel({
exclude: 'node_modules/**',
}),
dev && serve(serveOptions),
!dev && terser(),
],
-206
View File
@@ -1,206 +0,0 @@
import { noChange } from 'lit';
import { AttributePart, directive, Directive, DirectiveParameters } from 'lit/directive.js';
import { ActionHandlerDetail, ActionHandlerOptions, fireEvent } from 'custom-card-helpers';
const isTouch =
'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.maxTouchPoints > 0;
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
interface ActionHandler extends HTMLElement {
holdTime: number;
bind(element: Element, options?: ActionHandlerOptions): void;
}
interface ActionHandlerElement extends HTMLElement {
actionHandler?: boolean;
}
declare global {
interface HASSDomEvents {
action: ActionHandlerDetail;
}
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
class ActionHandler extends HTMLElement implements ActionHandler {
public holdTime = 500;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public ripple: any;
protected timer?: number;
protected held = false;
private dblClickTimeout?: number;
constructor() {
super();
this.ripple = document.createElement('mwc-ripple');
}
public connectedCallback(): void {
Object.assign(this.style, {
position: 'absolute',
width: isTouch ? '100px' : '50px',
height: isTouch ? '100px' : '50px',
transform: 'translate(-50%, -50%)',
pointerEvents: 'none',
zIndex: '999',
});
this.appendChild(this.ripple);
this.ripple.primary = true;
['touchcancel', 'mouseout', 'mouseup', 'touchmove', 'mousewheel', 'wheel', 'scroll'].forEach(
(ev) => {
document.addEventListener(
ev,
() => {
clearTimeout(this.timer);
this.stopAnimation();
this.timer = undefined;
},
{ passive: true },
);
},
);
}
public bind(element: ActionHandlerElement, options: ActionHandlerOptions = {}): void {
if (element.actionHandler) {
return;
}
element.actionHandler = true;
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;
});
const start = (ev: Event): void => {
this.held = false;
let x;
let y;
if ((ev as TouchEvent).touches) {
x = (ev as TouchEvent).touches[0].pageX;
y = (ev as TouchEvent).touches[0].pageY;
} else {
x = (ev as MouseEvent).pageX;
y = (ev as MouseEvent).pageY;
}
this.timer = window.setTimeout(() => {
this.startAnimation(x, y);
this.held = true;
}, this.holdTime);
};
const end = (ev: Event): void => {
// Prevent mouse event if touch event
ev.preventDefault();
if (['touchend', 'touchcancel'].includes(ev.type) && this.timer === undefined) {
return;
}
clearTimeout(this.timer);
this.stopAnimation();
this.timer = undefined;
if (this.held) {
fireEvent(element, '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(element, 'action', { action: 'tap' });
}, 250);
} else {
clearTimeout(this.dblClickTimeout);
this.dblClickTimeout = undefined;
fireEvent(element, 'action', { action: 'double_tap' });
}
} else {
fireEvent(element, 'action', { action: 'tap' });
}
};
const handleEnter = (ev: KeyboardEvent): void => {
if (ev.keyCode !== 13) {
return;
}
end(ev);
};
element.addEventListener('touchstart', start, { passive: true });
element.addEventListener('touchend', end);
element.addEventListener('touchcancel', end);
element.addEventListener('mousedown', start, { passive: true });
element.addEventListener('click', end);
element.addEventListener('keyup', handleEnter);
}
private startAnimation(x: number, y: number): void {
Object.assign(this.style, {
left: `${x}px`,
top: `${y}px`,
display: null,
});
this.ripple.disabled = false;
this.ripple.active = true;
this.ripple.unbounded = true;
}
private stopAnimation(): void {
this.ripple.active = false;
this.ripple.disabled = true;
this.style.display = 'none';
}
}
customElements.define('action-handler-time-picker-card', ActionHandler);
const getActionHandler = (): ActionHandler => {
const body = document.body;
if (body.querySelector('action-handler-time-picker-card')) {
return body.querySelector('action-handler-time-picker-card') as ActionHandler;
}
const actionhandler = document.createElement('action-handler-time-picker-card');
body.appendChild(actionhandler);
return actionhandler as ActionHandler;
};
export const actionHandlerBind = (
element: ActionHandlerElement,
options?: ActionHandlerOptions,
): void => {
const actionhandler: ActionHandler = getActionHandler();
if (!actionhandler) {
return;
}
actionhandler.bind(element, options);
};
export const actionHandler = directive(
class extends Directive {
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
update(part: AttributePart, [options]: DirectiveParameters<this>) {
actionHandlerBind(part.element as ActionHandlerElement, options);
return noChange;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/explicit-function-return-type
render(_options?: ActionHandlerOptions) {}
},
);
+11 -4
View File
@@ -1,6 +1,13 @@
import { css, CSSResult, html, LitElement, TemplateResult } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { ClassInfo, classMap } from 'lit/directives/class-map.js';
import {
css,
CSSResult,
customElement,
html,
LitElement,
property,
TemplateResult,
} from 'lit-element';
import { ClassInfo, classMap } from 'lit-html/directives/class-map';
import { Layout, Period } from '../types';
/**
@@ -56,7 +63,7 @@ export class TimePeriodComponent extends LitElement {
.time-period {
width: 30px;
padding: var(--tpc-control-padding);
padding: 8px;
background: var(--tpc-off-color);
color: var(--tpc-text-color);
text-align: center;
+11 -4
View File
@@ -1,5 +1,12 @@
import { LitElement, TemplateResult, html, CSSResult, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import {
css,
CSSResult,
customElement,
html,
LitElement,
property,
TemplateResult,
} from 'lit-element';
import { TimeUnit } from '../models/time-unit';
import { Direction } from '../types';
@@ -62,7 +69,7 @@ export class TimeUnitComponent extends LitElement {
.time-picker-icon {
width: 30px;
padding: var(--tpc-control-padding);
padding: 8px;
text-align: center;
cursor: pointer;
color: var(--tpc-icon-color);
@@ -70,7 +77,7 @@ export class TimeUnitComponent extends LitElement {
.time-input {
width: 30px;
padding: var(--tpc-control-padding);
padding: 8px 8px 6px;
background: var(--tpc-elements-background-color);
border: 0;
border-bottom: 2px solid var(--tpc-elements-background-color);
+1 -3
View File
@@ -9,8 +9,6 @@ export const ENTITY_DOMAIN = 'input_datetime';
// Config defaults
export const DEFAULT_HOUR_STEP = 1;
export const DEFAULT_MINUTE_STEP = 5;
export const DEFAULT_SECOND_STEP = 5;
export const DEFAULT_HOUR_MODE = 24;
export const DEFAULT_LAYOUT_HOUR_MODE: Layout.HourMode = 'double';
export const DEFAULT_LAYOUT_HOUR_MODE = 'double';
export const DEFAULT_LAYOUT_ALIGN_CONTROLS = Layout.AlignControls.CENTER;
export const DEFAULT_LAYOUT_NAME = Layout.Name.HEADER;
+216 -137
View File
@@ -1,158 +1,218 @@
import { HomeAssistant, LovelaceCardEditor } from 'custom-card-helpers';
import { supportsEntityNameSelector } from './entity-name';
import { html, LitElement, TemplateResult } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { TimePickerCardConfig } from './types';
const NAME_TO_LABEL_MAP: Record<string, string> = {
entity: 'input_datetime entity id',
name: 'Name',
hour_step: 'Hour step',
minute_step: 'Minute step',
hour_mode: 'Hour mode',
link_values: 'Link values',
align_controls: 'Align controls',
embedded: 'Embedded?',
thin: 'Thin layout?',
icon: 'Icon',
seconds: 'Seconds',
};
type FormSchemaEntry = Record<string, unknown>;
// The entity_name selector, which lets users compose a name out of registry
// parts, was added in HA 2025.11. Older versions fall back to a plain text field.
const nameSchema = (hass: HomeAssistant): FormSchemaEntry =>
supportsEntityNameSelector(hass)
? { name: 'name', selector: { entity_name: {} }, context: { entity: 'entity' } }
: { name: 'name', selector: { text: {} } };
const schema = (hass: HomeAssistant): FormSchemaEntry[] => [
{ name: 'entity', selector: { entity: { domain: 'input_datetime' } } },
nameSchema(hass),
{
type: 'grid',
schema: [
{
name: 'hour_step',
type: 'integer',
required: true,
default: 1,
valueMin: 1,
valueMax: 24,
},
{
name: 'minute_step',
type: 'integer',
required: true,
default: 5,
valueMin: 1,
valueMax: 60,
},
{
name: 'hour_mode',
type: 'select',
options: [
[12, '12'],
[24, '24'],
],
},
{ name: 'link_values', type: 'boolean' },
],
},
{
type: 'expandable',
name: 'layout',
title: 'Layout controls',
schema: [
{
name: 'hour_mode',
type: 'select',
options: [
['single', 'single'],
['double', 'double'],
],
},
{
name: 'align_controls',
type: 'select',
options: [
['left', 'left'],
['center', 'center'],
['right', 'right'],
],
},
{
name: 'name',
type: 'select',
options: [
['header', 'header'],
['inside', 'inside'],
],
},
{ name: 'embedded', type: 'boolean' },
{ name: 'thin', type: 'boolean' },
],
},
{
type: 'expandable',
name: 'hide',
title: 'Hide controls',
schema: [
{
type: 'grid',
name: '',
schema: [
{ name: 'name', type: 'boolean' },
{ name: 'icon', type: 'boolean' },
{ name: 'seconds', type: 'boolean' },
],
},
],
},
{
type: 'expandable',
title: 'Actions',
schema: [
{ name: 'tap_action', selector: { action: {} } },
{ name: 'double_tap_action', selector: { action: {} } },
{ name: 'hold_action', selector: { action: {} } },
],
},
];
import { computeDomain, HomeAssistant, LovelaceCardEditor } from 'custom-card-helpers';
import { HassEntity } from 'home-assistant-js-websocket';
import {
css,
CSSResult,
customElement,
html,
LitElement,
property,
TemplateResult,
} from 'lit-element';
import {
DEFAULT_HOUR_STEP,
DEFAULT_MINUTE_STEP,
ENTITY_DOMAIN,
DEFAULT_LAYOUT_ALIGN_CONTROLS,
DEFAULT_LAYOUT_NAME,
} from './const';
import { TimePickerCardConfig, Layout, HourMode } from './types';
@customElement('time-picker-card-editor')
export class TimePickerCardEditor extends LitElement implements LovelaceCardEditor {
private static readonly NUMBER_PROPERTIES = ['hour_step', 'minute_step', 'hour_mode'];
private static readonly CONFIG_CHANGED_EVENT = 'config-changed';
@property({ type: Object }) hass!: HomeAssistant;
@property() private config!: TimePickerCardConfig;
private computeLabel({ name }: { name: string }): string {
return NAME_TO_LABEL_MAP[name] || name;
private get entity(): HassEntity | undefined {
return this.hass.states[this.config.entity];
}
private valueChanged(ev: CustomEvent): void {
const newConfig = { ...this.config, ...ev.detail.value };
this.dispatch(newConfig);
private get datetimeEntities(): Array<string> {
return Object.keys(this.hass.states).filter(
(entityId) =>
computeDomain(entityId) === ENTITY_DOMAIN &&
this.hass.states[entityId].attributes.has_time === true
);
}
render(): TemplateResult {
return html`
<ha-form
.hass=${this.hass}
.data=${this.config}
.schema=${schema(this.hass)}
.computeLabel=${this.computeLabel}
@value-changed=${this.valueChanged}
></ha-form>
`;
return html`<div class="card-config">
<paper-dropdown-menu
style="width: 100%"
label="Entity (Required)"
.configValue=${'entity'}
@value-changed=${this.onValueChange}
>
<paper-listbox
slot="dropdown-content"
.selected=${this.datetimeEntities.indexOf(this.config.entity)}
>
${this.datetimeEntities.map((entity) => html`<paper-item>${entity}</paper-item>`)}
</paper-listbox>
</paper-dropdown-menu>
<paper-input
label="Name (Optional)"
.configValue=${'name'}
.value=${this.config.name}
.placeholder=${this.entity?.attributes.friendly_name}
@value-changed=${this.onValueChange}
></paper-input>
<div class="side-by-side">
<div>
<ha-switch
style="margin-left: 10px"
.checked="${!Boolean(this.config.hide?.name)}"
.configValue="${'hide_name'}"
@change="${this.onHideNameChange}"
></ha-switch>
Show name?
</div>
<paper-dropdown-menu
style="width: 100%"
label="Name Position (Optional)"
@value-changed=${this.onLayoutNameChange}
>
<paper-listbox
slot="dropdown-content"
.selected=${Object.values(Layout.Name).indexOf(
this.config.layout?.name ?? DEFAULT_LAYOUT_NAME
)}
>
${Object.values(Layout.Name).map((name) => html`<paper-item>${name}</paper-item>`)}
</paper-listbox>
</paper-dropdown-menu>
</div>
<div class="side-by-side">
<paper-input
type="number"
label="Hour Step (Optional)"
.configValue=${'hour_step'}
.value=${this.config.hour_step || DEFAULT_HOUR_STEP}
@value-changed=${this.onValueChange}
></paper-input>
<paper-input
type="number"
label="Minute Step (Optional)"
.configValue=${'minute_step'}
.value=${this.config.minute_step || DEFAULT_MINUTE_STEP}
@value-changed=${this.onValueChange}
></paper-input>
</div>
<div class="side-by-side">
<div>
<ha-switch
.checked="${this.config.hour_mode === 12}"
.configValue="${'hour_mode'}"
@change="${this.onHourModeChange}"
></ha-switch>
12-Hour mode
</div>
${this.config.hour_mode === 12
? html`<div>
<ha-switch
.checked="${this.config.layout?.hour_mode === 'single'}"
.configValue="${'hour_mode_layout'}"
@change="${this.onHourModeLayoutChange}"
></ha-switch>
"Single" hour mode layout
</div>`
: ''}
</div>
<div class="side-by-side">
<paper-dropdown-menu
style="width: 100%"
label="Align Controls (Optional)"
@value-changed=${this.onLayoutAlignChange}
>
<paper-listbox
slot="dropdown-content"
.selected=${Object.values(Layout.AlignControls).indexOf(
this.config.layout?.align_controls ?? DEFAULT_LAYOUT_ALIGN_CONTROLS
)}
>
${Object.values(Layout.AlignControls).map((a) => html`<paper-item>${a}</paper-item>`)}
</paper-listbox>
</paper-dropdown-menu>
<div>
<ha-switch
.checked="${this.config.link_values}"
.configValue="${'link_values'}"
@change="${this.onLinkValuesChange}"
></ha-switch>
Link Values
</div>
</div>
</div>`;
}
setConfig(config: TimePickerCardConfig): void {
setConfig(config): void {
this.config = config;
}
private onHourModeChange({ target: { checked } }): void {
const hourMode: HourMode = checked ? 12 : 24;
const newConfig = { ...this.config, hour_mode: hourMode };
this.dispatch(newConfig);
}
private onHideNameChange({ target: { checked } }): void {
const newConfig = { ...this.config, hide: { name: !checked } };
this.dispatch(newConfig);
}
private onLayoutAlignChange({ detail: { value } }: CustomEvent): void {
const layout = { ...this.config.layout, align_controls: value };
const newConfig = { ...this.config, layout };
this.dispatch(newConfig);
}
private onLayoutNameChange({ detail: { value } }: CustomEvent): void {
const layout = { ...this.config.layout, name: value };
const newConfig = { ...this.config, layout };
this.dispatch(newConfig);
}
private onHourModeLayoutChange({ target: { checked } }): void {
const hourModeLayout: Layout.HourMode = checked ? 'single' : 'double';
const newConfig = {
...this.config,
layout: { ...this.config.layout, hour_mode: hourModeLayout },
};
this.dispatch(newConfig);
}
private onLinkValuesChange({ target: { checked } }): void {
const newConfig = { ...this.config, link_values: checked };
this.dispatch(newConfig);
}
private onValueChange(e: CustomEvent): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const property = (e.target as any).configValue;
let value = e.detail.value;
if (TimePickerCardEditor.NUMBER_PROPERTIES.indexOf(property) > -1) {
value = parseInt(value);
if (isNaN(value)) {
return;
}
}
if (this.config[property] === value) {
return;
}
const newConfig = { ...this.config, [property]: value };
this.dispatch(newConfig);
}
private dispatch(config: TimePickerCardConfig): void {
const event = new CustomEvent(TimePickerCardEditor.CONFIG_CHANGED_EVENT, {
bubbles: true,
@@ -162,4 +222,23 @@ export class TimePickerCardEditor extends LitElement implements LovelaceCardEdit
this.dispatchEvent(event);
}
static get styles(): CSSResult {
return css`
ha-switch {
padding: 16px 0;
margin-right: 16px;
}
.side-by-side {
display: flex;
}
.side-by-side > * {
flex: 1;
padding-right: 4px;
}
.suffix {
margin: 0 8px;
}
`;
}
}
-55
View File
@@ -1,55 +0,0 @@
import { HomeAssistant } from 'custom-card-helpers';
import { HassEntity } from 'home-assistant-js-websocket';
import { EntityName } from './types';
// hass.formatEntityName only accepts a card's `name` option (a user string, a
// structured name, or undefined) from HA 2026.4. Earlier versions expose the
// same helper with an incompatible signature, so feature detection is not
// enough - the version has to be checked.
function atLeastVersion(hass: HomeAssistant, major: number, minor: number): boolean {
const [haMajor, haMinor] = (hass.config?.version ?? '').split('.', 2);
return Number(haMajor) > major || (Number(haMajor) === major && Number(haMinor) >= minor);
}
function supportsEntityNames(hass: HomeAssistant): boolean {
// A hass can report a recent version without carrying the helper (a test
// harness, or a hass that has not finished initialising), and calling it
// then throws - so the version gate alone is not enough.
if (!hass || typeof (hass as { formatEntityName?: unknown }).formatEntityName !== 'function') {
return false;
}
return atLeastVersion(hass, 2026, 4);
}
/**
* The `entity_name` selector, which lets users compose a name out of registry
* parts in the visual editor, was added in HA 2025.11.
*/
export function supportsEntityNameSelector(hass: HomeAssistant): boolean {
return atLeastVersion(hass, 2025, 11);
}
type HassWithEntityNames = HomeAssistant & {
formatEntityName: (stateObj: HassEntity, name: EntityName | undefined) => string;
};
/**
* Resolves a `name` option against the entity's registry context (entity,
* device, area, floor). Falls back to the friendly name on Home Assistant
* versions that cannot resolve a structured name.
*/
export function computeEntityName(
hass: HomeAssistant,
stateObj: HassEntity | undefined,
name: EntityName | undefined,
): string | undefined {
const configuredName = typeof name === 'string' ? name : undefined;
if (!stateObj) {
return configuredName;
}
if (supportsEntityNames(hass)) {
return (hass as HassWithEntityNames).formatEntityName(stateObj, name);
}
return configuredName ?? stateObj.attributes.friendly_name;
}
+1 -5
View File
@@ -8,11 +8,7 @@ import { TimeUnit } from './time-unit';
export class Hour extends TimeUnit {
private static readonly VALUE_LIMIT = 24;
constructor(
value: number,
step = DEFAULT_HOUR_STEP,
private hourMode?: HourMode,
) {
constructor(value: number, step = DEFAULT_HOUR_STEP, private hourMode?: HourMode) {
super(value, step, Hour.VALUE_LIMIT);
}
-26
View File
@@ -1,26 +0,0 @@
import { DEFAULT_SECOND_STEP } from '../const';
import { TimeUnit } from './time-unit';
import { Direction } from '../types';
/**
* * Represents the second value of a datetime.
*/
export class Second extends TimeUnit {
private static readonly VALUE_LIMIT = 60;
minValue = 0;
maxValue = Second.VALUE_LIMIT - 1;
constructor(value: number, step = DEFAULT_SECOND_STEP) {
super(value, step, Second.VALUE_LIMIT);
}
/**
* Returns true if the seconds will overflow to a different hour when changed in {@param direction}.
* @param direction
*/
willOverflow(direction: Direction): boolean {
const newValue = direction === Direction.UP ? this.value + this._step : this.value - this._step;
return newValue >= this._limit || newValue < 0;
}
}
+2 -10
View File
@@ -19,11 +19,7 @@ export abstract class TimeUnit {
* @param _step how much to increase / decrease the value when step-changing
* @param _limit value upper limit
*/
constructor(
private _value: number,
protected _step: number,
protected _limit: number,
) {}
constructor(private _value: number, protected _step: number, protected _limit: number) {}
get value(): number {
return this._value;
@@ -44,11 +40,7 @@ export abstract class TimeUnit {
* @param direction
*/
stepUpdate(direction: Direction, step: number = this._step): void {
if (direction === Direction.UP) {
this.increment(step);
} else {
this.decrement(step);
}
direction === Direction.UP ? this.increment(step) : this.decrement(step);
}
toString(): string {
+3 -17
View File
@@ -1,15 +1,9 @@
import { Direction } from '../types';
import { Hour } from './hour';
import { Minute } from './minute';
import { Second } from './second';
import { Direction } from '../types';
export class Time {
constructor(
public hour: Hour,
public minute: Minute,
public second: Second,
private _linkValues: boolean = false,
) {}
constructor(public hour: Hour, public minute: Minute, private _linkValues: boolean = false) {}
hourStep(direction: Direction): void {
this.hour.stepUpdate(direction);
@@ -23,15 +17,7 @@ export class Time {
this.minute.stepUpdate(direction);
}
secondStep(direction: Direction): void {
if (this._linkValues && this.second.willOverflow(direction)) {
this.minute.stepUpdate(direction, 1);
}
this.second.stepUpdate(direction);
}
get value(): string {
return `${this.hour.value}:${this.minute.value}:${this.second.value}`;
return `${this.hour.value}:${this.minute.value}`;
}
}
+16 -2
View File
@@ -1,6 +1,7 @@
import { LovelaceCard } from 'custom-card-helpers';
import { html, TemplateResult } from 'lit';
import { html, TemplateResult } from 'lit-element';
import { TimePickerCardConfig } from './types';
import { LovelaceCard } from 'custom-card-helpers';
import { HassEntity } from 'home-assistant-js-websocket';
export class Partial {
static error(error: string, origConfig: TimePickerCardConfig): TemplateResult {
@@ -13,4 +14,17 @@ export class Partial {
return html`${errorCard}`;
}
static headerName(title: string): TemplateResult {
return html`
<div class="time-picker-header">
${title}
</div>
`;
}
static nestedName(name: string, entity: HassEntity): TemplateResult {
return html`<state-badge .stateObj=${entity}></state-badge>
<div class="entity-name-inside">${name}</div>`;
}
}
+47 -186
View File
@@ -1,42 +1,36 @@
import {
ActionHandlerEvent,
computeDomain,
handleAction,
hasAction,
HomeAssistant,
LovelaceCard,
} from 'custom-card-helpers';
import { computeDomain, HomeAssistant, LovelaceCard } from 'custom-card-helpers';
import { HassEntity } from 'home-assistant-js-websocket';
import { css, CSSResult, html, LitElement, TemplateResult } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { ClassInfo, classMap } from 'lit/directives/class-map.js';
import { actionHandler } from './action-handler-directive';
import { computeEntityName } from './entity-name';
import './components/time-period.component';
import './components/time-unit.component';
import {
css,
CSSResult,
customElement,
html,
LitElement,
property,
TemplateResult,
} from 'lit-element';
import { ClassInfo, classMap } from 'lit-html/directives/class-map';
import {
CARD_SIZE,
CARD_VERSION,
DEFAULT_HOUR_MODE,
DEFAULT_HOUR_STEP,
DEFAULT_LAYOUT_ALIGN_CONTROLS,
DEFAULT_LAYOUT_HOUR_MODE,
DEFAULT_LAYOUT_NAME,
DEFAULT_MINUTE_STEP,
ENTITY_DOMAIN,
DEFAULT_LAYOUT_ALIGN_CONTROLS,
} from './const';
import './editor';
import { Hour } from './models/hour';
import { Minute } from './models/minute';
import { Second } from './models/second';
import { Time } from './models/time';
import { Partial } from './partials';
import { Layout, Period, TimePickerCardConfig, TimePickerHideConfig } from './types';
import { Period, TimePickerCardConfig, Layout } from './types';
import './components/time-period.component';
import './components/time-unit.component';
import './editor';
console.info(
`%c TIME-PICKER-CARD \n%c Version ${CARD_VERSION} `,
'color: orange; font-weight: bold; background: black',
'color: white; font-weight: bold; background: dimgray',
'color: white; font-weight: bold; background: dimgray'
);
window.customCards = window.customCards || [];
@@ -53,34 +47,28 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
@property() private time!: Time;
@property() private period!: Period;
private bounce: number | undefined;
private get entity(): HassEntity | undefined {
return this.hass.states[this.config.entity];
}
private get isEmbedded(): boolean {
return this.config.layout?.embedded === true;
}
private get hasNameInHeader(): boolean {
return (
Boolean(this.name) &&
Boolean(this.config.hide?.name) === false &&
this.config.layout?.name !== Layout.Name.INSIDE &&
Boolean(this.config.layout?.embedded) === false // embedded layout disables name in header
this.config.layout?.name !== Layout.Name.INSIDE
);
}
private get hasNameInside(): boolean {
return (
Boolean(this.name) &&
(this.config.layout?.name === Layout.Name.INSIDE || Boolean(this.config.layout?.embedded))
Boolean(this.config.hide?.name) === false &&
this.config.layout?.name === Layout.Name.INSIDE
);
}
private get name(): string | undefined {
return computeEntityName(this.hass, this.entity, this.config.name);
return this.config.name || this.entity?.attributes.friendly_name;
}
private get shouldShowPeriod(): boolean {
@@ -91,18 +79,10 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
return this.config.layout?.align_controls ?? DEFAULT_LAYOUT_ALIGN_CONTROLS;
}
private get haCardClass(): ClassInfo {
return {
embedded: this.isEmbedded,
thin: this.config.layout?.thin === true,
};
}
private get rowClass(): ClassInfo {
return {
'time-picker-row': true,
'with-header-name': this.hasNameInHeader,
embedded: this.isEmbedded,
};
}
@@ -113,58 +93,6 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
};
}
private handleAction(ev: ActionHandlerEvent): void {
handleAction(this, this.hass, this.config, ev.detail.action!);
}
private renderHeaderName(title: string): TemplateResult {
return html`<div
class="time-picker-header"
@action=${this.handleAction}
.actionHandler="${actionHandler({
hasHold: hasAction(this.config.hold_action),
hasDoubleClick: hasAction(this.config.double_tap_action),
})}"
>
${title}
</div>`;
}
private renderNestedName(
name: string,
entity: HassEntity,
hide?: TimePickerHideConfig,
): TemplateResult {
const icon = html`<state-badge
class="entity-icon"
.stateObj=${entity}
@action=${this.handleAction}
.actionHandler="${actionHandler({
hasHold: hasAction(this.config.hold_action),
hasDoubleClick: hasAction(this.config.double_tap_action),
})}"
></state-badge>`;
const label = html`<div
class="entity-name-inside"
@action=${this.handleAction}
.actionHandler="${actionHandler({
hasHold: hasAction(this.config!.hold_action),
hasDoubleClick: hasAction(this.config.double_tap_action),
})}"
>
${name}
</div>`;
const visibleElements = [
{ element: icon, visible: !hide?.icon },
{ element: label, visible: !hide?.name },
]
.filter(({ visible }) => visible)
.map(({ element }) => element);
return html`${visibleElements}`;
}
render(): TemplateResult | null {
if (!this.entity) {
return Partial.error('Entity not found', this.config);
@@ -177,65 +105,49 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
if (!this.entity.attributes.has_time) {
return Partial.error(
`You must set an ${ENTITY_DOMAIN} entity that sets has_time: true`,
this.config,
this.config
);
}
const { hour, minute, second } = this.entity!.attributes;
const { hour, minute } = this.entity!.attributes;
const hourInstance = new Hour(hour, this.config.hour_step, this.config.hour_mode);
const minuteInstance = new Minute(minute, this.config.minute_step);
const secondInstance = new Second(second, this.config.second_step);
this.time = new Time(hourInstance, minuteInstance, secondInstance, this.config.link_values);
this.time = new Time(hourInstance, minuteInstance, this.config.link_values);
this.period = hourInstance.value >= 12 ? Period.PM : Period.AM;
return html`
<ha-card class=${classMap(this.haCardClass)}>
${this.hasNameInHeader ? this.renderHeaderName(this.name!) : ''}
<ha-card>
${this.hasNameInHeader ? Partial.headerName(this.name!) : ''}
<div class=${classMap(this.rowClass)}>
${
this.hasNameInside
? this.renderNestedName(this.name!, this.entity, this.config.hide)
: ''
}
${this.hasNameInside ? Partial.nestedName(this.name!, this.entity) : ''}
<div class=${classMap(this.contentClass)}>
<time-unit
.unit=${this.time.hour}
@stepChange=${this.onHourStepChange}
@update=${this.debouncedCallHassService}
@update=${this.callHassService}
></time-unit>
<div class="time-separator">:</div>
<time-unit
.unit=${this.time.minute}
@stepChange=${this.onMinuteStepChange}
@update=${this.debouncedCallHassService}
@update=${this.callHassService}
></time-unit>
${
this.config.hide?.seconds === false
? html`<div class="time-separator">:</div>
<time-unit
.unit=${this.time.second}
@stepChange=${this.onSecondStepChange}
@update=${this.debouncedCallHassService}
></time-unit>`
: ''
}
${
this.shouldShowPeriod
? html`<time-period
.period=${this.period}
.mode=${this.config.layout?.hour_mode ?? DEFAULT_LAYOUT_HOUR_MODE}
@toggle=${this.onPeriodToggle}
></time-period>`
: ''
}
${this.shouldShowPeriod
? html`<time-period
.period=${this.period}
.mode=${this.config.layout?.hour_mode ?? DEFAULT_LAYOUT_HOUR_MODE}
@toggle=${this.onPeriodToggle}
></time-period>`
: ''}
</div>
</div>
</ha-card>
`;
}
setConfig(config: TimePickerCardConfig): void {
setConfig(config): void {
if (!config) {
throw new Error('Invalid configuration');
}
@@ -257,31 +169,17 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
private onPeriodToggle(): void {
this.time.hour.togglePeriod();
this.debouncedCallHassService();
this.callHassService();
}
private onHourStepChange(event: CustomEvent): void {
this.time.hourStep(event.detail.direction);
this.debouncedCallHassService();
this.callHassService();
}
private onMinuteStepChange(event: CustomEvent): void {
this.time.minuteStep(event.detail.direction);
this.debouncedCallHassService();
}
private onSecondStepChange(event: CustomEvent): void {
this.time.secondStep(event.detail.direction);
this.debouncedCallHassService();
}
private debouncedCallHassService(): void {
if (this.config.delay) {
clearTimeout(this.bounce);
this.bounce = setTimeout(() => this.callHassService(), this.config.delay, this);
} else {
this.callHassService();
}
this.callHassService();
}
private callHassService(): Promise<void> {
@@ -303,8 +201,6 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
var(--primary-color)
);
--tpc-control-padding: var(--time-picker-control-padding, 8px);
--tpc-icon-color: var(--time-picker-icon-color, var(--primary-text-color));
--tpc-text-color: var(--time-picker-text-color, #fff);
--tpc-accent-color: var(--time-picker-accent-color, var(--primary-color));
@@ -313,16 +209,6 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
--tpc-border-radius: var(--time-picker-border-radius, var(--ha-card-border-radius, 4px));
}
ha-card {
overflow: auto;
}
ha-card.embedded {
box-shadow: none;
border: none;
background: transparent;
}
.time-picker-header {
padding: 16px;
color: var(--tpc-text-color);
@@ -333,10 +219,6 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
text-align: center;
}
.thin > .time-picker-header {
padding: 4px;
}
.time-picker-row {
display: flex;
flex-direction: row;
@@ -344,14 +226,6 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
padding: 16px;
}
.thin .time-picker-row {
padding: 0 !important ;
}
.time-picker-row.embedded {
padding: 0;
}
.time-picker-row.with-header-name {
padding: 8px 16px 16px;
}
@@ -375,36 +249,23 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
justify-content: flex-end;
}
.entity-icon {
cursor: pointer;
}
.entity-name-inside {
margin-left: 16px;
cursor: pointer;
}
`;
}
static getStubConfig(
_: HomeAssistant,
entities: Array<string>,
entities: Array<string>
): Omit<TimePickerCardConfig, 'type'> {
const datetimeEntity = entities.find((entityId) => computeDomain(entityId) === ENTITY_DOMAIN);
return {
entity: datetimeEntity || '',
hour_mode: DEFAULT_HOUR_MODE,
hour_step: DEFAULT_HOUR_STEP,
minute_step: DEFAULT_MINUTE_STEP,
layout: {
hour_mode: DEFAULT_LAYOUT_HOUR_MODE,
align_controls: DEFAULT_LAYOUT_ALIGN_CONTROLS,
name: DEFAULT_LAYOUT_NAME,
},
hide: {
seconds: true,
},
entity: datetimeEntity || 'input_datetime.example_entity',
hour_mode: 24,
hour_step: 1,
minute_step: 5,
};
}
+2 -18
View File
@@ -1,36 +1,22 @@
import { ActionConfig, LovelaceCardConfig } from 'custom-card-helpers';
import { LovelaceCardConfig } from 'custom-card-helpers';
export interface TimePickerCardConfig extends LovelaceCardConfig {
entity: string;
name?: EntityName;
name?: string;
link_values?: boolean;
hour_mode?: HourMode;
hour_step?: number;
minute_step?: number;
second_step?: number;
delay?: number;
layout?: TimePickerLayoutConfig;
hide?: TimePickerHideConfig;
tap_action?: ActionConfig;
double_tap_action?: ActionConfig;
hold_action?: ActionConfig;
}
/** A `name` option: a plain string, or name parts resolved from the registry. */
export type EntityName = string | EntityNameItem | EntityNameItem[];
export type EntityNameItem =
| { type: 'entity' | 'device' | 'parent_device' | 'area' | 'floor' }
| { type: 'text'; text: string };
export type HourMode = 12 | 24 | undefined;
export interface TimePickerLayoutConfig {
align_controls?: Layout.AlignControls;
name?: Layout.Name;
hour_mode?: Layout.HourMode;
embedded?: boolean;
thin?: boolean;
}
export namespace Layout {
@@ -50,8 +36,6 @@ export namespace Layout {
export interface TimePickerHideConfig {
name?: boolean;
icon?: boolean;
seconds?: boolean;
}
export enum Direction {
+17
View File
@@ -0,0 +1,17 @@
import { LovelaceCard } from 'custom-card-helpers';
import { customElement, html, LitElement, TemplateResult } from 'lit-element';
@customElement('hui-error-card')
export class HuiErrorCard extends LitElement implements LovelaceCard {
setConfig(): void {
return;
}
render(): TemplateResult {
return html``;
}
getCardSize(): number {
return 1;
}
}
+111
View File
@@ -0,0 +1,111 @@
import { expect, fixture, html } from '@open-wc/testing';
import { HomeAssistant } from 'custom-card-helpers';
import { HassEntity } from 'home-assistant-js-websocket';
import '../../src/time-picker-card';
import { TimePickerCard } from '../../src/time-picker-card';
import { TimePickerCardConfig, HourMode } from '../../src/types';
import './test-error-card';
async function render(config?: {
entityId?: string;
friendlyName?: string;
hasTime?: boolean;
hourMode?: HourMode;
hideName?: boolean;
}): Promise<TimePickerCard> {
const { entityId, friendlyName, hasTime, hourMode, hideName } = Object.assign(
{
entityId: 'input_datetime.wake_time',
friendlyName: 'Wake Time',
hasTime: true,
hideName: false,
},
config
);
const testEntity: HassEntity = {
entity_id: entityId,
attributes: {
friendly_name: friendlyName,
has_time: hasTime,
},
state: '00:10:00',
last_changed: '',
last_updated: '',
context: {
id: entityId,
user_id: null,
},
};
const hass: Partial<HomeAssistant> = {
states: {
[entityId]: testEntity,
},
};
const testConfig: TimePickerCardConfig = {
type: 'custom:time-picker-card',
entity: entityId,
hour_mode: hourMode,
hide: {
name: hideName,
},
};
const card = await fixture<TimePickerCard>(
html`<time-picker-card .hass=${hass} .config=${testConfig}></time-picker-card>`
);
card.setConfig(testConfig);
return card;
}
function errorCard(): string {
return html`<hui-error-card></hui-error-card>`.getHTML();
}
describe('test', () => {
it('renders', async () => {
const card = await render();
expect(card).shadowDom.to.match('time-picker-card');
});
it('errors if entity is not an input_datetime', async () => {
const card = await render({ entityId: 'sensor.test_sensor' });
expect(card).shadowDom.to.equal(errorCard());
});
it("errors if entity doesn't have time", async () => {
const card = await render({ hasTime: false });
expect(card).shadowDom.to.equal(errorCard());
});
it('renders card name', async () => {
const card = await render();
expect(card.shadowRoot?.innerHTML).to.match(/Wake Time/);
});
it('hides card name', async () => {
const card = await render({ hideName: true });
expect(card.shadowRoot?.innerHTML).not.to.match(/Wake Time/);
});
it('renders time units', async () => {
const card = await render();
expect(card.shadowRoot?.innerHTML.match(/<time-unit>/g)!.length).to.equal(2);
});
describe('time-period', () => {
it('is not rendered by default', async () => {
const card = await render();
expect(card.shadowRoot?.innerHTML.match(/<time-period>/g)).to.be.null;
});
it('renders in 12-hour mode', async () => {
const card = await render({ hourMode: 12 });
expect(card.shadowRoot?.innerHTML.match(/<time-period>/g)!.length).to.equal(1);
});
});
});
-78
View File
@@ -1,78 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { HomeAssistant } from 'custom-card-helpers';
import { HassEntity } from 'home-assistant-js-websocket';
import { computeEntityName, supportsEntityNameSelector } from '../src/entity-name';
const entity: HassEntity = {
entity_id: 'input_datetime.alarm',
state: '07:30:00',
attributes: { friendly_name: 'Alarm' },
last_changed: '',
last_updated: '',
context: { id: '', user_id: null, parent_id: null },
};
const hassWithVersion = (
version: string | undefined,
extra: Record<string, unknown> = {},
): HomeAssistant =>
({ config: version ? { version } : undefined, ...extra }) as unknown as HomeAssistant;
describe('supportsEntityNameSelector', () => {
it('is false before Home Assistant 2025.11', () => {
expect(supportsEntityNameSelector(hassWithVersion('2025.10.3'))).toBe(false);
expect(supportsEntityNameSelector(hassWithVersion('2024.12.0'))).toBe(false);
});
it('is true from Home Assistant 2025.11', () => {
expect(supportsEntityNameSelector(hassWithVersion('2025.11.0'))).toBe(true);
expect(supportsEntityNameSelector(hassWithVersion('2026.1.0'))).toBe(true);
});
it('is false when the version is unknown', () => {
expect(supportsEntityNameSelector(hassWithVersion(undefined))).toBe(false);
});
});
describe('computeEntityName', () => {
it('returns the configured string when there is no entity', () => {
expect(computeEntityName(hassWithVersion('2026.9.0'), undefined, 'Wake up')).toEqual('Wake up');
expect(computeEntityName(hassWithVersion('2026.9.0'), undefined, undefined)).toBeUndefined();
expect(
computeEntityName(hassWithVersion('2026.9.0'), undefined, [{ type: 'area' }]),
).toBeUndefined();
});
it('delegates to hass.formatEntityName from Home Assistant 2026.4', () => {
const formatEntityName = vi.fn().mockReturnValue('Bedroom Alarm');
const hass = hassWithVersion('2026.4.0', { formatEntityName });
const name = [{ type: 'area' as const }, { type: 'entity' as const }];
expect(computeEntityName(hass, entity, name)).toEqual('Bedroom Alarm');
expect(formatEntityName).toHaveBeenCalledWith(entity, name);
});
it('passes an undefined name through to hass.formatEntityName', () => {
const formatEntityName = vi.fn().mockReturnValue('Alarm');
computeEntityName(hassWithVersion('2026.4.0', { formatEntityName }), entity, undefined);
expect(formatEntityName).toHaveBeenCalledWith(entity, undefined);
});
it('falls back to the friendly name before Home Assistant 2026.4', () => {
const formatEntityName = vi.fn();
const hass = hassWithVersion('2026.3.2', { formatEntityName });
expect(computeEntityName(hass, entity, [{ type: 'area' }])).toEqual('Alarm');
expect(computeEntityName(hass, entity, undefined)).toEqual('Alarm');
expect(formatEntityName).not.toHaveBeenCalled();
});
it('prefers a configured string over the friendly name on older versions', () => {
expect(computeEntityName(hassWithVersion('2026.3.2'), entity, 'Wake up')).toEqual('Wake up');
});
it('falls back when a recent hass does not carry formatEntityName', () => {
expect(computeEntityName(hassWithVersion('2026.9.0'), entity, 'Wake up')).toEqual('Wake up');
expect(computeEntityName(hassWithVersion('2026.9.0'), entity, undefined)).toEqual('Alarm');
});
});
+45
View File
@@ -0,0 +1,45 @@
module.exports = function (config) {
return config.set({
basePath: './',
frameworks: ['mocha', 'chai'],
files: ['**/*.test.ts'],
preprocessors: {
'**/*.test.ts': ['rollup'],
},
rollupPreprocessor: {
plugins: [
require('@rollup/plugin-node-resolve')(),
require('@rollup/plugin-json')(),
require('@rollup/plugin-typescript')(),
require('@rollup/plugin-commonjs')({
namedExports: {
chai: ['expect'],
},
}),
],
output: {
format: 'umd',
sourcemap: 'inline',
},
onwarn: function (message) {
if (/Circular dependency/.test(message)) return;
console.warn(message);
},
},
reporters: ['dots'],
port: 9876,
colors: true,
autoWatch: false,
singleRun: true,
concurrency: Infinity,
browsers: ['ChromeHeadless'],
client: {
captureConsole: false,
},
});
};
+33 -33
View File
@@ -1,165 +1,165 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { Hour } from '../../src/models/hour';
import { Direction } from '../../src/types';
import { DEFAULT_HOUR_STEP } from '../../src/const';
import { expect } from 'chai';
describe('Hour', () => {
let hour: Hour;
describe('with default config', () => {
context('with default config', () => {
beforeEach(() => {
hour = new Hour(2);
});
it('returns value', () => {
expect(hour.value).toEqual(2);
expect(hour.value).to.equal(2);
});
it('has correct max value', () => {
expect(hour.maxValue).toEqual(23);
expect(hour.maxValue).to.equal(23);
});
describe('toString', () => {
it('returns formatted string', () => {
expect(hour.toString()).toEqual('02');
expect(hour.toString()).to.equal('02');
});
it('renders values correctly', () => {
hour = new Hour(0);
expect(hour.toString()).toEqual('00');
expect(hour.toString()).to.equal('00');
hour = new Hour(6);
expect(hour.toString()).toEqual('06');
expect(hour.toString()).to.equal('06');
hour = new Hour(12);
expect(hour.toString()).toEqual('12');
expect(hour.toString()).to.equal('12');
hour = new Hour(18);
expect(hour.toString()).toEqual('18');
expect(hour.toString()).to.equal('18');
hour = new Hour(23);
expect(hour.toString()).toEqual('23');
expect(hour.toString()).to.equal('23');
});
});
describe('stepUpdate', () => {
it('updates up', () => {
hour.stepUpdate(Direction.UP);
expect(hour.value).toEqual(3);
expect(hour.value).to.equal(3);
});
it('updates down', () => {
hour.stepUpdate(Direction.DOWN);
expect(hour.value).toEqual(1);
expect(hour.value).to.equal(1);
});
it('goes up from 23 to 0', () => {
hour = new Hour(23);
hour.stepUpdate(Direction.UP);
expect(hour.value).toEqual(0);
expect(hour.value).to.equal(0);
});
it('goes down from 0 to 23', () => {
hour = new Hour(0);
hour.stepUpdate(Direction.DOWN);
expect(hour.value).toEqual(23);
expect(hour.value).to.equal(23);
});
});
describe('setStringValue', () => {
it('sets value from valid string', () => {
hour.setStringValue('3');
expect(hour.value).toEqual(3);
expect(hour.value).to.equal(3);
});
it("doesn't set value from invalid string", () => {
hour.setStringValue('test');
expect(hour.value).toEqual(2);
expect(hour.value).to.equal(2);
});
it("doesn't set value from outside of scope", () => {
hour.setStringValue('24');
expect(hour.value).toEqual(2);
expect(hour.value).to.equal(2);
});
});
});
describe('with a different step size', () => {
context('with a different step size', () => {
beforeEach(() => {
hour = new Hour(2, 5);
});
it('goes up correctly', () => {
hour.stepUpdate(Direction.UP);
expect(hour.value).toEqual(7);
expect(hour.value).to.equal(7);
hour.stepUpdate(Direction.UP);
hour.stepUpdate(Direction.UP);
hour.stepUpdate(Direction.UP);
hour.stepUpdate(Direction.UP);
expect(hour.value).toEqual(3);
expect(hour.value).to.equal(3);
});
it('goes down correclty', () => {
hour.stepUpdate(Direction.DOWN);
expect(hour.value).toEqual(21);
expect(hour.value).to.equal(21);
});
});
describe('in 12 hour mode', () => {
context('in 12 hour mode', () => {
beforeEach(() => {
hour = new Hour(2, DEFAULT_HOUR_STEP, 12);
});
it('has correct max value', () => {
expect(hour.maxValue).toEqual(12);
expect(hour.maxValue).to.equal(12);
});
describe('togglePeriod', () => {
it('changes values correclty', () => {
hour = new Hour(2, DEFAULT_HOUR_STEP, 12);
hour.togglePeriod();
expect(hour.value).toEqual(14);
expect(hour.value).to.equal(14);
hour = new Hour(0, DEFAULT_HOUR_STEP, 12);
hour.togglePeriod();
expect(hour.value).toEqual(12);
expect(hour.value).to.equal(12);
hour = new Hour(23, DEFAULT_HOUR_STEP, 12);
hour.togglePeriod();
expect(hour.value).toEqual(11);
expect(hour.value).to.equal(11);
});
it('has no side effects', () => {
const value = hour.value;
hour.togglePeriod();
hour.togglePeriod();
expect(hour.value).toEqual(value);
expect(hour.value).to.equal(value);
});
});
describe('toString', () => {
it('renders values above 12 orrectly', () => {
hour = new Hour(13, DEFAULT_HOUR_STEP, 12);
expect(hour.toString()).toEqual('01');
expect(hour.toString()).to.equal('01');
});
it('renders values correctly', () => {
hour = new Hour(0, DEFAULT_HOUR_STEP, 12);
expect(hour.toString()).toEqual('12');
expect(hour.toString()).to.equal('12');
hour = new Hour(6, DEFAULT_HOUR_STEP, 12);
expect(hour.toString()).toEqual('06');
expect(hour.toString()).to.equal('06');
hour = new Hour(12, DEFAULT_HOUR_STEP, 12);
expect(hour.toString()).toEqual('12');
expect(hour.toString()).to.equal('12');
hour = new Hour(18, DEFAULT_HOUR_STEP, 12);
expect(hour.toString()).toEqual('06');
expect(hour.toString()).to.equal('06');
hour = new Hour(23, DEFAULT_HOUR_STEP, 12);
expect(hour.toString()).toEqual('11');
expect(hour.toString()).to.equal('11');
});
});
});
+23 -23
View File
@@ -1,119 +1,119 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { Minute } from '../../src/models/minute';
import { Direction } from '../../src/types';
import { expect } from 'chai';
describe('Minute', () => {
let minute: Minute;
describe('with default config', () => {
context('with default config', () => {
beforeEach(() => {
minute = new Minute(2);
});
it('returns value', () => {
expect(minute.value).toEqual(2);
expect(minute.value).to.equal(2);
});
it('has correct max value', () => {
expect(minute.maxValue).toEqual(59);
expect(minute.maxValue).to.equal(59);
});
describe('toString', () => {
it('renders values correctly', () => {
minute = new Minute(0);
expect(minute.toString()).toEqual('00');
expect(minute.toString()).to.equal('00');
minute = new Minute(5);
expect(minute.toString()).toEqual('05');
expect(minute.toString()).to.equal('05');
minute = new Minute(59);
expect(minute.toString()).toEqual('59');
expect(minute.toString()).to.equal('59');
});
});
describe('stepUpdate', () => {
it('updates up', () => {
minute.stepUpdate(Direction.UP);
expect(minute.value).toEqual(7);
expect(minute.value).to.equal(7);
});
it('updates down', () => {
minute.stepUpdate(Direction.DOWN);
expect(minute.value).toEqual(57);
expect(minute.value).to.equal(57);
});
it('goes up from 59 to 4', () => {
minute = new Minute(59);
minute.stepUpdate(Direction.UP);
expect(minute.value).toEqual(4);
expect(minute.value).to.equal(4);
});
it('goes down from 0 to 55', () => {
minute = new Minute(0);
minute.stepUpdate(Direction.DOWN);
expect(minute.value).toEqual(55);
expect(minute.value).to.equal(55);
});
});
describe('setStringValue', () => {
it('sets value from valid string', () => {
minute.setStringValue('3');
expect(minute.value).toEqual(3);
expect(minute.value).to.equal(3);
});
it("doesn't set value from invalid string", () => {
minute.setStringValue('test');
expect(minute.value).toEqual(2);
expect(minute.value).to.equal(2);
});
it("doesn't set value from outside of scope", () => {
minute.setStringValue('60');
expect(minute.value).toEqual(2);
expect(minute.value).to.equal(2);
minute.setStringValue('65');
expect(minute.value).toEqual(2);
expect(minute.value).to.equal(2);
});
});
});
describe('with a different step size', () => {
context('with a different step size', () => {
beforeEach(() => {
minute = new Minute(2, 15);
});
it('goes up correctly', () => {
minute.stepUpdate(Direction.UP);
expect(minute.value).toEqual(17);
expect(minute.value).to.equal(17);
minute.stepUpdate(Direction.UP);
minute.stepUpdate(Direction.UP);
minute.stepUpdate(Direction.UP);
expect(minute.value).toEqual(2);
expect(minute.value).to.equal(2);
minute.stepUpdate(Direction.UP);
expect(minute.value).toEqual(17);
expect(minute.value).to.equal(17);
});
it('goes down correclty', () => {
minute.stepUpdate(Direction.DOWN);
expect(minute.value).toEqual(47);
expect(minute.value).to.equal(47);
});
});
describe('will overflow', () => {
it('returns true if the minutes will overflow an hour up', () => {
minute.setStringValue('59');
expect(minute.willOverflow(Direction.UP)).toBe(true);
expect(minute.willOverflow(Direction.UP)).to.be.true;
});
it('returns true if the minutes will overflow an hour down', () => {
minute.setStringValue('0');
expect(minute.willOverflow(Direction.DOWN)).toBe(true);
expect(minute.willOverflow(Direction.DOWN)).to.be.true;
});
it('returns true if the minutes will not overflow', () => {
minute.setStringValue('55');
expect(minute.willOverflow(Direction.DOWN)).toBe(false);
expect(minute.willOverflow(Direction.DOWN)).to.be.false;
});
});
});
-119
View File
@@ -1,119 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { Second } from '../../src/models/second';
import { Direction } from '../../src/types';
describe('Second', () => {
let second: Second;
describe('with default config', () => {
beforeEach(() => {
second = new Second(2);
});
it('returns value', () => {
expect(second.value).toEqual(2);
});
it('has correct max value', () => {
expect(second.maxValue).toEqual(59);
});
describe('toString', () => {
it('renders values correctly', () => {
second = new Second(0);
expect(second.toString()).toEqual('00');
second = new Second(5);
expect(second.toString()).toEqual('05');
second = new Second(59);
expect(second.toString()).toEqual('59');
});
});
describe('stepUpdate', () => {
it('updates up', () => {
second.stepUpdate(Direction.UP);
expect(second.value).toEqual(7);
});
it('updates down', () => {
second.stepUpdate(Direction.DOWN);
expect(second.value).toEqual(57);
});
it('goes up from 59 to 4', () => {
second = new Second(59);
second.stepUpdate(Direction.UP);
expect(second.value).toEqual(4);
});
it('goes down from 0 to 55', () => {
second = new Second(0);
second.stepUpdate(Direction.DOWN);
expect(second.value).toEqual(55);
});
});
describe('setStringValue', () => {
it('sets value from valid string', () => {
second.setStringValue('3');
expect(second.value).toEqual(3);
});
it("doesn't set value from invalid string", () => {
second.setStringValue('test');
expect(second.value).toEqual(2);
});
it("doesn't set value from outside of scope", () => {
second.setStringValue('60');
expect(second.value).toEqual(2);
second.setStringValue('65');
expect(second.value).toEqual(2);
});
});
});
describe('with a different step size', () => {
beforeEach(() => {
second = new Second(2, 15);
});
it('goes up correctly', () => {
second.stepUpdate(Direction.UP);
expect(second.value).toEqual(17);
second.stepUpdate(Direction.UP);
second.stepUpdate(Direction.UP);
second.stepUpdate(Direction.UP);
expect(second.value).toEqual(2);
second.stepUpdate(Direction.UP);
expect(second.value).toEqual(17);
});
it('goes down correclty', () => {
second.stepUpdate(Direction.DOWN);
expect(second.value).toEqual(47);
});
});
describe('will overflow', () => {
it('returns true if the Seconds will overflow an hour up', () => {
second.setStringValue('59');
expect(second.willOverflow(Direction.UP)).toBe(true);
});
it('returns true if the Seconds will overflow an hour down', () => {
second.setStringValue('0');
expect(second.willOverflow(Direction.DOWN)).toBe(true);
});
it('returns true if the Seconds will not overflow', () => {
second.setStringValue('55');
expect(second.willOverflow(Direction.DOWN)).toBe(false);
});
});
});
-77
View File
@@ -1,77 +0,0 @@
import { describe, it, expect } from 'vitest';
import { Hour } from '../../src/models/hour';
import { Minute } from '../../src/models/minute';
import { Second } from '../../src/models/second';
import { Time } from '../../src/models/time';
import { Direction } from '../../src/types';
const time = (h: number, m: number, s: number, linkValues = false): Time =>
new Time(new Hour(h), new Minute(m, 5), new Second(s, 5), linkValues);
describe('Time', () => {
describe('value', () => {
it('joins the units with colons', () => {
expect(time(7, 5, 0).value).toEqual('7:5:0');
});
});
describe('hourStep', () => {
it('steps the hour without touching minutes', () => {
const t = time(23, 30, 0);
t.hourStep(Direction.UP);
expect([t.hour.value, t.minute.value]).toEqual([0, 30]);
});
});
describe('minuteStep', () => {
it('does not change the hour when values are not linked', () => {
const t = time(11, 55, 0);
t.minuteStep(Direction.UP);
expect([t.hour.value, t.minute.value]).toEqual([11, 0]);
});
it('carries an overflow into the next hour when values are linked', () => {
const t = time(11, 55, 0, true);
t.minuteStep(Direction.UP);
expect([t.hour.value, t.minute.value]).toEqual([12, 0]);
});
it('borrows from the previous hour when values are linked', () => {
const t = time(0, 0, 0, true);
t.minuteStep(Direction.DOWN);
expect([t.hour.value, t.minute.value]).toEqual([23, 55]);
});
it('carries by one hour regardless of the hour step', () => {
const t = new Time(new Hour(10, 3), new Minute(55, 5), new Second(0), true);
t.minuteStep(Direction.UP);
expect(t.hour.value).toEqual(11);
});
it('does not carry when the step stays within the hour', () => {
const t = time(10, 30, 0, true);
t.minuteStep(Direction.UP);
expect([t.hour.value, t.minute.value]).toEqual([10, 35]);
});
});
describe('secondStep', () => {
it('does not change the minute when values are not linked', () => {
const t = time(10, 30, 55);
t.secondStep(Direction.UP);
expect([t.minute.value, t.second.value]).toEqual([30, 0]);
});
it('carries an overflow into the next minute when values are linked', () => {
const t = time(10, 30, 55, true);
t.secondStep(Direction.UP);
expect([t.minute.value, t.second.value]).toEqual([31, 0]);
});
it('borrows from the previous minute when values are linked', () => {
const t = time(10, 30, 0, true);
t.secondStep(Direction.DOWN);
expect([t.minute.value, t.second.value]).toEqual([29, 55]);
});
});
});
-171
View File
@@ -1,171 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { LitElement } from 'lit';
import { HomeAssistant } from 'custom-card-helpers';
import { HassEntity } from 'home-assistant-js-websocket';
import '../src/time-picker-card';
import { TimePickerCard } from '../src/time-picker-card';
import { TimePickerCardConfig } from '../src/types';
// The card renders Home Assistant's error card for invalid configs; stub the
// element so the test can read the error it was given.
class ErrorCardStub extends HTMLElement {
config?: { error: string };
setConfig(config: { error: string }): void {
this.config = config;
}
}
customElements.define('hui-error-card', ErrorCardStub);
const ENTITY_ID = 'input_datetime.alarm';
/** YAML is untyped at runtime, so setConfig has to cope with shapes the type forbids. */
const invalidConfig = (config: Record<string, unknown>): TimePickerCardConfig =>
config as unknown as TimePickerCardConfig;
const alarm = (hour: number, minute = 30, second = 0): HassEntity => ({
entity_id: ENTITY_ID,
state: `${hour}:${minute}:${second}`,
attributes: { friendly_name: 'Alarm', has_time: true, has_date: false, hour, minute, second },
last_changed: '',
last_updated: '',
context: { id: '', user_id: null, parent_id: null },
});
const createHass = (entity: HassEntity = alarm(7)): HomeAssistant =>
({
states: { [entity.entity_id]: entity },
config: { version: '2026.9.2' },
callService: vi.fn().mockResolvedValue(undefined),
}) as unknown as HomeAssistant;
const renderCard = async (
config: Partial<TimePickerCardConfig>,
hass: HomeAssistant = createHass(),
): Promise<TimePickerCard> => {
const card = document.createElement('time-picker-card') as TimePickerCard;
card.setConfig({ type: 'custom:time-picker-card', entity: ENTITY_ID, ...config });
card.hass = hass;
document.body.appendChild(card);
await settle(card);
return card;
};
/** Waits for the card and its nested Lit children to finish rendering. */
const settle = async (card: TimePickerCard): Promise<void> => {
await card.updateComplete;
const children = card.shadowRoot!.querySelectorAll('time-unit, time-period');
await Promise.all(Array.from(children).map((el) => (el as LitElement).updateComplete));
};
const inputValues = (card: TimePickerCard): string[] =>
Array.from(card.shadowRoot!.querySelectorAll('time-unit')).map(
(unit) => unit.shadowRoot!.querySelector<HTMLInputElement>('input')!.value,
);
describe('time-picker-card', () => {
let card: TimePickerCard;
afterEach(() => {
card?.remove();
});
describe('setConfig', () => {
beforeEach(() => {
card = document.createElement('time-picker-card') as TimePickerCard;
});
it('rejects a missing entity', () => {
expect(() => card.setConfig(invalidConfig({ type: 'custom:time-picker-card' }))).toThrow(
'You must set an entity',
);
});
it('rejects an unknown hour mode', () => {
expect(() =>
card.setConfig(
invalidConfig({ type: 'custom:time-picker-card', entity: ENTITY_ID, hour_mode: 10 }),
),
).toThrow('Invalid hour_mode');
});
});
describe('rendering', () => {
it('shows hour and minute inputs with the entity time', async () => {
card = await renderCard({});
expect(inputValues(card)).toEqual(['07', '30']);
expect(card.shadowRoot!.querySelector('time-period')).toBeNull();
});
it('shows the entity name in the header by default', async () => {
card = await renderCard({});
expect(card.shadowRoot!.querySelector('.time-picker-header')!.textContent!.trim()).toEqual(
'Alarm',
);
});
it('shows seconds and an AM/PM toggle when configured', async () => {
card = await renderCard({ hour_mode: 12, hide: { seconds: false } });
expect(inputValues(card)).toEqual(['07', '30', '00']);
expect(card.shadowRoot!.querySelector('time-period')).not.toBeNull();
});
it('re-renders when hass delivers a new entity state', async () => {
card = await renderCard({});
card.hass = createHass(alarm(9, 45));
await settle(card);
expect(inputValues(card)).toEqual(['09', '45']);
});
it('renders an error card when the entity is missing', async () => {
card = await renderCard({ entity: 'input_datetime.missing' });
const error = card.shadowRoot!.querySelector('hui-error-card') as ErrorCardStub;
expect(error.config?.error).toEqual('Entity not found');
});
it('renders an error card when the entity has no time', async () => {
const dateOnly = { ...alarm(7), attributes: { friendly_name: 'Date', has_time: false } };
card = await renderCard({}, createHass(dateOnly));
const error = card.shadowRoot!.querySelector('hui-error-card') as ErrorCardStub;
expect(error.config?.error).toContain('has_time: true');
});
});
describe('interaction', () => {
it('calls set_datetime when an hour arrow is clicked', async () => {
const hass = createHass();
card = await renderCard({}, hass);
const hourUnit = card.shadowRoot!.querySelector('time-unit')!;
hourUnit.shadowRoot!.querySelector<HTMLElement>('.time-picker-icon')!.click();
expect(hass.callService).toHaveBeenCalledWith('input_datetime', 'set_datetime', {
entity_id: ENTITY_ID,
time: '8:30:0',
});
});
it('debounces the service call when a delay is configured', async () => {
vi.useFakeTimers();
try {
const hass = createHass();
card = await renderCard({ delay: 500 }, hass);
const [up] = Array.from(
card
.shadowRoot!.querySelector('time-unit')!
.shadowRoot!.querySelectorAll<HTMLElement>('.time-picker-icon'),
);
up.click();
up.click();
expect(hass.callService).not.toHaveBeenCalled();
vi.advanceTimersByTime(500);
expect(hass.callService).toHaveBeenCalledTimes(1);
expect(hass.callService).toHaveBeenCalledWith('input_datetime', 'set_datetime', {
entity_id: ENTITY_ID,
time: '9:30:0',
});
} finally {
vi.useRealTimers();
}
});
});
});
+8 -15
View File
@@ -1,25 +1,18 @@
{
// TypeScript stays on the 6.0 line: typescript-eslint declares a peer range of
// >=4.8.4 <6.1.0, and @rollup/plugin-typescript needs the JS compiler API
// that the native (7.x) compiler does not expose yet.
"compilerOptions": {
"target": "ES2021",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"target": "es2017",
"module": "esnext",
"moduleResolution": "node",
"lib": ["es2017", "dom", "dom.iterable"],
"noEmit": true,
"strict": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"strict": true,
"noImplicitAny": false,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
// Lit reactive properties rely on TypeScript's legacy decorators. Keep
// useDefineForClassFields off: it defaults to true for target >= ES2022 and
// then class fields silently shadow the accessors that @property() installs.
"experimentalDecorators": true,
"useDefineForClassFields": false
},
"include": ["src", "test", "vitest.config.ts"]
"allowSyntheticDefaultImports": true // for importing sinon in tests
}
}
-8
View File
@@ -1,8 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
environment: 'happy-dom',
},
});