Compare commits

...
76 Commits
Author SHA1 Message Date
GeorgeSG e7aade9647 Merge pull request #67 from bramkragten/entity-name-followup
Keep an empty name meaning Home Assistant's own name
2026-09-30 11:18:19 +03:00
Bram KragtenandClaude Opus 5 769176243f Reflow the name union after dropping parent_device
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 23:36:54 +02:00
Bram KragtenandClaude Opus 5 7fc93eecea Keep an empty name meaning "use Home Assistant's name"
formatEntityName returns any string it is given verbatim, the empty one
included, so `name: ''` blanked the label where it previously fell through
to the friendly name. It now reaches the formatter as undefined.

Also drops `parent_device` from the accepted name parts. It only landed in
the frontend on 2026-09-10 and is in no release yet, so under the 2026.4
gate it was silently ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 12:48:36 +01:00
Georgi Gardev 349c0aecf6 Modernize tech stack 2026-09-15 21:19:58 +03:00
GeorgeSG 8477d69bfe Merge pull request #66 from bramkragten/ha-entity-names
Resolve entity names via hass.formatEntityName
2026-09-15 14:47:20 +03:00
Bram KragtenandClaude Opus 5 64b83101dd Guard against a hass without formatEntityName
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 12:33:10 +02:00
Bram KragtenandClaude Opus 5 547b34e85a Add explicit return types to schema helpers
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 15:15:03 +02:00
Bram KragtenandClaude Opus 5 1069a8e7e4 Resolve entity names via hass.formatEntityName
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 15:08:51 +02:00
Georgi Gardev 36732636d0 Update deps 2024-11-05 22:11:41 +02:00
Georgi Gardev b5ecab9aca Modernize dependencies 2024-11-05 20:53:52 +02:00
GeorgeSG f3b1711721 Merge pull request #52 from GeorgeSG/dependabot/npm_and_yarn/word-wrap-1.2.4 2024-11-05 09:17:44 +02:00
dependabot[bot] c1f992ea42 Bump word-wrap from 1.2.3 to 1.2.4
Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.4.
- [Release notes](https://github.com/jonschlinkert/word-wrap/releases)
- [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.4)

---
updated-dependencies:
- dependency-name: word-wrap
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-07-18 22:02:58 +00:00
GeorgeSG bbec5940f5 Merge pull request #50 from GeorgeSG/dependabot/npm_and_yarn/json5-2.2.3 2023-01-23 12:52:03 +02:00
dependabot[bot] b0b4da98d5 Bump json5 from 2.2.1 to 2.2.3
Bumps [json5](https://github.com/json5/json5) from 2.2.1 to 2.2.3.
- [Release notes](https://github.com/json5/json5/releases)
- [Changelog](https://github.com/json5/json5/blob/main/CHANGELOG.md)
- [Commits](https://github.com/json5/json5/compare/v2.2.1...v2.2.3)

---
updated-dependencies:
- dependency-name: json5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-09 13:17:24 +00:00
GeorgeSG 479b57df6f Delete stale.yml 2022-12-06 09:01:49 +02:00
GeorgeSG b9bf51c59d Update stale.yml 2022-12-06 09:00:28 +02:00
GeorgeSG 7dc9eaa26a Create stale.yml 2022-12-05 18:18:38 +02:00
Georgi Gardev abd21485ef 1.4.0 2022-12-01 15:58:01 +02:00
Georgi Gardev ff2d7a226d Add optional delay 2022-12-01 15:58:01 +02:00
Georgi Gardev 7a536c4ef6 1.3.1 2022-12-01 15:58:00 +02:00
Georgi Gardev 05a1048bde Fix lint issues 2022-12-01 15:58:00 +02:00
Georgi Gardev 2194e049e3 Bring eslint-plugin back 2022-12-01 15:58:00 +02:00
Georgi Gardev d149617781 1.3.0 2022-12-01 15:58:00 +02:00
Georgi Gardev 4ada7f0dc2 Update visual editor and screenshots 2022-12-01 15:58:00 +02:00
Georgi Gardev bb0e334c07 Implement editor using ha-form 2022-12-01 15:58:00 +02:00
Georgi Gardev 6cbdc31552 Add thin layout option 2022-12-01 15:58:00 +02:00
Georgi Gardev 65931d4822 Transparent background when embedded 2022-12-01 15:58:00 +02:00
Georgi Gardev f7daf893b3 Add tap actions 2022-12-01 15:58:00 +02:00
Georgi Gardev b33fd03c11 Remove border only when embedded 2022-12-01 15:58:00 +02:00
Georgi Gardev 9090fcfc2d Add dev configurations 2022-12-01 15:58:00 +02:00
Georgi Gardev aeb0d0150e Update all dependencies 2022-12-01 15:58:00 +02:00
Georgi Gardev 5cec16dd22 .gitignore homeassistant files 2022-12-01 15:58:00 +02:00
Georgi Gardev 33786c14c0 Add homeassistant docker for development 2022-11-03 10:02:37 +02:00
GeorgeSG ee129ddb89 Merge pull request #41 from GeorgeSG/dependabot/npm_and_yarn/terser-4.8.1 2022-11-02 17:29:35 +02:00
GeorgeSG 740354b1cf Merge pull request #44 from OToussaint/patch-1 2022-11-02 17:28:59 +02:00
Olivier Toussaint 835267f89b Fix issue #43
Fix issue #43 HA 2022.11.0
2022-11-02 10:26:42 +01:00
dependabot[bot] a606fd1d2e Bump terser from 4.8.0 to 4.8.1
Bumps [terser](https://github.com/terser/terser) from 4.8.0 to 4.8.1.
- [Release notes](https://github.com/terser/terser/releases)
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/commits)

---
updated-dependencies:
- dependency-name: terser
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-07-21 01:32:41 +00:00
GeorgeSG 9b4cfeb4e5 Merge pull request #37 from thecatos/hacs-url
Update HACS URL
2022-06-02 08:24:11 +03:00
Sven Serlier f304900617 Update HACS URL 2022-06-02 06:41:49 +02:00
GeorgeSG ad6f3920e6 Add option to hide icon 2022-05-08 11:17:49 +03:00
GeorgeSG 52e6773a37 Merge pull request #36 from GeorgeSG/dependabot/npm_and_yarn/ansi-regex-3.0.1 2022-05-08 10:18:25 +03:00
dependabot[bot] 6dc770d954 Bump ansi-regex from 3.0.0 to 3.0.1
Bumps [ansi-regex](https://github.com/chalk/ansi-regex) from 3.0.0 to 3.0.1.
- [Release notes](https://github.com/chalk/ansi-regex/releases)
- [Commits](https://github.com/chalk/ansi-regex/compare/v3.0.0...v3.0.1)

---
updated-dependencies:
- dependency-name: ansi-regex
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-05-08 07:17:15 +00:00
GeorgeSG 1d88b5ad5b Merge pull request #28 from GeorgeSG/dependabot/npm_and_yarn/pathval-1.1.1 2022-05-08 10:17:05 +03:00
GeorgeSG c8c9e4df69 Merge pull request #30 from GeorgeSG/dependabot/npm_and_yarn/ajv-6.12.6 2022-05-08 10:16:59 +03:00
GeorgeSG a2c61d429b Merge pull request #32 from GeorgeSG/dependabot/npm_and_yarn/karma-6.3.16 2022-05-08 10:16:41 +03:00
GeorgeSG 2a7a024aa3 Merge pull request #33 from GeorgeSG/dependabot/npm_and_yarn/minimist-1.2.6 2022-05-08 10:16:28 +03:00
dependabot[bot] 80ebf6fde6 Bump minimist from 1.2.5 to 1.2.6
Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6)

---
updated-dependencies:
- dependency-name: minimist
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-26 16:02:34 +00:00
dependabot[bot] e9d3a6c72b Bump karma from 5.2.3 to 6.3.16
Bumps [karma](https://github.com/karma-runner/karma) from 5.2.3 to 6.3.16.
- [Release notes](https://github.com/karma-runner/karma/releases)
- [Changelog](https://github.com/karma-runner/karma/blob/master/CHANGELOG.md)
- [Commits](https://github.com/karma-runner/karma/compare/v5.2.3...v6.3.16)

---
updated-dependencies:
- dependency-name: karma
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-02 08:33:43 +00:00
dependabot[bot] bde447ceb4 Bump ajv from 6.12.2 to 6.12.6
Bumps [ajv](https://github.com/ajv-validator/ajv) from 6.12.2 to 6.12.6.
- [Release notes](https://github.com/ajv-validator/ajv/releases)
- [Commits](https://github.com/ajv-validator/ajv/compare/v6.12.2...v6.12.6)

---
updated-dependencies:
- dependency-name: ajv
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-13 10:23:36 +00:00
dependabot[bot] 41bce3de6b Bump pathval from 1.1.0 to 1.1.1
Bumps [pathval](https://github.com/chaijs/pathval) from 1.1.0 to 1.1.1.
- [Release notes](https://github.com/chaijs/pathval/releases)
- [Changelog](https://github.com/chaijs/pathval/blob/master/CHANGELOG.md)
- [Commits](https://github.com/chaijs/pathval/compare/v1.1.0...v1.1.1)

---
updated-dependencies:
- dependency-name: pathval
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-11 01:41:17 +00:00
GeorgeSG e5c16c2afd Merge pull request #23 from GeorgeSG/dependabot/npm_and_yarn/path-parse-1.0.7 2021-10-10 21:00:34 +03:00
dependabot[bot] 5d06659370 Bump path-parse from 1.0.6 to 1.0.7
Bumps [path-parse](https://github.com/jbgutierrez/path-parse) from 1.0.6 to 1.0.7.
- [Release notes](https://github.com/jbgutierrez/path-parse/releases)
- [Commits](https://github.com/jbgutierrez/path-parse/commits/v1.0.7)

---
updated-dependencies:
- dependency-name: path-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-08-12 09:52:08 +00:00
GeorgeSG 7f7a82c653 Merge pull request #21 from GeorgeSG/dependabot/npm_and_yarn/glob-parent-5.1.2
Bump glob-parent from 5.1.1 to 5.1.2
2021-06-12 15:05:50 +03:00
dependabot[bot] 9ebf4feb50 Bump glob-parent from 5.1.1 to 5.1.2
Bumps [glob-parent](https://github.com/gulpjs/glob-parent) from 5.1.1 to 5.1.2.
- [Release notes](https://github.com/gulpjs/glob-parent/releases)
- [Changelog](https://github.com/gulpjs/glob-parent/blob/main/CHANGELOG.md)
- [Commits](https://github.com/gulpjs/glob-parent/compare/v5.1.1...v5.1.2)

---
updated-dependencies:
- dependency-name: glob-parent
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-06-11 14:38:28 +00:00
GeorgeSG 9aee96ad18 Merge pull request #20 from GeorgeSG/dependabot/npm_and_yarn/lodash-4.17.21
Bump lodash from 4.17.19 to 4.17.21
2021-05-11 16:20:05 +03:00
dependabot[bot] 7e70edfa31 Bump lodash from 4.17.19 to 4.17.21
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.19 to 4.17.21.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.19...4.17.21)

Signed-off-by: dependabot[bot] <support@github.com>
2021-05-11 13:04:45 +00:00
GeorgeSG 31ae5e4097 Merge pull request #19 from GeorgeSG/dependabot/npm_and_yarn/y18n-4.0.1
Bump y18n from 4.0.0 to 4.0.1
2021-04-01 12:04:04 +03:00
dependabot[bot] 27b05d9fcf Bump y18n from 4.0.0 to 4.0.1
Bumps [y18n](https://github.com/yargs/y18n) from 4.0.0 to 4.0.1.
- [Release notes](https://github.com/yargs/y18n/releases)
- [Changelog](https://github.com/yargs/y18n/blob/master/CHANGELOG.md)
- [Commits](https://github.com/yargs/y18n/commits)

Signed-off-by: dependabot[bot] <support@github.com>
2021-03-31 23:20:01 +00:00
GeorgeSG 36d9d19959 Update README 2021-03-31 19:49:53 +03:00
GeorgeSG 8c22fbbeb5 1.2.1 2021-03-31 19:41:36 +03:00
GeorgeSG def608444e Fixes #18: Change overflow to auto to avoid showing scrollbars 2021-03-31 19:41:26 +03:00
GeorgeSG afe276a9d0 1.2.0 2021-03-27 21:20:54 +02:00
GeorgeSG c9705e25aa Implement #12: CSS variable for control padding 2021-03-27 21:19:00 +02:00
GeorgeSG f4c8fd283a Fix #13: Fix card overflowing in grid 2021-03-27 20:57:50 +02:00
GeorgeSG ed570dd10a Merge pull request #14 from GeorgeSG/dependabot/npm_and_yarn/socket.io-2.4.1
Bump socket.io from 2.3.0 to 2.4.1
2021-03-27 20:29:53 +02:00
dependabot[bot] 2a032a0bbc Bump socket.io from 2.3.0 to 2.4.1
Bumps [socket.io](https://github.com/socketio/socket.io) from 2.3.0 to 2.4.1.
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/2.4.1/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/2.3.0...2.4.1)

Signed-off-by: dependabot[bot] <support@github.com>
2021-01-21 15:09:14 +00:00
GeorgeSG c3fa2ce6b6 1.1.1 2020-11-22 16:38:06 +02:00
GeorgeSG f4128145fb Fix name and seconds being linked together in Visual Editor 2020-11-22 16:37:48 +02:00
GeorgeSG e8d87e9afb 1.1.0 2020-11-22 13:47:02 +02:00
GeorgeSG e56dc41dcc Fix import 2020-11-22 13:41:48 +02:00
GeorgeSG 4b5fc79630 Add embedded layout option 2020-11-22 13:40:19 +02:00
GeorgeSG 6113ec9415 Add seconds config to visual editor 2020-11-22 13:23:20 +02:00
GeorgeSG f306d95748 Add optional seconds picker 2020-11-22 13:11:16 +02:00
GeorgeSG fe91b79320 Bump dependencies 2020-11-22 12:35:54 +02:00
GeorgeSG 0b1b87ed6e Merge pull request #8 from GeorgeSG/dependabot/npm_and_yarn/http-proxy-1.18.1
Bump http-proxy from 1.18.0 to 1.18.1
2020-09-12 14:02:34 +03:00
dependabot[bot] 81a0f1a95a Bump http-proxy from 1.18.0 to 1.18.1
Bumps [http-proxy](https://github.com/http-party/node-http-proxy) from 1.18.0 to 1.18.1.
- [Release notes](https://github.com/http-party/node-http-proxy/releases)
- [Changelog](https://github.com/http-party/node-http-proxy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/http-party/node-http-proxy/compare/1.18.0...1.18.1)

Signed-off-by: dependabot[bot] <support@github.com>
2020-09-11 22:23:04 +00:00
45 changed files with 4924 additions and 4344 deletions
-20
View File
@@ -1,20 +0,0 @@
{
"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"]
}
+17 -7
View File
@@ -1,4 +1,4 @@
name: 'CI'
name: CI
on:
push:
@@ -12,10 +12,20 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Build
run: |
npm install
npm run build
- 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 run test
run: npm test
- name: Build
run: npm run rollup
+12 -9
View File
@@ -4,21 +4,24 @@ on:
release:
types: [published]
permissions:
contents: write
jobs:
release:
name: Build and upload
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
cache: npm
- name: Build time-picker-card.js
run: |
npm install
npm ci
npm run build
- name: Upload to release
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 }}
run: gh release upload "${{ github.event.release.tag_name }}" dist/time-picker-card.js --clobber
env:
GH_TOKEN: ${{ github.token }}
+5
View File
@@ -1,2 +1,7 @@
node_modules/
dist/
devtools/homeassistant/*
!devtools/homeassistant/configuration.yaml
!devtools/homeassistant/ui-lovelace.yaml
!devtools/homeassistant/packages/
+1
View File
@@ -0,0 +1 @@
24
+3
View File
@@ -1,5 +1,8 @@
{
"files.exclude": {
"dist/**": true
},
"files.associations": {
"*.yaml": "home-assistant"
}
}
+51 -8
View File
@@ -76,6 +76,7 @@ entity: input_datetime.alarm_time
hour_mode: 12
layout:
hour_mode: single
align_controls: right
hide:
name: true
```
@@ -89,9 +90,17 @@ type: 'custom:time-picker-card'
entity: input_datetime.alarm_time
layout:
name: inside
align_controls: right
align_controls: center
```
### 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)
@@ -99,16 +108,21 @@ layout:
## 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 | **Optional** | Card name | Entity's `friendly_name` |
| 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` |
### Layout Object
@@ -117,12 +131,16 @@ 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` |
### Theme Variables
@@ -130,13 +148,38 @@ Time Picker Card will automatically pick up colors from your lovelace theme, but
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`, `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.
## Meta
@@ -147,14 +190,14 @@ you can use the following variables in your theme's config file:
- [![Twitter][twitter-icon]][twitter-link] [@georgesg92][twitter-link]
[hacs-shield]: https://img.shields.io/badge/HACS-Default-brightgreen.svg
[hacs-link]: https://github.com/custom-components/hacs
[hacs-link]: https://github.com/hacs/integration
[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/workflow/status/GeorgeSG/lovelace-time-picker-card/CI?label=CI&logo=github&
[ci-shield]: https://img.shields.io/github/actions/workflow/status/GeorgeSG/lovelace-time-picker-card/ci.yaml?branch=master&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/2020.svg
[maintenance-shield]: https://img.shields.io/maintenance/yes/2026.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
@@ -0,0 +1,21 @@
# 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
@@ -0,0 +1,12 @@
# 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
@@ -0,0 +1,154 @@
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
@@ -0,0 +1,31 @@
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.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 142 KiB

+3299 -3634
View File
File diff suppressed because it is too large Load Diff
+36 -34
View File
@@ -1,6 +1,6 @@
{
"name": "time-picker-card",
"version": "1.0.1",
"version": "1.4.0",
"author": "Georgi Gardev <georgi@gardev.com>",
"description": "Lovelace time-picker-card",
"keywords": [
@@ -13,48 +13,50 @@
"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 rollup",
"lint": "eslint src/*.ts",
"build": "npm run lint && npm run typecheck && npm run rollup",
"lint": "eslint .",
"rollup": "rollup -c",
"test": "./node_modules/karma/bin/karma start ./test/karma.conf.js",
"test": "vitest run",
"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"
"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 ."
},
"dependencies": {
"custom-card-helpers": "^1.6.4",
"home-assistant-js-websocket": "^5.1.0",
"lit-element": "^2.3.1",
"lit-html": "^1.2.1"
"custom-card-helpers": "^2.0.0",
"home-assistant-js-websocket": "^9.7.0",
"lit": "^3.3.3"
},
"devDependencies": {
"@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"
"@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"
}
}
+4 -8
View File
@@ -1,16 +1,15 @@
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 babel from 'rollup-plugin-babel';
import terser from '@rollup/plugin-terser';
import serve from 'rollup-plugin-serve';
const dev = process.env.ROLLUP_WATCH;
const serveOptions = {
contentBase: ['./dist'],
host: '0.0.0.0',
port: 5000,
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
allowCrossOrigin: true,
headers: {
'Access-Control-Allow-Origin': '*',
@@ -24,12 +23,9 @@ export default {
format: 'es',
},
plugins: [
typescript({ exclude: ['test/**/*'] }),
nodeResolve(),
json(),
typescript(),
babel({
exclude: 'node_modules/**',
}),
dev && serve(serveOptions),
!dev && terser(),
],
+206
View File
@@ -0,0 +1,206 @@
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) {}
},
);
+4 -11
View File
@@ -1,13 +1,6 @@
import {
css,
CSSResult,
customElement,
html,
LitElement,
property,
TemplateResult,
} from 'lit-element';
import { ClassInfo, classMap } from 'lit-html/directives/class-map';
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 { Layout, Period } from '../types';
/**
@@ -63,7 +56,7 @@ export class TimePeriodComponent extends LitElement {
.time-period {
width: 30px;
padding: 8px;
padding: var(--tpc-control-padding);
background: var(--tpc-off-color);
color: var(--tpc-text-color);
text-align: center;
+4 -11
View File
@@ -1,12 +1,5 @@
import {
css,
CSSResult,
customElement,
html,
LitElement,
property,
TemplateResult,
} from 'lit-element';
import { LitElement, TemplateResult, html, CSSResult, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { TimeUnit } from '../models/time-unit';
import { Direction } from '../types';
@@ -69,7 +62,7 @@ export class TimeUnitComponent extends LitElement {
.time-picker-icon {
width: 30px;
padding: 8px;
padding: var(--tpc-control-padding);
text-align: center;
cursor: pointer;
color: var(--tpc-icon-color);
@@ -77,7 +70,7 @@ export class TimeUnitComponent extends LitElement {
.time-input {
width: 30px;
padding: 8px 8px 6px;
padding: var(--tpc-control-padding);
background: var(--tpc-elements-background-color);
border: 0;
border-bottom: 2px solid var(--tpc-elements-background-color);
+3 -1
View File
@@ -9,6 +9,8 @@ export const ENTITY_DOMAIN = 'input_datetime';
// Config defaults
export const DEFAULT_HOUR_STEP = 1;
export const DEFAULT_MINUTE_STEP = 5;
export const DEFAULT_LAYOUT_HOUR_MODE = 'double';
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_ALIGN_CONTROLS = Layout.AlignControls.CENTER;
export const DEFAULT_LAYOUT_NAME = Layout.Name.HEADER;
+137 -216
View File
@@ -1,218 +1,158 @@
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';
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: {} } },
],
},
];
@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 get entity(): HassEntity | undefined {
return this.hass.states[this.config.entity];
private computeLabel({ name }: { name: string }): string {
return NAME_TO_LABEL_MAP[name] || name;
}
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
);
private valueChanged(ev: CustomEvent): void {
const newConfig = { ...this.config, ...ev.detail.value };
this.dispatch(newConfig);
}
render(): TemplateResult {
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>`;
return html`
<ha-form
.hass=${this.hass}
.data=${this.config}
.schema=${schema(this.hass)}
.computeLabel=${this.computeLabel}
@value-changed=${this.valueChanged}
></ha-form>
`;
}
setConfig(config): void {
setConfig(config: TimePickerCardConfig): 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,
@@ -222,23 +162,4 @@ 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;
}
`;
}
}
+60
View File
@@ -0,0 +1,60 @@
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 {
// A configured empty name has always meant "use Home Assistant's name", but
// formatEntityName returns any string verbatim - including the empty one, which
// would blank the label. Normalise it to undefined so the formatter composes.
if (name === '') name = 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;
}
+5 -1
View File
@@ -8,7 +8,11 @@ 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
@@ -0,0 +1,26 @@
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;
}
}
+10 -2
View File
@@ -19,7 +19,11 @@ 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;
@@ -40,7 +44,11 @@ export abstract class TimeUnit {
* @param direction
*/
stepUpdate(direction: Direction, step: number = this._step): void {
direction === Direction.UP ? this.increment(step) : this.decrement(step);
if (direction === Direction.UP) {
this.increment(step);
} else {
this.decrement(step);
}
}
toString(): string {
+17 -3
View File
@@ -1,9 +1,15 @@
import { Direction } from '../types';
import { Hour } from './hour';
import { Minute } from './minute';
import { Direction } from '../types';
import { Second } from './second';
export class Time {
constructor(public hour: Hour, public minute: Minute, private _linkValues: boolean = false) {}
constructor(
public hour: Hour,
public minute: Minute,
public second: Second,
private _linkValues: boolean = false,
) {}
hourStep(direction: Direction): void {
this.hour.stepUpdate(direction);
@@ -17,7 +23,15 @@ 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}`;
return `${this.hour.value}:${this.minute.value}:${this.second.value}`;
}
}
+2 -16
View File
@@ -1,7 +1,6 @@
import { html, TemplateResult } from 'lit-element';
import { TimePickerCardConfig } from './types';
import { LovelaceCard } from 'custom-card-helpers';
import { HassEntity } from 'home-assistant-js-websocket';
import { html, TemplateResult } from 'lit';
import { TimePickerCardConfig } from './types';
export class Partial {
static error(error: string, origConfig: TimePickerCardConfig): TemplateResult {
@@ -14,17 +13,4 @@ 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>`;
}
}
+181 -42
View File
@@ -1,36 +1,42 @@
import { computeDomain, HomeAssistant, LovelaceCard } from 'custom-card-helpers';
import { HassEntity } from 'home-assistant-js-websocket';
import {
css,
CSSResult,
customElement,
html,
LitElement,
property,
TemplateResult,
} from 'lit-element';
import { ClassInfo, classMap } from 'lit-html/directives/class-map';
ActionHandlerEvent,
computeDomain,
handleAction,
hasAction,
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 {
CARD_SIZE,
CARD_VERSION,
DEFAULT_LAYOUT_HOUR_MODE,
ENTITY_DOMAIN,
DEFAULT_HOUR_MODE,
DEFAULT_HOUR_STEP,
DEFAULT_LAYOUT_ALIGN_CONTROLS,
DEFAULT_LAYOUT_HOUR_MODE,
DEFAULT_LAYOUT_NAME,
DEFAULT_MINUTE_STEP,
ENTITY_DOMAIN,
} 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 { Period, TimePickerCardConfig, Layout } from './types';
import './components/time-period.component';
import './components/time-unit.component';
import './editor';
import { Layout, Period, TimePickerCardConfig, TimePickerHideConfig } from './types';
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 || [];
@@ -47,28 +53,34 @@ 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
this.config.layout?.name !== Layout.Name.INSIDE &&
Boolean(this.config.layout?.embedded) === false // embedded layout disables name in header
);
}
private get hasNameInside(): boolean {
return (
Boolean(this.name) &&
Boolean(this.config.hide?.name) === false &&
this.config.layout?.name === Layout.Name.INSIDE
(this.config.layout?.name === Layout.Name.INSIDE || Boolean(this.config.layout?.embedded))
);
}
private get name(): string | undefined {
return this.config.name || this.entity?.attributes.friendly_name;
return computeEntityName(this.hass, this.entity, this.config.name);
}
private get shouldShowPeriod(): boolean {
@@ -79,10 +91,18 @@ 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,
};
}
@@ -93,6 +113,58 @@ 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);
@@ -105,49 +177,65 @@ 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 } = this.entity!.attributes;
const { hour, minute, second } = 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);
this.time = new Time(hourInstance, minuteInstance, this.config.link_values);
const secondInstance = new Second(second, this.config.second_step);
this.time = new Time(hourInstance, minuteInstance, secondInstance, this.config.link_values);
this.period = hourInstance.value >= 12 ? Period.PM : Period.AM;
return html`
<ha-card>
${this.hasNameInHeader ? Partial.headerName(this.name!) : ''}
<ha-card class=${classMap(this.haCardClass)}>
${this.hasNameInHeader ? this.renderHeaderName(this.name!) : ''}
<div class=${classMap(this.rowClass)}>
${this.hasNameInside ? Partial.nestedName(this.name!, this.entity) : ''}
${
this.hasNameInside
? this.renderNestedName(this.name!, this.entity, this.config.hide)
: ''
}
<div class=${classMap(this.contentClass)}>
<time-unit
.unit=${this.time.hour}
@stepChange=${this.onHourStepChange}
@update=${this.callHassService}
@update=${this.debouncedCallHassService}
></time-unit>
<div class="time-separator">:</div>
<time-unit
.unit=${this.time.minute}
@stepChange=${this.onMinuteStepChange}
@update=${this.callHassService}
@update=${this.debouncedCallHassService}
></time-unit>
${this.shouldShowPeriod
${
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>`
: ''}
: ''
}
</div>
</div>
</ha-card>
`;
}
setConfig(config): void {
setConfig(config: TimePickerCardConfig): void {
if (!config) {
throw new Error('Invalid configuration');
}
@@ -169,18 +257,32 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
private onPeriodToggle(): void {
this.time.hour.togglePeriod();
this.callHassService();
this.debouncedCallHassService();
}
private onHourStepChange(event: CustomEvent): void {
this.time.hourStep(event.detail.direction);
this.callHassService();
this.debouncedCallHassService();
}
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();
}
}
private callHassService(): Promise<void> {
if (!this.hass) {
@@ -201,6 +303,8 @@ 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));
@@ -209,6 +313,16 @@ 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);
@@ -219,6 +333,10 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
text-align: center;
}
.thin > .time-picker-header {
padding: 4px;
}
.time-picker-row {
display: flex;
flex-direction: row;
@@ -226,6 +344,14 @@ 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;
}
@@ -249,23 +375,36 @@ 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 || 'input_datetime.example_entity',
hour_mode: 24,
hour_step: 1,
minute_step: 5,
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,
},
};
}
+17 -2
View File
@@ -1,22 +1,35 @@
import { LovelaceCardConfig } from 'custom-card-helpers';
import { ActionConfig, LovelaceCardConfig } from 'custom-card-helpers';
export interface TimePickerCardConfig extends LovelaceCardConfig {
entity: string;
name?: string;
name?: EntityName;
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' | '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 {
@@ -36,6 +49,8 @@ export namespace Layout {
export interface TimePickerHideConfig {
name?: boolean;
icon?: boolean;
seconds?: boolean;
}
export enum Direction {
-17
View File
@@ -1,17 +0,0 @@
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
@@ -1,111 +0,0 @@
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
@@ -0,0 +1,78 @@
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
@@ -1,45 +0,0 @@
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;
context('with default config', () => {
describe('with default config', () => {
beforeEach(() => {
hour = new Hour(2);
});
it('returns value', () => {
expect(hour.value).to.equal(2);
expect(hour.value).toEqual(2);
});
it('has correct max value', () => {
expect(hour.maxValue).to.equal(23);
expect(hour.maxValue).toEqual(23);
});
describe('toString', () => {
it('returns formatted string', () => {
expect(hour.toString()).to.equal('02');
expect(hour.toString()).toEqual('02');
});
it('renders values correctly', () => {
hour = new Hour(0);
expect(hour.toString()).to.equal('00');
expect(hour.toString()).toEqual('00');
hour = new Hour(6);
expect(hour.toString()).to.equal('06');
expect(hour.toString()).toEqual('06');
hour = new Hour(12);
expect(hour.toString()).to.equal('12');
expect(hour.toString()).toEqual('12');
hour = new Hour(18);
expect(hour.toString()).to.equal('18');
expect(hour.toString()).toEqual('18');
hour = new Hour(23);
expect(hour.toString()).to.equal('23');
expect(hour.toString()).toEqual('23');
});
});
describe('stepUpdate', () => {
it('updates up', () => {
hour.stepUpdate(Direction.UP);
expect(hour.value).to.equal(3);
expect(hour.value).toEqual(3);
});
it('updates down', () => {
hour.stepUpdate(Direction.DOWN);
expect(hour.value).to.equal(1);
expect(hour.value).toEqual(1);
});
it('goes up from 23 to 0', () => {
hour = new Hour(23);
hour.stepUpdate(Direction.UP);
expect(hour.value).to.equal(0);
expect(hour.value).toEqual(0);
});
it('goes down from 0 to 23', () => {
hour = new Hour(0);
hour.stepUpdate(Direction.DOWN);
expect(hour.value).to.equal(23);
expect(hour.value).toEqual(23);
});
});
describe('setStringValue', () => {
it('sets value from valid string', () => {
hour.setStringValue('3');
expect(hour.value).to.equal(3);
expect(hour.value).toEqual(3);
});
it("doesn't set value from invalid string", () => {
hour.setStringValue('test');
expect(hour.value).to.equal(2);
expect(hour.value).toEqual(2);
});
it("doesn't set value from outside of scope", () => {
hour.setStringValue('24');
expect(hour.value).to.equal(2);
expect(hour.value).toEqual(2);
});
});
});
context('with a different step size', () => {
describe('with a different step size', () => {
beforeEach(() => {
hour = new Hour(2, 5);
});
it('goes up correctly', () => {
hour.stepUpdate(Direction.UP);
expect(hour.value).to.equal(7);
expect(hour.value).toEqual(7);
hour.stepUpdate(Direction.UP);
hour.stepUpdate(Direction.UP);
hour.stepUpdate(Direction.UP);
hour.stepUpdate(Direction.UP);
expect(hour.value).to.equal(3);
expect(hour.value).toEqual(3);
});
it('goes down correclty', () => {
hour.stepUpdate(Direction.DOWN);
expect(hour.value).to.equal(21);
expect(hour.value).toEqual(21);
});
});
context('in 12 hour mode', () => {
describe('in 12 hour mode', () => {
beforeEach(() => {
hour = new Hour(2, DEFAULT_HOUR_STEP, 12);
});
it('has correct max value', () => {
expect(hour.maxValue).to.equal(12);
expect(hour.maxValue).toEqual(12);
});
describe('togglePeriod', () => {
it('changes values correclty', () => {
hour = new Hour(2, DEFAULT_HOUR_STEP, 12);
hour.togglePeriod();
expect(hour.value).to.equal(14);
expect(hour.value).toEqual(14);
hour = new Hour(0, DEFAULT_HOUR_STEP, 12);
hour.togglePeriod();
expect(hour.value).to.equal(12);
expect(hour.value).toEqual(12);
hour = new Hour(23, DEFAULT_HOUR_STEP, 12);
hour.togglePeriod();
expect(hour.value).to.equal(11);
expect(hour.value).toEqual(11);
});
it('has no side effects', () => {
const value = hour.value;
hour.togglePeriod();
hour.togglePeriod();
expect(hour.value).to.equal(value);
expect(hour.value).toEqual(value);
});
});
describe('toString', () => {
it('renders values above 12 orrectly', () => {
hour = new Hour(13, DEFAULT_HOUR_STEP, 12);
expect(hour.toString()).to.equal('01');
expect(hour.toString()).toEqual('01');
});
it('renders values correctly', () => {
hour = new Hour(0, DEFAULT_HOUR_STEP, 12);
expect(hour.toString()).to.equal('12');
expect(hour.toString()).toEqual('12');
hour = new Hour(6, DEFAULT_HOUR_STEP, 12);
expect(hour.toString()).to.equal('06');
expect(hour.toString()).toEqual('06');
hour = new Hour(12, DEFAULT_HOUR_STEP, 12);
expect(hour.toString()).to.equal('12');
expect(hour.toString()).toEqual('12');
hour = new Hour(18, DEFAULT_HOUR_STEP, 12);
expect(hour.toString()).to.equal('06');
expect(hour.toString()).toEqual('06');
hour = new Hour(23, DEFAULT_HOUR_STEP, 12);
expect(hour.toString()).to.equal('11');
expect(hour.toString()).toEqual('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;
context('with default config', () => {
describe('with default config', () => {
beforeEach(() => {
minute = new Minute(2);
});
it('returns value', () => {
expect(minute.value).to.equal(2);
expect(minute.value).toEqual(2);
});
it('has correct max value', () => {
expect(minute.maxValue).to.equal(59);
expect(minute.maxValue).toEqual(59);
});
describe('toString', () => {
it('renders values correctly', () => {
minute = new Minute(0);
expect(minute.toString()).to.equal('00');
expect(minute.toString()).toEqual('00');
minute = new Minute(5);
expect(minute.toString()).to.equal('05');
expect(minute.toString()).toEqual('05');
minute = new Minute(59);
expect(minute.toString()).to.equal('59');
expect(minute.toString()).toEqual('59');
});
});
describe('stepUpdate', () => {
it('updates up', () => {
minute.stepUpdate(Direction.UP);
expect(minute.value).to.equal(7);
expect(minute.value).toEqual(7);
});
it('updates down', () => {
minute.stepUpdate(Direction.DOWN);
expect(minute.value).to.equal(57);
expect(minute.value).toEqual(57);
});
it('goes up from 59 to 4', () => {
minute = new Minute(59);
minute.stepUpdate(Direction.UP);
expect(minute.value).to.equal(4);
expect(minute.value).toEqual(4);
});
it('goes down from 0 to 55', () => {
minute = new Minute(0);
minute.stepUpdate(Direction.DOWN);
expect(minute.value).to.equal(55);
expect(minute.value).toEqual(55);
});
});
describe('setStringValue', () => {
it('sets value from valid string', () => {
minute.setStringValue('3');
expect(minute.value).to.equal(3);
expect(minute.value).toEqual(3);
});
it("doesn't set value from invalid string", () => {
minute.setStringValue('test');
expect(minute.value).to.equal(2);
expect(minute.value).toEqual(2);
});
it("doesn't set value from outside of scope", () => {
minute.setStringValue('60');
expect(minute.value).to.equal(2);
expect(minute.value).toEqual(2);
minute.setStringValue('65');
expect(minute.value).to.equal(2);
expect(minute.value).toEqual(2);
});
});
});
context('with a different step size', () => {
describe('with a different step size', () => {
beforeEach(() => {
minute = new Minute(2, 15);
});
it('goes up correctly', () => {
minute.stepUpdate(Direction.UP);
expect(minute.value).to.equal(17);
expect(minute.value).toEqual(17);
minute.stepUpdate(Direction.UP);
minute.stepUpdate(Direction.UP);
minute.stepUpdate(Direction.UP);
expect(minute.value).to.equal(2);
expect(minute.value).toEqual(2);
minute.stepUpdate(Direction.UP);
expect(minute.value).to.equal(17);
expect(minute.value).toEqual(17);
});
it('goes down correclty', () => {
minute.stepUpdate(Direction.DOWN);
expect(minute.value).to.equal(47);
expect(minute.value).toEqual(47);
});
});
describe('will overflow', () => {
it('returns true if the minutes will overflow an hour up', () => {
minute.setStringValue('59');
expect(minute.willOverflow(Direction.UP)).to.be.true;
expect(minute.willOverflow(Direction.UP)).toBe(true);
});
it('returns true if the minutes will overflow an hour down', () => {
minute.setStringValue('0');
expect(minute.willOverflow(Direction.DOWN)).to.be.true;
expect(minute.willOverflow(Direction.DOWN)).toBe(true);
});
it('returns true if the minutes will not overflow', () => {
minute.setStringValue('55');
expect(minute.willOverflow(Direction.DOWN)).to.be.false;
expect(minute.willOverflow(Direction.DOWN)).toBe(false);
});
});
});
+119
View File
@@ -0,0 +1,119 @@
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
@@ -0,0 +1,77 @@
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
@@ -0,0 +1,171 @@
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();
}
});
});
});
+15 -8
View File
@@ -1,18 +1,25 @@
{
// 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": "es2017",
"module": "esnext",
"moduleResolution": "node",
"lib": ["es2017", "dom", "dom.iterable"],
"target": "ES2021",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2021", "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,
"allowSyntheticDefaultImports": true // for importing sinon in tests
}
"useDefineForClassFields": false
},
"include": ["src", "test", "vitest.config.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
environment: 'happy-dom',
},
});