Compare commits

...
64 Commits
Author SHA1 Message Date
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
32 changed files with 5987 additions and 4468 deletions
+1 -3
View File
@@ -2,8 +2,6 @@
"parser": "@typescript-eslint/parser", // Specifies the ESLint parser "parser": "@typescript-eslint/parser", // Specifies the ESLint parser
"extends": [ "extends": [
"plugin:@typescript-eslint/recommended", // Uses the recommended rules from the @typescript-eslint/eslint-plugin "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": { "parserOptions": {
"ecmaVersion": 2018, // Allows for the parsing of modern ECMAScript features "ecmaVersion": 2018, // Allows for the parsing of modern ECMAScript features
@@ -14,7 +12,7 @@
"@typescript-eslint/camelcase": 0, "@typescript-eslint/camelcase": 0,
"@typescript-eslint/no-non-null-assertion": 0, "@typescript-eslint/no-non-null-assertion": 0,
"@typescript-eslint/explicit-function-return-type": [1, { "allowExpressions": true }], "@typescript-eslint/explicit-function-return-type": [1, { "allowExpressions": true }],
"@typescript-eslint/no-namespace": 0 "@typescript-eslint/no-namespace": 0,
}, },
"ignorePatterns": ["dist/", "node_modules/", "*.js"] "ignorePatterns": ["dist/", "node_modules/", "*.js"]
} }
+4
View File
@@ -1,2 +1,6 @@
node_modules/ node_modules/
dist/ dist/
devtools/homeassistant/*
!devtools/homeassistant/configuration.yaml
!devtools/homeassistant/ui-lovelace.yaml
+3
View File
@@ -1,5 +1,8 @@
{ {
"files.exclude": { "files.exclude": {
"dist/**": true "dist/**": true
},
"files.associations": {
"*.yaml": "home-assistant"
} }
} }
+67 -27
View File
@@ -76,6 +76,7 @@ entity: input_datetime.alarm_time
hour_mode: 12 hour_mode: 12
layout: layout:
hour_mode: single hour_mode: single
align_controls: right
hide: hide:
name: true name: true
``` ```
@@ -89,26 +90,38 @@ type: 'custom:time-picker-card'
entity: input_datetime.alarm_time entity: input_datetime.alarm_time
layout: layout:
name: inside 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 ### With a custom lovelace theme
![Custom theme](https://raw.githubusercontent.com/GeorgeSG/lovelace-time-picker-card/master/examples/custom.png) ![Custom theme](https://raw.githubusercontent.com/GeorgeSG/lovelace-time-picker-card/master/examples/custom.png)
## Options ## Options
| Name | Type | Requirement | Description | Default | | Name | Type | Requirement | Description | Default |
| ----------- | ------------ | ------------ | --------------------------------------------------------------------------------------------------------- | ------------------------ | | ----------------- | ------------ | ------------ | --------------------------------------------------------------------------------------------------------- | ------------------------ |
| type | string | **Required** | `custom:time-picker-card` | | | 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` | | | 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` | | 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_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` | | hour_step | number | **Optional** | Hour change when clicking arrows | `1` |
| minute_step | number | **Optional** | Minute change when clicking arrows | `5` | | minute_step | number | **Optional** | Minute change when clicking arrows | `5` |
| layout | object | **Optional** | Card Layout configuration | `none` | | delay | number | **Optional** | Delay in ms before updating entity | `0` |
| hide | object | **Optional** | Hide object | `none` | | 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 ### Layout Object
@@ -117,28 +130,55 @@ 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` | | 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` | | 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` | | 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` | | 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 ### Hide Object
| Name | Type | Requirement | Description | Default | | Name | Type | Requirement | Description | Default |
| ---- | ------- | ------------ | ------------------- | ------- | | ------- | ------- | ------------ | ------------------------------------------------- | ------- |
| name | boolean | **Optional** | Hides the card name | `false` | | name | boolean | **Optional** | Hides the card name | `false` |
| seconds | boolean | **Optional** | Hides seconds input | `true` | | icon | boolean | **Optional** | Hides the card icon (only with controls "inside") | `false` |
| seconds | boolean | **Optional** | Hides seconds input | `true` |
### Theme Variables ### Theme Variables
Time Picker Card will automatically pick up colors from your lovelace theme, but if you want to customize some of them, Time Picker Card will automatically pick up colors from your lovelace theme, but if you want to customize some of them,
you can use the following variables in your theme's config file: you can use the following variables in your theme's config file:
| Name | Default | Description | | Name | Default | Description |
| ------------------------------------- | ------------------------------ | -------------------------------------- | | ------------------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------- |
| time-picker-elements-background-color | `var(--primary-color)` | Background color for header and inputs | | 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-icon-color | `var(--primary-text-color)` | Arrow color |
| time-picker-text-color | `white` | Text color | | time-picker-text-color | `white` | Text color |
| time-picker-accent-color | `var(--primary-color)` | AM / PM active 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-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-border-radius | `var(--ha-card-border-radius)` | Border radius of the card |
| time-picker-control-padding | `8px` | Padding for interactive elements. Increase for larger hitboxes of the controls. Example: `10px 12px` |
### Structured names
*Requires Home Assistant 2026.4 or later. On earlier versions a structured `name` falls back to the entity's friendly name.*
Home Assistant composes an entity's display name out of its registry context
(entity, device, area, floor) rather than one `friendly_name` string. `name` can
be a list of those parts instead of a plain string, so it keeps following renames
and matches what the built-in cards show:
```yaml
type: custom:time-picker-card
entity: input_datetime.alarm
name:
- type: area
- type: entity
```
Available part types are `entity`, `device`, `parent_device`, `area`, `floor`, and
`text` (a literal, written as `{type: text, text: 'Alarm'}`). Parts that resolve to
nothing are dropped. A plain string `name` keeps working exactly as before, and the
visual editor offers both modes on Home Assistant 2025.11 and later.
See the [Home Assistant developer documentation](https://developers.home-assistant.io/docs/frontend/data#hassformatentitynamestateobj-name-options) for details.
## Meta ## Meta
@@ -149,14 +189,14 @@ you can use the following variables in your theme's config file:
- [![Twitter][twitter-icon]][twitter-link] [@georgesg92][twitter-link] - [![Twitter][twitter-icon]][twitter-link] [@georgesg92][twitter-link]
[hacs-shield]: https://img.shields.io/badge/HACS-Default-brightgreen.svg [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-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 [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-shield]: https://img.shields.io/github/release/GeorgeSG/lovelace-time-picker-card.svg
[releases-link]: https://github.com/GeorgeSG/lovelace-time-picker-card/releases [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/workflow/status/GeorgeSG/lovelace-time-picker-card/CI?label=CI&logo=github&
[ci-link]: https://github.com/GeorgeSG/lovelace-time-picker-card/actions?query=workflow%3ACI [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/2022.svg
[maintenance-link]: https://github.com/GeorgeSG/lovelace-time-picker-card [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-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 [license-link]: https://github.com/GeorgeSG/lovelace-time-picker-card/blob/master/LICENSE
+27
View File
@@ -0,0 +1,27 @@
# Configure a default setup of Home Assistant (frontend, api, etc)
default_config:
homeassistant:
packages: !include_dir_named packages/
logger:
default: info
logs:
hacs: debug
lovelace:
mode: yaml
resources:
- type: module
url: /hacsfiles/button-card/button-card.js
- type: module
url: /hacsfiles/lovelace-state-switch/state-switch.js
- type: module
url: http://127.0.0.1:5000/time-picker-card.js
dashboards:
lovelace-home:
mode: yaml
title: Home
icon: mdi:home
show_in_sidebar: true
filename: ui-lovelace.yaml
+155
View File
@@ -0,0 +1,155 @@
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: Hidden 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
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

+8
View File
@@ -0,0 +1,8 @@
module.exports = {
preset: 'ts-jest/presets/js-with-babel',
testRegex: '/test/.*\\.test?\\.ts$',
transformIgnorePatterns: [
'node_modules/(?!(testing-library__dom|@open-wc|lit-html|lit-element|pure-lit|lit-element-state-decoupler)/)',
],
moduleFileExtensions: ['ts', 'js'],
};
+5066 -3831
View File
File diff suppressed because it is too large Load Diff
+24 -34
View File
@@ -1,6 +1,6 @@
{ {
"name": "time-picker-card", "name": "time-picker-card",
"version": "1.1.0", "version": "1.4.0",
"author": "Georgi Gardev <georgi@gardev.com>", "author": "Georgi Gardev <georgi@gardev.com>",
"description": "Lovelace time-picker-card", "description": "Lovelace time-picker-card",
"keywords": [ "keywords": [
@@ -15,46 +15,36 @@
"license": "MIT", "license": "MIT",
"repository": "git@github.com:GeorgeSG/lovelace-time-picker-card.git", "repository": "git@github.com:GeorgeSG/lovelace-time-picker-card.git",
"scripts": { "scripts": {
"start": "rollup -c --watch", "start": "rollup -c --watch --bundleConfigAsCjs",
"build": "npm run lint && npm run rollup", "build": "npm run lint && npm run rollup",
"lint": "eslint src/*.ts", "lint": "eslint src/*.ts",
"rollup": "rollup -c", "rollup": "rollup -c --bundleConfigAsCjs",
"test": "./node_modules/karma/bin/karma start ./test/karma.conf.js", "test": "jest",
"release-patch": "npm version patch && git push origin master --tags", "release-patch": "npm version patch && git push origin master --tags",
"release-minor": "npm version minor && 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"
}, },
"dependencies": { "dependencies": {
"custom-card-helpers": "^1.6.6", "custom-card-helpers": "^1.9.0",
"home-assistant-js-websocket": "^5.7.0", "home-assistant-js-websocket": "^8.0.0",
"lit-element": "^2.4.0", "lit": "^2.8.0"
"lit-html": "^1.3.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.12.7", "@el3um4s/rollup-plugin-terser": "^1.0.2",
"@open-wc/testing": "^2.5.32", "@rollup/plugin-babel": "^6.0.2",
"@rollup/plugin-commonjs": "^11.1.0", "@rollup/plugin-commonjs": "^28.0.1",
"@rollup/plugin-json": "^4.1.0", "@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^7.1.3", "@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-typescript": "^4.1.2", "@rollup/plugin-typescript": "^12.1.1",
"@types/chai": "^4.2.14", "@types/jest": "^29.2.1",
"@typescript-eslint/eslint-plugin": "^2.34.0", "@typescript-eslint/eslint-plugin": "^8.13.0",
"@typescript-eslint/parser": "^2.34.0", "eslint": "^8.26.0",
"eslint": "^6.8.0", "jest": "^29.2.2",
"eslint-config-prettier": "^6.15.0", "prettier": "^3.3.3",
"eslint-plugin-prettier": "^3.1.4", "rollup": "^4.24.4",
"karma": "^5.2.3", "rollup-plugin-serve": "^2.0.1",
"karma-chai": "^0.1.0", "ts-jest": "^29.0.3",
"karma-chrome-launcher": "^3.1.0", "typescript": "^5.6.3"
"karma-mocha": "^2.0.1",
"karma-rollup-preprocessor": "^7.0.5",
"mocha": "^7.2.0",
"prettier": "^2.2.0",
"rollup": "^1.32.1",
"rollup-plugin-babel": "^4.4.0",
"rollup-plugin-serve": "^1.1.0",
"rollup-plugin-terser": "^5.3.1",
"sinon": "^9.2.1",
"typescript": "^3.9.7"
} }
} }
+4 -4
View File
@@ -1,15 +1,15 @@
import nodeResolve from '@rollup/plugin-node-resolve'; import nodeResolve from '@rollup/plugin-node-resolve';
import json from '@rollup/plugin-json'; import json from '@rollup/plugin-json';
import typescript from '@rollup/plugin-typescript'; import typescript from '@rollup/plugin-typescript';
import { terser } from 'rollup-plugin-terser'; import { terser } from '@el3um4s/rollup-plugin-terser';
import babel from 'rollup-plugin-babel'; import babel from '@rollup/plugin-babel';
import serve from 'rollup-plugin-serve'; import serve from 'rollup-plugin-serve';
const dev = process.env.ROLLUP_WATCH; const dev = process.env.ROLLUP_WATCH;
const serveOptions = { const serveOptions = {
contentBase: ['./dist'], contentBase: ['./dist'],
host: '0.0.0.0', host: 'localhost',
port: 5000, port: 5000,
allowCrossOrigin: true, allowCrossOrigin: true,
headers: { headers: {
@@ -24,9 +24,9 @@ export default {
format: 'es', format: 'es',
}, },
plugins: [ plugins: [
typescript({ exclude: ['test/**/*'] }),
nodeResolve(), nodeResolve(),
json(), json(),
typescript(),
babel({ babel({
exclude: 'node_modules/**', exclude: 'node_modules/**',
}), }),
+207
View File
@@ -0,0 +1,207 @@
import { noChange } from 'lit';
import { AttributePart, directive, Directive, DirectiveParameters } from 'lit/directive.js';
import { fireEvent } from 'custom-card-helpers';
import { ActionHandlerDetail, ActionHandlerOptions } from 'custom-card-helpers/dist/types';
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): 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): 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-empty-function, @typescript-eslint/no-unused-vars, @typescript-eslint/explicit-function-return-type
render(_options?: ActionHandlerOptions) {}
},
);
+4 -11
View File
@@ -1,13 +1,6 @@
import { import { css, CSSResult, html, LitElement, TemplateResult } from 'lit';
css, import { customElement, property } from 'lit/decorators.js';
CSSResult, import { ClassInfo, classMap } from 'lit/directives/class-map.js';
customElement,
html,
LitElement,
property,
TemplateResult,
} from 'lit-element';
import { ClassInfo, classMap } from 'lit-html/directives/class-map';
import { Layout, Period } from '../types'; import { Layout, Period } from '../types';
/** /**
@@ -63,7 +56,7 @@ export class TimePeriodComponent extends LitElement {
.time-period { .time-period {
width: 30px; width: 30px;
padding: 8px; padding: var(--tpc-control-padding);
background: var(--tpc-off-color); background: var(--tpc-off-color);
color: var(--tpc-text-color); color: var(--tpc-text-color);
text-align: center; text-align: center;
+4 -11
View File
@@ -1,12 +1,5 @@
import { import { LitElement, TemplateResult, html, CSSResult, css } from 'lit';
css, import { customElement, property } from 'lit/decorators.js';
CSSResult,
customElement,
html,
LitElement,
property,
TemplateResult,
} from 'lit-element';
import { TimeUnit } from '../models/time-unit'; import { TimeUnit } from '../models/time-unit';
import { Direction } from '../types'; import { Direction } from '../types';
@@ -69,7 +62,7 @@ export class TimeUnitComponent extends LitElement {
.time-picker-icon { .time-picker-icon {
width: 30px; width: 30px;
padding: 8px; padding: var(--tpc-control-padding);
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
color: var(--tpc-icon-color); color: var(--tpc-icon-color);
@@ -77,7 +70,7 @@ export class TimeUnitComponent extends LitElement {
.time-input { .time-input {
width: 30px; width: 30px;
padding: 8px 8px 6px; padding: var(--tpc-control-padding);
background: var(--tpc-elements-background-color); background: var(--tpc-elements-background-color);
border: 0; border: 0;
border-bottom: 2px solid var(--tpc-elements-background-color); border-bottom: 2px solid var(--tpc-elements-background-color);
+2 -1
View File
@@ -10,6 +10,7 @@ export const ENTITY_DOMAIN = 'input_datetime';
export const DEFAULT_HOUR_STEP = 1; export const DEFAULT_HOUR_STEP = 1;
export const DEFAULT_MINUTE_STEP = 5; export const DEFAULT_MINUTE_STEP = 5;
export const DEFAULT_SECOND_STEP = 5; export const DEFAULT_SECOND_STEP = 5;
export const DEFAULT_LAYOUT_HOUR_MODE = 'double'; 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_ALIGN_CONTROLS = Layout.AlignControls.CENTER;
export const DEFAULT_LAYOUT_NAME = Layout.Name.HEADER; export const DEFAULT_LAYOUT_NAME = Layout.Name.HEADER;
+136 -253
View File
@@ -1,256 +1,158 @@
import { computeDomain, HomeAssistant, LovelaceCardEditor } from 'custom-card-helpers'; import { HomeAssistant, LovelaceCardEditor } from 'custom-card-helpers';
import { HassEntity } from 'home-assistant-js-websocket'; import { supportsEntityNameSelector } from './entity-name';
import { import { html, LitElement, TemplateResult } from 'lit';
css, import { customElement, property } from 'lit/decorators.js';
CSSResult, import { TimePickerCardConfig } from './types';
customElement,
html, const NAME_TO_LABEL_MAP = {
LitElement, entity: 'input_datetime entity id',
property, name: 'Name',
TemplateResult, hour_step: 'Hour step',
} from 'lit-element'; minute_step: 'Minute step',
import { hour_mode: 'Hour mode',
DEFAULT_HOUR_STEP, link_values: 'Link values',
DEFAULT_MINUTE_STEP, align_controls: 'Align controls',
ENTITY_DOMAIN, embedded: 'Embedded?',
DEFAULT_LAYOUT_ALIGN_CONTROLS, thin: 'Thin layout?',
DEFAULT_LAYOUT_NAME, icon: 'Icon',
DEFAULT_SECOND_STEP, seconds: 'Seconds',
} from './const'; };
import { TimePickerCardConfig, Layout, HourMode } from './types';
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') @customElement('time-picker-card-editor')
export class TimePickerCardEditor extends LitElement implements LovelaceCardEditor { export class TimePickerCardEditor extends LitElement implements LovelaceCardEditor {
private static readonly NUMBER_PROPERTIES = [
'hour_step',
'minute_step',
'second_step',
'hour_mode',
];
private static readonly CONFIG_CHANGED_EVENT = 'config-changed'; private static readonly CONFIG_CHANGED_EVENT = 'config-changed';
@property({ type: Object }) hass!: HomeAssistant; @property({ type: Object }) hass!: HomeAssistant;
@property() private config!: TimePickerCardConfig; @property() private config!: TimePickerCardConfig;
private get entity(): HassEntity | undefined { private computeLabel({ name }): string {
return this.hass.states[this.config.entity]; return NAME_TO_LABEL_MAP[name] || name;
} }
private get datetimeEntities(): Array<string> { private valueChanged(ev: CustomEvent): void {
return Object.keys(this.hass.states).filter( const newConfig = { ...this.config, ...ev.detail.value };
(entityId) => this.dispatch(newConfig);
computeDomain(entityId) === ENTITY_DOMAIN &&
this.hass.states[entityId].attributes.has_time === true
);
} }
render(): TemplateResult { render(): TemplateResult {
return html`<div class="card-config"> return html`
<paper-dropdown-menu <ha-form
style="width: 100%" .hass=${this.hass}
label="Entity (Required)" .data=${this.config}
.configValue=${'entity'} .schema=${schema(this.hass)}
@value-changed=${this.onValueChange} .computeLabel=${this.computeLabel}
> @value-changed=${this.valueChanged}
<paper-listbox ></ha-form>
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)}"
@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
style="margin-left: 10px"
.checked="${this.config.hide?.seconds === false}"
@change="${this.onHideSecondsChange}"
></ha-switch>
Show seconds?
</div>
<paper-input
type="number"
label="Second Step (Optional)"
.configValue=${'second_step'}
.value=${this.config.second_step || DEFAULT_SECOND_STEP}
@value-changed=${this.onValueChange}
></paper-input>
</div>
<div class="side-by-side">
<div>
<ha-switch
.checked="${this.config.hour_mode === 12}"
@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'}"
@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}"
@change="${this.onLinkValuesChange}"
></ha-switch>
Link Values
</div>
</div>
<div class="side-by-side">
<div>
<ha-switch
.checked="${this.config.layout?.embedded}"
@change="${this.onEmbeddedChange}"
></ha-switch>
Embedded
</div>
</div>
</div>`;
} }
setConfig(config): void { setConfig(config): void {
this.config = config; 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 onHideSecondsChange({ target: { checked } }): void {
const newConfig = { ...this.config, hide: { seconds: !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 onEmbeddedChange({ target: { checked } }): void {
const newConfig = { ...this.config, layout: { embedded: 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 { private dispatch(config: TimePickerCardConfig): void {
const event = new CustomEvent(TimePickerCardEditor.CONFIG_CHANGED_EVENT, { const event = new CustomEvent(TimePickerCardEditor.CONFIG_CHANGED_EVENT, {
bubbles: true, bubbles: true,
@@ -260,23 +162,4 @@ export class TimePickerCardEditor extends LitElement implements LovelaceCardEdit
this.dispatchEvent(event); this.dispatchEvent(event);
} }
static get styles(): CSSResult {
return css`
ha-switch {
padding: 16px 0;
margin-right: 16px;
}
.side-by-side {
display: flex;
}
.side-by-side > * {
flex: 1;
padding-right: 4px;
}
.suffix {
margin: 0 8px;
}
`;
}
} }
+55
View File
@@ -0,0 +1,55 @@
import { HomeAssistant } from 'custom-card-helpers';
import { HassEntity } from 'home-assistant-js-websocket';
import { EntityName } from './types';
// hass.formatEntityName only accepts a card's `name` option (a user string, a
// structured name, or undefined) from HA 2026.4. Earlier versions expose the
// same helper with an incompatible signature, so feature detection is not
// enough - the version has to be checked.
function atLeastVersion(hass: HomeAssistant, major: number, minor: number): boolean {
const [haMajor, haMinor] = (hass.config?.version ?? '').split('.', 2);
return Number(haMajor) > major || (Number(haMajor) === major && Number(haMinor) >= minor);
}
function supportsEntityNames(hass: HomeAssistant): boolean {
// A hass can report a recent version without carrying the helper (a test
// harness, or a hass that has not finished initialising), and calling it
// then throws - so the version gate alone is not enough.
if (!hass || typeof (hass as { formatEntityName?: unknown }).formatEntityName !== 'function') {
return false;
}
return atLeastVersion(hass, 2026, 4);
}
/**
* The `entity_name` selector, which lets users compose a name out of registry
* parts in the visual editor, was added in HA 2025.11.
*/
export function supportsEntityNameSelector(hass: HomeAssistant): boolean {
return atLeastVersion(hass, 2025, 11);
}
type HassWithEntityNames = HomeAssistant & {
formatEntityName: (stateObj: HassEntity, name: EntityName | undefined) => string;
};
/**
* Resolves a `name` option against the entity's registry context (entity,
* device, area, floor). Falls back to the friendly name on Home Assistant
* versions that cannot resolve a structured name.
*/
export function computeEntityName(
hass: HomeAssistant,
stateObj: HassEntity | undefined,
name: EntityName | undefined
): string | undefined {
const configuredName = typeof name === 'string' ? name : undefined;
if (!stateObj) {
return configuredName;
}
if (supportsEntityNames(hass)) {
return (hass as HassWithEntityNames).formatEntityName(stateObj, name);
}
return configuredName ?? stateObj.attributes.friendly_name;
}
+2 -12
View File
@@ -1,7 +1,6 @@
import { html, TemplateResult } from 'lit-element';
import { TimePickerCardConfig } from './types';
import { LovelaceCard } from 'custom-card-helpers'; 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 { export class Partial {
static error(error: string, origConfig: TimePickerCardConfig): TemplateResult { static error(error: string, origConfig: TimePickerCardConfig): TemplateResult {
@@ -14,13 +13,4 @@ export class Partial {
return html`${errorCard}`; 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>`;
}
} }
+127 -27
View File
@@ -1,22 +1,28 @@
import { computeDomain, HomeAssistant, LovelaceCard } from 'custom-card-helpers';
import { HassEntity } from 'home-assistant-js-websocket';
import { import {
css, ActionHandlerEvent,
CSSResult, computeDomain,
customElement, handleAction,
html, hasAction,
LitElement, HomeAssistant,
property, LovelaceCard,
TemplateResult, } from 'custom-card-helpers';
} from 'lit-element'; import { HassEntity } from 'home-assistant-js-websocket';
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 { actionHandler } from './action-handler-directive';
import { computeEntityName } from './entity-name';
import './components/time-period.component'; import './components/time-period.component';
import './components/time-unit.component'; import './components/time-unit.component';
import { import {
CARD_SIZE, CARD_SIZE,
CARD_VERSION, CARD_VERSION,
DEFAULT_HOUR_MODE,
DEFAULT_HOUR_STEP,
DEFAULT_LAYOUT_ALIGN_CONTROLS, DEFAULT_LAYOUT_ALIGN_CONTROLS,
DEFAULT_LAYOUT_HOUR_MODE, DEFAULT_LAYOUT_HOUR_MODE,
DEFAULT_LAYOUT_NAME,
DEFAULT_MINUTE_STEP,
ENTITY_DOMAIN, ENTITY_DOMAIN,
} from './const'; } from './const';
import './editor'; import './editor';
@@ -25,7 +31,7 @@ import { Minute } from './models/minute';
import { Second } from './models/second'; import { Second } from './models/second';
import { Time } from './models/time'; import { Time } from './models/time';
import { Partial } from './partials'; import { Partial } from './partials';
import { Layout, Period, TimePickerCardConfig } from './types'; import { Layout, Period, TimePickerCardConfig, TimePickerHideConfig } from './types';
console.info( console.info(
`%c TIME-PICKER-CARD \n%c Version ${CARD_VERSION} `, `%c TIME-PICKER-CARD \n%c Version ${CARD_VERSION} `,
@@ -47,6 +53,8 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
@property() private time!: Time; @property() private time!: Time;
@property() private period!: Period; @property() private period!: Period;
private bounce: number | undefined;
private get entity(): HassEntity | undefined { private get entity(): HassEntity | undefined {
return this.hass.states[this.config.entity]; return this.hass.states[this.config.entity];
} }
@@ -67,13 +75,12 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
private get hasNameInside(): boolean { private get hasNameInside(): boolean {
return ( return (
Boolean(this.name) && Boolean(this.name) &&
Boolean(this.config.hide?.name) === false &&
(this.config.layout?.name === Layout.Name.INSIDE || Boolean(this.config.layout?.embedded)) (this.config.layout?.name === Layout.Name.INSIDE || Boolean(this.config.layout?.embedded))
); );
} }
private get name(): string | undefined { 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 { private get shouldShowPeriod(): boolean {
@@ -87,6 +94,7 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
private get haCardClass(): ClassInfo { private get haCardClass(): ClassInfo {
return { return {
embedded: this.isEmbedded, embedded: this.isEmbedded,
thin: this.config.layout?.thin === true,
}; };
} }
@@ -105,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 { render(): TemplateResult | null {
if (!this.entity) { if (!this.entity) {
return Partial.error('Entity not found', this.config); return Partial.error('Entity not found', this.config);
@@ -130,28 +190,30 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
return html` return html`
<ha-card class=${classMap(this.haCardClass)}> <ha-card class=${classMap(this.haCardClass)}>
${this.hasNameInHeader ? Partial.headerName(this.name!) : ''} ${this.hasNameInHeader ? this.renderHeaderName(this.name!) : ''}
<div class=${classMap(this.rowClass)}> <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)}> <div class=${classMap(this.contentClass)}>
<time-unit <time-unit
.unit=${this.time.hour} .unit=${this.time.hour}
@stepChange=${this.onHourStepChange} @stepChange=${this.onHourStepChange}
@update=${this.callHassService} @update=${this.debouncedCallHassService}
></time-unit> ></time-unit>
<div class="time-separator">:</div> <div class="time-separator">:</div>
<time-unit <time-unit
.unit=${this.time.minute} .unit=${this.time.minute}
@stepChange=${this.onMinuteStepChange} @stepChange=${this.onMinuteStepChange}
@update=${this.callHassService} @update=${this.debouncedCallHassService}
></time-unit> ></time-unit>
${this.config.hide?.seconds === false ${this.config.hide?.seconds === false
? html`<div class="time-separator">:</div> ? html`<div class="time-separator">:</div>
<time-unit <time-unit
.unit=${this.time.second} .unit=${this.time.second}
@stepChange=${this.onSecondStepChange} @stepChange=${this.onSecondStepChange}
@update=${this.callHassService} @update=${this.debouncedCallHassService}
></time-unit>` ></time-unit>`
: ''} : ''}
${this.shouldShowPeriod ${this.shouldShowPeriod
@@ -189,22 +251,31 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
private onPeriodToggle(): void { private onPeriodToggle(): void {
this.time.hour.togglePeriod(); this.time.hour.togglePeriod();
this.callHassService(); this.debouncedCallHassService();
} }
private onHourStepChange(event: CustomEvent): void { private onHourStepChange(event: CustomEvent): void {
this.time.hourStep(event.detail.direction); this.time.hourStep(event.detail.direction);
this.callHassService(); this.debouncedCallHassService();
} }
private onMinuteStepChange(event: CustomEvent): void { private onMinuteStepChange(event: CustomEvent): void {
this.time.minuteStep(event.detail.direction); this.time.minuteStep(event.detail.direction);
this.callHassService(); this.debouncedCallHassService();
} }
private onSecondStepChange(event: CustomEvent): void { private onSecondStepChange(event: CustomEvent): void {
this.time.secondStep(event.detail.direction); this.time.secondStep(event.detail.direction);
this.callHassService(); 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> { private callHassService(): Promise<void> {
@@ -226,6 +297,8 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
var(--primary-color) 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-icon-color: var(--time-picker-icon-color, var(--primary-text-color));
--tpc-text-color: var(--time-picker-text-color, #fff); --tpc-text-color: var(--time-picker-text-color, #fff);
--tpc-accent-color: var(--time-picker-accent-color, var(--primary-color)); --tpc-accent-color: var(--time-picker-accent-color, var(--primary-color));
@@ -234,8 +307,14 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
--tpc-border-radius: var(--time-picker-border-radius, var(--ha-card-border-radius, 4px)); --tpc-border-radius: var(--time-picker-border-radius, var(--ha-card-border-radius, 4px));
} }
ha-card {
overflow: auto;
}
ha-card.embedded { ha-card.embedded {
box-shadow: none; box-shadow: none;
border: none;
background: transparent;
} }
.time-picker-header { .time-picker-header {
@@ -248,6 +327,10 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
text-align: center; text-align: center;
} }
.thin > .time-picker-header {
padding: 4px;
}
.time-picker-row { .time-picker-row {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@@ -255,6 +338,10 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
padding: 16px; padding: 16px;
} }
.thin .time-picker-row {
padding: 0 !important ;
}
.time-picker-row.embedded { .time-picker-row.embedded {
padding: 0; padding: 0;
} }
@@ -282,8 +369,13 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
justify-content: flex-end; justify-content: flex-end;
} }
.entity-icon {
cursor: pointer;
}
.entity-name-inside { .entity-name-inside {
margin-left: 16px; margin-left: 16px;
cursor: pointer;
} }
`; `;
} }
@@ -295,10 +387,18 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
const datetimeEntity = entities.find((entityId) => computeDomain(entityId) === ENTITY_DOMAIN); const datetimeEntity = entities.find((entityId) => computeDomain(entityId) === ENTITY_DOMAIN);
return { return {
entity: datetimeEntity || 'input_datetime.example_entity', entity: datetimeEntity || '',
hour_mode: 24, hour_mode: DEFAULT_HOUR_MODE,
hour_step: 1, hour_step: DEFAULT_HOUR_STEP,
minute_step: 5, 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,
},
}; };
} }
+15 -2
View File
@@ -1,17 +1,28 @@
import { LovelaceCardConfig } from 'custom-card-helpers'; import { ActionConfig, LovelaceCardConfig } from 'custom-card-helpers';
export interface TimePickerCardConfig extends LovelaceCardConfig { export interface TimePickerCardConfig extends LovelaceCardConfig {
entity: string; entity: string;
name?: string; name?: EntityName;
link_values?: boolean; link_values?: boolean;
hour_mode?: HourMode; hour_mode?: HourMode;
hour_step?: number; hour_step?: number;
minute_step?: number; minute_step?: number;
second_step?: number; second_step?: number;
delay?: number;
layout?: TimePickerLayoutConfig; layout?: TimePickerLayoutConfig;
hide?: TimePickerHideConfig; hide?: TimePickerHideConfig;
tap_action?: ActionConfig;
double_tap_action?: ActionConfig;
hold_action?: ActionConfig;
} }
/** A `name` option: a plain string, or name parts resolved from the registry. */
export type EntityName = string | EntityNameItem | EntityNameItem[];
export type EntityNameItem =
| { type: 'entity' | 'device' | 'parent_device' | 'area' | 'floor' }
| { type: 'text'; text: string };
export type HourMode = 12 | 24 | undefined; export type HourMode = 12 | 24 | undefined;
export interface TimePickerLayoutConfig { export interface TimePickerLayoutConfig {
@@ -19,6 +30,7 @@ export interface TimePickerLayoutConfig {
name?: Layout.Name; name?: Layout.Name;
hour_mode?: Layout.HourMode; hour_mode?: Layout.HourMode;
embedded?: boolean; embedded?: boolean;
thin?: boolean;
} }
export namespace Layout { export namespace Layout {
@@ -38,6 +50,7 @@ export namespace Layout {
export interface TimePickerHideConfig { export interface TimePickerHideConfig {
name?: boolean; name?: boolean;
icon?: boolean;
seconds?: boolean; seconds?: boolean;
} }
-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);
});
});
});
-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,
},
});
};
+32 -33
View File
@@ -1,165 +1,164 @@
import { Hour } from '../../src/models/hour'; import { Hour } from '../../src/models/hour';
import { Direction } from '../../src/types'; import { Direction } from '../../src/types';
import { DEFAULT_HOUR_STEP } from '../../src/const'; import { DEFAULT_HOUR_STEP } from '../../src/const';
import { expect } from 'chai';
describe('Hour', () => { describe('Hour', () => {
let hour: Hour; let hour: Hour;
context('with default config', () => { describe('with default config', () => {
beforeEach(() => { beforeEach(() => {
hour = new Hour(2); hour = new Hour(2);
}); });
it('returns value', () => { it('returns value', () => {
expect(hour.value).to.equal(2); expect(hour.value).toEqual(2);
}); });
it('has correct max value', () => { it('has correct max value', () => {
expect(hour.maxValue).to.equal(23); expect(hour.maxValue).toEqual(23);
}); });
describe('toString', () => { describe('toString', () => {
it('returns formatted string', () => { it('returns formatted string', () => {
expect(hour.toString()).to.equal('02'); expect(hour.toString()).toEqual('02');
}); });
it('renders values correctly', () => { it('renders values correctly', () => {
hour = new Hour(0); hour = new Hour(0);
expect(hour.toString()).to.equal('00'); expect(hour.toString()).toEqual('00');
hour = new Hour(6); hour = new Hour(6);
expect(hour.toString()).to.equal('06'); expect(hour.toString()).toEqual('06');
hour = new Hour(12); hour = new Hour(12);
expect(hour.toString()).to.equal('12'); expect(hour.toString()).toEqual('12');
hour = new Hour(18); hour = new Hour(18);
expect(hour.toString()).to.equal('18'); expect(hour.toString()).toEqual('18');
hour = new Hour(23); hour = new Hour(23);
expect(hour.toString()).to.equal('23'); expect(hour.toString()).toEqual('23');
}); });
}); });
describe('stepUpdate', () => { describe('stepUpdate', () => {
it('updates up', () => { it('updates up', () => {
hour.stepUpdate(Direction.UP); hour.stepUpdate(Direction.UP);
expect(hour.value).to.equal(3); expect(hour.value).toEqual(3);
}); });
it('updates down', () => { it('updates down', () => {
hour.stepUpdate(Direction.DOWN); hour.stepUpdate(Direction.DOWN);
expect(hour.value).to.equal(1); expect(hour.value).toEqual(1);
}); });
it('goes up from 23 to 0', () => { it('goes up from 23 to 0', () => {
hour = new Hour(23); hour = new Hour(23);
hour.stepUpdate(Direction.UP); hour.stepUpdate(Direction.UP);
expect(hour.value).to.equal(0); expect(hour.value).toEqual(0);
}); });
it('goes down from 0 to 23', () => { it('goes down from 0 to 23', () => {
hour = new Hour(0); hour = new Hour(0);
hour.stepUpdate(Direction.DOWN); hour.stepUpdate(Direction.DOWN);
expect(hour.value).to.equal(23); expect(hour.value).toEqual(23);
}); });
}); });
describe('setStringValue', () => { describe('setStringValue', () => {
it('sets value from valid string', () => { it('sets value from valid string', () => {
hour.setStringValue('3'); hour.setStringValue('3');
expect(hour.value).to.equal(3); expect(hour.value).toEqual(3);
}); });
it("doesn't set value from invalid string", () => { it("doesn't set value from invalid string", () => {
hour.setStringValue('test'); hour.setStringValue('test');
expect(hour.value).to.equal(2); expect(hour.value).toEqual(2);
}); });
it("doesn't set value from outside of scope", () => { it("doesn't set value from outside of scope", () => {
hour.setStringValue('24'); 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(() => { beforeEach(() => {
hour = new Hour(2, 5); hour = new Hour(2, 5);
}); });
it('goes up correctly', () => { it('goes up correctly', () => {
hour.stepUpdate(Direction.UP); 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);
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', () => { it('goes down correclty', () => {
hour.stepUpdate(Direction.DOWN); 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(() => { beforeEach(() => {
hour = new Hour(2, DEFAULT_HOUR_STEP, 12); hour = new Hour(2, DEFAULT_HOUR_STEP, 12);
}); });
it('has correct max value', () => { it('has correct max value', () => {
expect(hour.maxValue).to.equal(12); expect(hour.maxValue).toEqual(12);
}); });
describe('togglePeriod', () => { describe('togglePeriod', () => {
it('changes values correclty', () => { it('changes values correclty', () => {
hour = new Hour(2, DEFAULT_HOUR_STEP, 12); hour = new Hour(2, DEFAULT_HOUR_STEP, 12);
hour.togglePeriod(); hour.togglePeriod();
expect(hour.value).to.equal(14); expect(hour.value).toEqual(14);
hour = new Hour(0, DEFAULT_HOUR_STEP, 12); hour = new Hour(0, DEFAULT_HOUR_STEP, 12);
hour.togglePeriod(); hour.togglePeriod();
expect(hour.value).to.equal(12); expect(hour.value).toEqual(12);
hour = new Hour(23, DEFAULT_HOUR_STEP, 12); hour = new Hour(23, DEFAULT_HOUR_STEP, 12);
hour.togglePeriod(); hour.togglePeriod();
expect(hour.value).to.equal(11); expect(hour.value).toEqual(11);
}); });
it('has no side effects', () => { it('has no side effects', () => {
const value = hour.value; const value = hour.value;
hour.togglePeriod(); hour.togglePeriod();
hour.togglePeriod(); hour.togglePeriod();
expect(hour.value).to.equal(value); expect(hour.value).toEqual(value);
}); });
}); });
describe('toString', () => { describe('toString', () => {
it('renders values above 12 orrectly', () => { it('renders values above 12 orrectly', () => {
hour = new Hour(13, DEFAULT_HOUR_STEP, 12); hour = new Hour(13, DEFAULT_HOUR_STEP, 12);
expect(hour.toString()).to.equal('01'); expect(hour.toString()).toEqual('01');
}); });
it('renders values correctly', () => { it('renders values correctly', () => {
hour = new Hour(0, DEFAULT_HOUR_STEP, 12); 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); 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); 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); 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); hour = new Hour(23, DEFAULT_HOUR_STEP, 12);
expect(hour.toString()).to.equal('11'); expect(hour.toString()).toEqual('11');
}); });
}); });
}); });
+22 -23
View File
@@ -1,119 +1,118 @@
import { Minute } from '../../src/models/minute'; import { Minute } from '../../src/models/minute';
import { Direction } from '../../src/types'; import { Direction } from '../../src/types';
import { expect } from 'chai';
describe('Minute', () => { describe('Minute', () => {
let minute: Minute; let minute: Minute;
context('with default config', () => { describe('with default config', () => {
beforeEach(() => { beforeEach(() => {
minute = new Minute(2); minute = new Minute(2);
}); });
it('returns value', () => { it('returns value', () => {
expect(minute.value).to.equal(2); expect(minute.value).toEqual(2);
}); });
it('has correct max value', () => { it('has correct max value', () => {
expect(minute.maxValue).to.equal(59); expect(minute.maxValue).toEqual(59);
}); });
describe('toString', () => { describe('toString', () => {
it('renders values correctly', () => { it('renders values correctly', () => {
minute = new Minute(0); minute = new Minute(0);
expect(minute.toString()).to.equal('00'); expect(minute.toString()).toEqual('00');
minute = new Minute(5); minute = new Minute(5);
expect(minute.toString()).to.equal('05'); expect(minute.toString()).toEqual('05');
minute = new Minute(59); minute = new Minute(59);
expect(minute.toString()).to.equal('59'); expect(minute.toString()).toEqual('59');
}); });
}); });
describe('stepUpdate', () => { describe('stepUpdate', () => {
it('updates up', () => { it('updates up', () => {
minute.stepUpdate(Direction.UP); minute.stepUpdate(Direction.UP);
expect(minute.value).to.equal(7); expect(minute.value).toEqual(7);
}); });
it('updates down', () => { it('updates down', () => {
minute.stepUpdate(Direction.DOWN); minute.stepUpdate(Direction.DOWN);
expect(minute.value).to.equal(57); expect(minute.value).toEqual(57);
}); });
it('goes up from 59 to 4', () => { it('goes up from 59 to 4', () => {
minute = new Minute(59); minute = new Minute(59);
minute.stepUpdate(Direction.UP); minute.stepUpdate(Direction.UP);
expect(minute.value).to.equal(4); expect(minute.value).toEqual(4);
}); });
it('goes down from 0 to 55', () => { it('goes down from 0 to 55', () => {
minute = new Minute(0); minute = new Minute(0);
minute.stepUpdate(Direction.DOWN); minute.stepUpdate(Direction.DOWN);
expect(minute.value).to.equal(55); expect(minute.value).toEqual(55);
}); });
}); });
describe('setStringValue', () => { describe('setStringValue', () => {
it('sets value from valid string', () => { it('sets value from valid string', () => {
minute.setStringValue('3'); minute.setStringValue('3');
expect(minute.value).to.equal(3); expect(minute.value).toEqual(3);
}); });
it("doesn't set value from invalid string", () => { it("doesn't set value from invalid string", () => {
minute.setStringValue('test'); minute.setStringValue('test');
expect(minute.value).to.equal(2); expect(minute.value).toEqual(2);
}); });
it("doesn't set value from outside of scope", () => { it("doesn't set value from outside of scope", () => {
minute.setStringValue('60'); minute.setStringValue('60');
expect(minute.value).to.equal(2); expect(minute.value).toEqual(2);
minute.setStringValue('65'); 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(() => { beforeEach(() => {
minute = new Minute(2, 15); minute = new Minute(2, 15);
}); });
it('goes up correctly', () => { it('goes up correctly', () => {
minute.stepUpdate(Direction.UP); 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); 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); minute.stepUpdate(Direction.UP);
expect(minute.value).to.equal(17); expect(minute.value).toEqual(17);
}); });
it('goes down correclty', () => { it('goes down correclty', () => {
minute.stepUpdate(Direction.DOWN); minute.stepUpdate(Direction.DOWN);
expect(minute.value).to.equal(47); expect(minute.value).toEqual(47);
}); });
}); });
describe('will overflow', () => { describe('will overflow', () => {
it('returns true if the minutes will overflow an hour up', () => { it('returns true if the minutes will overflow an hour up', () => {
minute.setStringValue('59'); 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', () => { it('returns true if the minutes will overflow an hour down', () => {
minute.setStringValue('0'); 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', () => { it('returns true if the minutes will not overflow', () => {
minute.setStringValue('55'); minute.setStringValue('55');
expect(minute.willOverflow(Direction.DOWN)).to.be.false; expect(minute.willOverflow(Direction.DOWN)).toBe(false);
}); });
}); });
}); });
+22 -23
View File
@@ -1,119 +1,118 @@
import { Second } from '../../src/models/second'; import { Second } from '../../src/models/second';
import { Direction } from '../../src/types'; import { Direction } from '../../src/types';
import { expect } from 'chai';
describe('Second', () => { describe('Second', () => {
let second: Second; let second: Second;
context('with default config', () => { describe('with default config', () => {
beforeEach(() => { beforeEach(() => {
second = new Second(2); second = new Second(2);
}); });
it('returns value', () => { it('returns value', () => {
expect(second.value).to.equal(2); expect(second.value).toEqual(2);
}); });
it('has correct max value', () => { it('has correct max value', () => {
expect(second.maxValue).to.equal(59); expect(second.maxValue).toEqual(59);
}); });
describe('toString', () => { describe('toString', () => {
it('renders values correctly', () => { it('renders values correctly', () => {
second = new Second(0); second = new Second(0);
expect(second.toString()).to.equal('00'); expect(second.toString()).toEqual('00');
second = new Second(5); second = new Second(5);
expect(second.toString()).to.equal('05'); expect(second.toString()).toEqual('05');
second = new Second(59); second = new Second(59);
expect(second.toString()).to.equal('59'); expect(second.toString()).toEqual('59');
}); });
}); });
describe('stepUpdate', () => { describe('stepUpdate', () => {
it('updates up', () => { it('updates up', () => {
second.stepUpdate(Direction.UP); second.stepUpdate(Direction.UP);
expect(second.value).to.equal(7); expect(second.value).toEqual(7);
}); });
it('updates down', () => { it('updates down', () => {
second.stepUpdate(Direction.DOWN); second.stepUpdate(Direction.DOWN);
expect(second.value).to.equal(57); expect(second.value).toEqual(57);
}); });
it('goes up from 59 to 4', () => { it('goes up from 59 to 4', () => {
second = new Second(59); second = new Second(59);
second.stepUpdate(Direction.UP); second.stepUpdate(Direction.UP);
expect(second.value).to.equal(4); expect(second.value).toEqual(4);
}); });
it('goes down from 0 to 55', () => { it('goes down from 0 to 55', () => {
second = new Second(0); second = new Second(0);
second.stepUpdate(Direction.DOWN); second.stepUpdate(Direction.DOWN);
expect(second.value).to.equal(55); expect(second.value).toEqual(55);
}); });
}); });
describe('setStringValue', () => { describe('setStringValue', () => {
it('sets value from valid string', () => { it('sets value from valid string', () => {
second.setStringValue('3'); second.setStringValue('3');
expect(second.value).to.equal(3); expect(second.value).toEqual(3);
}); });
it("doesn't set value from invalid string", () => { it("doesn't set value from invalid string", () => {
second.setStringValue('test'); second.setStringValue('test');
expect(second.value).to.equal(2); expect(second.value).toEqual(2);
}); });
it("doesn't set value from outside of scope", () => { it("doesn't set value from outside of scope", () => {
second.setStringValue('60'); second.setStringValue('60');
expect(second.value).to.equal(2); expect(second.value).toEqual(2);
second.setStringValue('65'); second.setStringValue('65');
expect(second.value).to.equal(2); expect(second.value).toEqual(2);
}); });
}); });
}); });
context('with a different step size', () => { describe('with a different step size', () => {
beforeEach(() => { beforeEach(() => {
second = new Second(2, 15); second = new Second(2, 15);
}); });
it('goes up correctly', () => { it('goes up correctly', () => {
second.stepUpdate(Direction.UP); second.stepUpdate(Direction.UP);
expect(second.value).to.equal(17); expect(second.value).toEqual(17);
second.stepUpdate(Direction.UP); second.stepUpdate(Direction.UP);
second.stepUpdate(Direction.UP); second.stepUpdate(Direction.UP);
second.stepUpdate(Direction.UP); second.stepUpdate(Direction.UP);
expect(second.value).to.equal(2); expect(second.value).toEqual(2);
second.stepUpdate(Direction.UP); second.stepUpdate(Direction.UP);
expect(second.value).to.equal(17); expect(second.value).toEqual(17);
}); });
it('goes down correclty', () => { it('goes down correclty', () => {
second.stepUpdate(Direction.DOWN); second.stepUpdate(Direction.DOWN);
expect(second.value).to.equal(47); expect(second.value).toEqual(47);
}); });
}); });
describe('will overflow', () => { describe('will overflow', () => {
it('returns true if the Seconds will overflow an hour up', () => { it('returns true if the Seconds will overflow an hour up', () => {
second.setStringValue('59'); second.setStringValue('59');
expect(second.willOverflow(Direction.UP)).to.be.true; expect(second.willOverflow(Direction.UP)).toBe(true);
}); });
it('returns true if the Seconds will overflow an hour down', () => { it('returns true if the Seconds will overflow an hour down', () => {
second.setStringValue('0'); second.setStringValue('0');
expect(second.willOverflow(Direction.DOWN)).to.be.true; expect(second.willOverflow(Direction.DOWN)).toBe(true);
}); });
it('returns true if the Seconds will not overflow', () => { it('returns true if the Seconds will not overflow', () => {
second.setStringValue('55'); second.setStringValue('55');
expect(second.willOverflow(Direction.DOWN)).to.be.false; expect(second.willOverflow(Direction.DOWN)).toBe(false);
}); });
}); });
}); });