Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54d89b3253 | ||
|
|
44cb92bba4 | ||
|
|
326c195dfd | ||
|
|
81ca4cfb53 | ||
|
|
e7aade9647 | ||
|
|
769176243f | ||
|
|
7fc93eecea | ||
|
|
349c0aecf6 | ||
|
|
8477d69bfe | ||
|
|
64b83101dd | ||
|
|
547b34e85a | ||
|
|
1069a8e7e4 | ||
|
|
36732636d0 | ||
|
|
b5ecab9aca | ||
|
|
f3b1711721 | ||
|
|
c1f992ea42 | ||
|
|
bbec5940f5 | ||
|
|
b0b4da98d5 | ||
|
|
479b57df6f | ||
|
|
b9bf51c59d | ||
|
|
7dc9eaa26a | ||
|
|
abd21485ef | ||
|
|
ff2d7a226d | ||
|
|
7a536c4ef6 | ||
|
|
05a1048bde | ||
|
|
2194e049e3 | ||
|
|
d149617781 | ||
|
|
4ada7f0dc2 | ||
|
|
bb0e334c07 | ||
|
|
6cbdc31552 | ||
|
|
65931d4822 | ||
|
|
f7daf893b3 | ||
|
|
b33fd03c11 | ||
|
|
9090fcfc2d | ||
|
|
aeb0d0150e | ||
|
|
5cec16dd22 | ||
|
|
33786c14c0 | ||
|
|
ee129ddb89 | ||
|
|
740354b1cf | ||
|
|
835267f89b | ||
|
|
a606fd1d2e | ||
|
|
9b4cfeb4e5 | ||
|
|
f304900617 | ||
|
|
ad6f3920e6 | ||
|
|
52e6773a37 | ||
|
|
6dc770d954 | ||
|
|
1d88b5ad5b | ||
|
|
c8c9e4df69 | ||
|
|
a2c61d429b | ||
|
|
2a7a024aa3 | ||
|
|
80ebf6fde6 | ||
|
|
e9d3a6c72b | ||
|
|
bde447ceb4 | ||
|
|
41bce3de6b | ||
|
|
e5c16c2afd | ||
|
|
5d06659370 | ||
|
|
7f7a82c653 | ||
|
|
9ebf4feb50 | ||
|
|
9aee96ad18 | ||
|
|
7e70edfa31 | ||
|
|
31ae5e4097 | ||
|
|
27b05d9fcf | ||
|
|
36d9d19959 | ||
|
|
8c22fbbeb5 | ||
|
|
def608444e | ||
|
|
afe276a9d0 | ||
|
|
c9705e25aa | ||
|
|
f4c8fd283a | ||
|
|
ed570dd10a | ||
|
|
2a032a0bbc |
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
devtools/homeassistant/*
|
||||
!devtools/homeassistant/configuration.yaml
|
||||
!devtools/homeassistant/ui-lovelace.yaml
|
||||
!devtools/homeassistant/packages/
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"files.exclude": {
|
||||
"dist/**": true
|
||||
},
|
||||
"files.associations": {
|
||||
"*.yaml": "home-assistant"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
This is a Time Picker Card for [Home Assistant](https://www.home-assistant.io/)'s [Lovelace UI](https://www.home-assistant.io/lovelace).
|
||||
|
||||
Requires an [Input Datetime](https://www.home-assistant.io/integrations/input_datetime/) that has time (`has_time: true`).
|
||||
Supports a [Time](https://www.home-assistant.io/integrations/time/) entity or an [Input Datetime](https://www.home-assistant.io/integrations/input_datetime/) entity that has time (`has_time: true`).
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -66,6 +66,16 @@ hide:
|
||||
name: true
|
||||
```
|
||||
|
||||
### `time` entity
|
||||
|
||||
```yaml
|
||||
type: custom:time-picker-card
|
||||
entity: time.alarm_time
|
||||
minute_step: 5
|
||||
```
|
||||
|
||||
The card reads the time entity's `HH:MM:SS` state and updates it using `time.set_value`.
|
||||
|
||||
### Custom config - hidden card name, 12 hour mode with a "single" hour mode picker
|
||||
|
||||

|
||||
@@ -76,6 +86,7 @@ entity: input_datetime.alarm_time
|
||||
hour_mode: 12
|
||||
layout:
|
||||
hour_mode: single
|
||||
align_controls: right
|
||||
hide:
|
||||
name: true
|
||||
```
|
||||
@@ -89,26 +100,39 @@ 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, thin layout
|
||||
|
||||

|
||||
|
||||
### With a custom lovelace theme
|
||||
|
||||

|
||||
|
||||
## 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` |
|
||||
| link_values | boolean | **Optional** | If enabled, will change hour when minutes overflow. E.g. will go from 11:55 to 12:00, instead of 11:00 | `false` |
|
||||
| hour_mode | `12` or `24` | **Optional** | Hour format. If `12`, card will show AM/PM picker | `24` |
|
||||
| hour_step | number | **Optional** | Hour change when clicking arrows | `1` |
|
||||
| minute_step | number | **Optional** | Minute change when clicking arrows | `5` |
|
||||
| layout | object | **Optional** | Card Layout configuration | `none` |
|
||||
| hide | object | **Optional** | Hide object | `none` |
|
||||
| Name | Type | Requirement | Description | Default |
|
||||
| ----------------- | ------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
|
||||
| type | string | **Required** | `custom:time-picker-card` | |
|
||||
| entity | string | **Required** | [Time](https://www.home-assistant.io/integrations/time/) entity or [Input Datetime](https://www.home-assistant.io/integrations/input_datetime/) entity with `has_time: true` | |
|
||||
| name | string / list | **Optional** | Card name. Accepts a [structured name](#structured-names) on Home Assistant 2026.4 and later. | Entity's name |
|
||||
| link_values | boolean | **Optional** | If enabled, will change hour when minutes overflow. E.g. will go from 11:55 to 12:00, instead of 11:00 | `false` |
|
||||
| hour_mode | `12` or `24` | **Optional** | Hour format. If `12`, card will show AM/PM picker | `24` |
|
||||
| hour_step | number | **Optional** | Hour change when clicking arrows | `1` |
|
||||
| minute_step | number | **Optional** | Minute change when clicking arrows | `5` |
|
||||
| second_step | number | **Optional** | Second change when clicking arrows (only with `hide.seconds: false`) | `5` |
|
||||
| delay | number | **Optional** | Delay in ms before updating entity | `0` |
|
||||
| layout | object | **Optional** | Card Layout configuration | `none` |
|
||||
| hide | object | **Optional** | Hide object | `none` |
|
||||
| tap_action | action | **Optional** | Home assistant action to perform on tap | `more-info` |
|
||||
| double_tap_action | action | **Optional** | Home assistant action to perform on tap | `more-info` |
|
||||
| hold_action | action | **Optional** | Home assistant action to perform on tap | `more-info` |
|
||||
|
||||
### Layout Object
|
||||
|
||||
@@ -117,28 +141,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` |
|
||||
| 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` |
|
||||
| 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` |
|
||||
| seconds | boolean | **Optional** | Hides seconds input | `true` |
|
||||
| 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
|
||||
|
||||
Time Picker Card will automatically pick up colors from your lovelace theme, but if you want to customize some of them,
|
||||
you can use the following variables in your theme's config file:
|
||||
|
||||
| Name | Default | Description |
|
||||
| ------------------------------------- | ------------------------------ | -------------------------------------- |
|
||||
| time-picker-elements-background-color | `var(--primary-color)` | Background color for header and inputs |
|
||||
| time-picker-icon-color | `var(--primary-text-color)` | Arrow color |
|
||||
| time-picker-text-color | `white` | Text color |
|
||||
| time-picker-accent-color | `var(--primary-color)` | AM / PM active color |
|
||||
| time-picker-off-color | `var(--disabled-text-color)` | AM / PM inactive color |
|
||||
| time-picker-border-radius | `var(--ha-card-border-radius)` | Border radius of the card |
|
||||
| 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
|
||||
|
||||
@@ -149,14 +200,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
|
||||
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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,
|
||||
]);
|
||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 142 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "time-picker-card",
|
||||
"version": "1.1.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.6",
|
||||
"home-assistant-js-websocket": "^5.7.0",
|
||||
"lit-element": "^2.4.0",
|
||||
"lit-html": "^1.3.0"
|
||||
"custom-card-helpers": "^2.0.0",
|
||||
"home-assistant-js-websocket": "^9.7.0",
|
||||
"lit": "^3.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.12.7",
|
||||
"@open-wc/testing": "^2.5.32",
|
||||
"@rollup/plugin-commonjs": "^11.1.0",
|
||||
"@rollup/plugin-json": "^4.1.0",
|
||||
"@rollup/plugin-node-resolve": "^7.1.3",
|
||||
"@rollup/plugin-typescript": "^4.1.2",
|
||||
"@types/chai": "^4.2.14",
|
||||
"@typescript-eslint/eslint-plugin": "^2.34.0",
|
||||
"@typescript-eslint/parser": "^2.34.0",
|
||||
"eslint": "^6.8.0",
|
||||
"eslint-config-prettier": "^6.15.0",
|
||||
"eslint-plugin-prettier": "^3.1.4",
|
||||
"karma": "^5.2.3",
|
||||
"karma-chai": "^0.1.0",
|
||||
"karma-chrome-launcher": "^3.1.0",
|
||||
"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"
|
||||
"@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": "^21.0.4",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
|
||||
@@ -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) {}
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -5,11 +5,13 @@ export const CARD_VERSION = pkg.version;
|
||||
export const CARD_SIZE = 3;
|
||||
|
||||
export const ENTITY_DOMAIN = 'input_datetime';
|
||||
export const TIME_ENTITY_DOMAIN = 'time';
|
||||
|
||||
// Config defaults
|
||||
export const DEFAULT_HOUR_STEP = 1;
|
||||
export const DEFAULT_MINUTE_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_NAME = Layout.Name.HEADER;
|
||||
|
||||
@@ -1,258 +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,
|
||||
DEFAULT_SECOND_STEP,
|
||||
} 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: 'time or 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', 'time'] } } },
|
||||
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',
|
||||
'second_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)}"
|
||||
@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>`;
|
||||
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 hide = { ...this.config.hide, name: !checked };
|
||||
const newConfig = { ...this.config, hide };
|
||||
this.dispatch(newConfig);
|
||||
}
|
||||
|
||||
private onHideSecondsChange({ target: { checked } }): void {
|
||||
const hide = { ...this.config.hide, seconds: !checked };
|
||||
const newConfig = { ...this.config, hide };
|
||||
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 {
|
||||
const event = new CustomEvent(TimePickerCardEditor.CONFIG_CHANGED_EVENT, {
|
||||
bubbles: true,
|
||||
@@ -262,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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -8,7 +8,7 @@ export class Time {
|
||||
public hour: Hour,
|
||||
public minute: Minute,
|
||||
public second: Second,
|
||||
private _linkValues: boolean = false
|
||||
private _linkValues: boolean = false,
|
||||
) {}
|
||||
|
||||
hourStep(direction: Direction): void {
|
||||
|
||||
@@ -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,13 +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>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
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_HOUR_MODE,
|
||||
DEFAULT_HOUR_STEP,
|
||||
DEFAULT_LAYOUT_ALIGN_CONTROLS,
|
||||
DEFAULT_LAYOUT_HOUR_MODE,
|
||||
DEFAULT_LAYOUT_NAME,
|
||||
DEFAULT_MINUTE_STEP,
|
||||
ENTITY_DOMAIN,
|
||||
TIME_ENTITY_DOMAIN,
|
||||
} from './const';
|
||||
import './editor';
|
||||
import { Hour } from './models/hour';
|
||||
@@ -25,19 +32,19 @@ import { Minute } from './models/minute';
|
||||
import { Second } from './models/second';
|
||||
import { Time } from './models/time';
|
||||
import { Partial } from './partials';
|
||||
import { Layout, Period, TimePickerCardConfig } from './types';
|
||||
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 || [];
|
||||
window.customCards.push({
|
||||
type: 'time-picker-card',
|
||||
name: 'Time Picker Card',
|
||||
description: 'A Time Picker card for setting the time value of Input Datetime entities.',
|
||||
description: 'A Time Picker card for input_datetime and time entities.',
|
||||
});
|
||||
|
||||
@customElement('time-picker-card')
|
||||
@@ -47,6 +54,8 @@ 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];
|
||||
}
|
||||
@@ -67,13 +76,12 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
|
||||
private get hasNameInside(): boolean {
|
||||
return (
|
||||
Boolean(this.name) &&
|
||||
Boolean(this.config.hide?.name) === false &&
|
||||
(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 {
|
||||
@@ -87,6 +95,7 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
|
||||
private get haCardClass(): ClassInfo {
|
||||
return {
|
||||
embedded: this.isEmbedded,
|
||||
thin: this.config.layout?.thin === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -105,23 +114,90 @@ 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);
|
||||
}
|
||||
|
||||
if (computeDomain(this.entity.entity_id) !== ENTITY_DOMAIN) {
|
||||
return Partial.error(`You must set an ${ENTITY_DOMAIN} entity`, this.config);
|
||||
}
|
||||
|
||||
if (!this.entity.attributes.has_time) {
|
||||
const domain = computeDomain(this.entity.entity_id);
|
||||
if (domain !== ENTITY_DOMAIN && domain !== TIME_ENTITY_DOMAIN) {
|
||||
return Partial.error(
|
||||
`You must set an ${ENTITY_DOMAIN} entity that sets has_time: true`,
|
||||
this.config
|
||||
`You must set an ${ENTITY_DOMAIN} or ${TIME_ENTITY_DOMAIN} entity`,
|
||||
this.config,
|
||||
);
|
||||
}
|
||||
|
||||
const { hour, minute, second } = this.entity!.attributes;
|
||||
if (domain === ENTITY_DOMAIN && !this.entity.attributes.has_time) {
|
||||
return Partial.error(
|
||||
`You must set an ${ENTITY_DOMAIN} entity that sets has_time: true`,
|
||||
this.config,
|
||||
);
|
||||
}
|
||||
|
||||
let hour: number;
|
||||
let minute: number;
|
||||
let second: number;
|
||||
if (domain === TIME_ENTITY_DOMAIN) {
|
||||
const match = /^(\d{2}):(\d{2}):(\d{2})$/.exec(this.entity.state);
|
||||
if (!match || Number(match[1]) > 23 || Number(match[2]) > 59 || Number(match[3]) > 59) {
|
||||
return Partial.error('The time entity has no valid HH:MM:SS value', this.config);
|
||||
}
|
||||
[hour, minute, second] = match.slice(1).map(Number);
|
||||
} else {
|
||||
({ 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);
|
||||
const secondInstance = new Second(second, this.config.second_step);
|
||||
@@ -130,44 +206,52 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
|
||||
|
||||
return html`
|
||||
<ha-card class=${classMap(this.haCardClass)}>
|
||||
${this.hasNameInHeader ? Partial.headerName(this.name!) : ''}
|
||||
${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.config.hide?.seconds === false
|
||||
? html`<div class="time-separator">:</div>
|
||||
<time-unit
|
||||
.unit=${this.time.second}
|
||||
@stepChange=${this.onSecondStepChange}
|
||||
@update=${this.callHassService}
|
||||
></time-unit>`
|
||||
: ''}
|
||||
${this.shouldShowPeriod
|
||||
? html`<time-period
|
||||
.period=${this.period}
|
||||
.mode=${this.config.layout?.hour_mode ?? DEFAULT_LAYOUT_HOUR_MODE}
|
||||
@toggle=${this.onPeriodToggle}
|
||||
></time-period>`
|
||||
: ''}
|
||||
${
|
||||
this.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');
|
||||
}
|
||||
@@ -189,33 +273,54 @@ 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.callHassService();
|
||||
this.debouncedCallHassService();
|
||||
}
|
||||
|
||||
private onSecondStepChange(event: CustomEvent): void {
|
||||
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> {
|
||||
if (!this.hass) {
|
||||
throw new Error('Unable to update datetime');
|
||||
throw new Error('Unable to update time');
|
||||
}
|
||||
|
||||
return this.hass.callService(ENTITY_DOMAIN, 'set_datetime', {
|
||||
entity_id: this.entity!.entity_id,
|
||||
time: this.time.value,
|
||||
});
|
||||
const domain = computeDomain(this.entity!.entity_id);
|
||||
const time =
|
||||
domain === TIME_ENTITY_DOMAIN
|
||||
? [this.time.hour.value, this.time.minute.value, this.time.second.value]
|
||||
.map((value) => String(value).padStart(2, '0'))
|
||||
.join(':')
|
||||
: this.time.value;
|
||||
|
||||
return this.hass.callService(
|
||||
domain,
|
||||
domain === TIME_ENTITY_DOMAIN ? 'set_value' : 'set_datetime',
|
||||
{
|
||||
entity_id: this.entity!.entity_id,
|
||||
time,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static get styles(): CSSResult {
|
||||
@@ -226,6 +331,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));
|
||||
@@ -234,8 +341,14 @@ 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 {
|
||||
@@ -248,6 +361,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;
|
||||
@@ -255,6 +372,10 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.thin .time-picker-row {
|
||||
padding: 0 !important ;
|
||||
}
|
||||
|
||||
.time-picker-row.embedded {
|
||||
padding: 0;
|
||||
}
|
||||
@@ -282,23 +403,38 @@ 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);
|
||||
const datetimeEntity = entities.find((entityId) =>
|
||||
[ENTITY_DOMAIN, TIME_ENTITY_DOMAIN].includes(computeDomain(entityId)),
|
||||
);
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
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 {
|
||||
@@ -19,6 +29,7 @@ export interface TimePickerLayoutConfig {
|
||||
name?: Layout.Name;
|
||||
hour_mode?: Layout.HourMode;
|
||||
embedded?: boolean;
|
||||
thin?: boolean;
|
||||
}
|
||||
|
||||
export namespace Layout {
|
||||
@@ -38,6 +49,7 @@ export namespace Layout {
|
||||
|
||||
export interface TimePickerHideConfig {
|
||||
name?: boolean;
|
||||
icon?: boolean;
|
||||
seconds?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,119 +1,119 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { Second } from '../../src/models/second';
|
||||
import { Direction } from '../../src/types';
|
||||
import { expect } from 'chai';
|
||||
|
||||
describe('Second', () => {
|
||||
let second: Second;
|
||||
|
||||
context('with default config', () => {
|
||||
describe('with default config', () => {
|
||||
beforeEach(() => {
|
||||
second = new Second(2);
|
||||
});
|
||||
|
||||
it('returns value', () => {
|
||||
expect(second.value).to.equal(2);
|
||||
expect(second.value).toEqual(2);
|
||||
});
|
||||
|
||||
it('has correct max value', () => {
|
||||
expect(second.maxValue).to.equal(59);
|
||||
expect(second.maxValue).toEqual(59);
|
||||
});
|
||||
|
||||
describe('toString', () => {
|
||||
it('renders values correctly', () => {
|
||||
second = new Second(0);
|
||||
expect(second.toString()).to.equal('00');
|
||||
expect(second.toString()).toEqual('00');
|
||||
|
||||
second = new Second(5);
|
||||
expect(second.toString()).to.equal('05');
|
||||
expect(second.toString()).toEqual('05');
|
||||
|
||||
second = new Second(59);
|
||||
expect(second.toString()).to.equal('59');
|
||||
expect(second.toString()).toEqual('59');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stepUpdate', () => {
|
||||
it('updates up', () => {
|
||||
second.stepUpdate(Direction.UP);
|
||||
expect(second.value).to.equal(7);
|
||||
expect(second.value).toEqual(7);
|
||||
});
|
||||
|
||||
it('updates down', () => {
|
||||
second.stepUpdate(Direction.DOWN);
|
||||
expect(second.value).to.equal(57);
|
||||
expect(second.value).toEqual(57);
|
||||
});
|
||||
|
||||
it('goes up from 59 to 4', () => {
|
||||
second = new Second(59);
|
||||
second.stepUpdate(Direction.UP);
|
||||
expect(second.value).to.equal(4);
|
||||
expect(second.value).toEqual(4);
|
||||
});
|
||||
|
||||
it('goes down from 0 to 55', () => {
|
||||
second = new Second(0);
|
||||
second.stepUpdate(Direction.DOWN);
|
||||
expect(second.value).to.equal(55);
|
||||
expect(second.value).toEqual(55);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setStringValue', () => {
|
||||
it('sets value from valid string', () => {
|
||||
second.setStringValue('3');
|
||||
expect(second.value).to.equal(3);
|
||||
expect(second.value).toEqual(3);
|
||||
});
|
||||
|
||||
it("doesn't set value from invalid string", () => {
|
||||
second.setStringValue('test');
|
||||
expect(second.value).to.equal(2);
|
||||
expect(second.value).toEqual(2);
|
||||
});
|
||||
|
||||
it("doesn't set value from outside of scope", () => {
|
||||
second.setStringValue('60');
|
||||
expect(second.value).to.equal(2);
|
||||
expect(second.value).toEqual(2);
|
||||
|
||||
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(() => {
|
||||
second = new Second(2, 15);
|
||||
});
|
||||
|
||||
it('goes up correctly', () => {
|
||||
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);
|
||||
expect(second.value).to.equal(2);
|
||||
expect(second.value).toEqual(2);
|
||||
|
||||
second.stepUpdate(Direction.UP);
|
||||
expect(second.value).to.equal(17);
|
||||
expect(second.value).toEqual(17);
|
||||
});
|
||||
|
||||
it('goes down correclty', () => {
|
||||
second.stepUpdate(Direction.DOWN);
|
||||
expect(second.value).to.equal(47);
|
||||
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)).to.be.true;
|
||||
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)).to.be.true;
|
||||
expect(second.willOverflow(Direction.DOWN)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true if the Seconds will not overflow', () => {
|
||||
second.setStringValue('55');
|
||||
expect(second.willOverflow(Direction.DOWN)).to.be.false;
|
||||
expect(second.willOverflow(Direction.DOWN)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
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';
|
||||
const TIME_ENTITY_ID = 'time.alarm_time';
|
||||
|
||||
/** 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 timeEntity = (state = '07:30:00'): HassEntity => ({
|
||||
...alarm(7),
|
||||
entity_id: TIME_ENTITY_ID,
|
||||
state,
|
||||
attributes: { friendly_name: 'Alarm time' },
|
||||
});
|
||||
|
||||
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('reads a time entity from its state and follows state updates', async () => {
|
||||
card = await renderCard({ entity: TIME_ENTITY_ID }, createHass(timeEntity()));
|
||||
expect(inputValues(card)).toEqual(['07', '30']);
|
||||
|
||||
card.hass = createHass(timeEntity('09:45:06'));
|
||||
await settle(card);
|
||||
expect(inputValues(card)).toEqual(['09', '45']);
|
||||
});
|
||||
|
||||
it('rejects an unavailable time entity instead of rendering invalid numbers', async () => {
|
||||
card = await renderCard({ entity: TIME_ENTITY_ID }, createHass(timeEntity('unavailable')));
|
||||
const error = card.shadowRoot!.querySelector('hui-error-card') as ErrorCardStub;
|
||||
expect(error.config?.error).toContain('HH:MM:SS');
|
||||
});
|
||||
|
||||
it('rejects entities from other domains', async () => {
|
||||
const entity = { ...alarm(7), entity_id: 'sensor.alarm' };
|
||||
card = await renderCard({ entity: entity.entity_id }, createHass(entity));
|
||||
const error = card.shadowRoot!.querySelector('hui-error-card') as ErrorCardStub;
|
||||
expect(error.config?.error).toContain('input_datetime or time');
|
||||
});
|
||||
|
||||
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('calls time.set_value with a zero-padded time for time entities', async () => {
|
||||
const hass = createHass(timeEntity());
|
||||
card = await renderCard({ entity: TIME_ENTITY_ID }, hass);
|
||||
const hourUnit = card.shadowRoot!.querySelector('time-unit')!;
|
||||
hourUnit.shadowRoot!.querySelector<HTMLElement>('.time-picker-icon')!.click();
|
||||
|
||||
expect(hass.callService).toHaveBeenCalledWith('time', 'set_value', {
|
||||
entity_id: TIME_ENTITY_ID,
|
||||
time: '08:30:00',
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('offers time entities in the generated configuration', () => {
|
||||
expect(
|
||||
TimePickerCard.getStubConfig(createHass(), ['sensor.unknown', TIME_ENTITY_ID]).entity,
|
||||
).toBe(TIME_ENTITY_ID);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['test/**/*.test.ts'],
|
||||
environment: 'happy-dom',
|
||||
},
|
||||
});
|
||||