mirror of
https://github.com/GeorgeSG/lovelace-time-picker-card.git
synced 2026-11-02 03:08:45 +00:00
Modernize tech stack
This commit is contained in:
@@ -1,18 +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
|
|
||||||
],
|
|
||||||
"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:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -12,10 +12,20 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v7
|
||||||
- name: Build
|
- uses: actions/setup-node@v7
|
||||||
run: |
|
with:
|
||||||
npm install
|
node-version-file: .nvmrc
|
||||||
npm run build
|
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
|
- name: Test
|
||||||
run: npm run test
|
run: npm test
|
||||||
|
- name: Build
|
||||||
|
run: npm run rollup
|
||||||
|
|||||||
@@ -4,21 +4,24 @@ on:
|
|||||||
release:
|
release:
|
||||||
types: [published]
|
types: [published]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
name: Build and upload
|
name: Build and upload
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
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
|
- name: Build time-picker-card.js
|
||||||
run: |
|
run: |
|
||||||
npm install
|
npm ci
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
- name: Upload to release
|
- name: Upload to release
|
||||||
uses: shopify/upload-to-release@1.0.0
|
run: gh release upload "${{ github.event.release.tag_name }}" dist/time-picker-card.js --clobber
|
||||||
with:
|
env:
|
||||||
name: time-picker-card.js
|
GH_TOKEN: ${{ github.token }}
|
||||||
path: dist/time-picker-card.js
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ dist/
|
|||||||
devtools/homeassistant/*
|
devtools/homeassistant/*
|
||||||
!devtools/homeassistant/configuration.yaml
|
!devtools/homeassistant/configuration.yaml
|
||||||
!devtools/homeassistant/ui-lovelace.yaml
|
!devtools/homeassistant/ui-lovelace.yaml
|
||||||
|
!devtools/homeassistant/packages/
|
||||||
|
|||||||
@@ -107,21 +107,22 @@ layout:
|
|||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
| Name | Type | Requirement | Description | Default |
|
| Name | Type | Requirement | Description | Default |
|
||||||
| ----------------- | ------------ | ------------ | --------------------------------------------------------------------------------------------------------- | ------------------------ |
|
| ----------------- | ------------- | ------------ | --------------------------------------------------------------------------------------------------------- | ------------- |
|
||||||
| type | string | **Required** | `custom:time-picker-card` | |
|
| type | string | **Required** | `custom:time-picker-card` | |
|
||||||
| entity | string | **Required** | [Input Datetime](https://www.home-assistant.io/integrations/input_datetime/) entity with `has_time: true` | |
|
| entity | string | **Required** | [Input Datetime](https://www.home-assistant.io/integrations/input_datetime/) entity with `has_time: true` | |
|
||||||
| name | string / list | **Optional** | Card name. Accepts a [structured name](#structured-names) on Home Assistant 2026.4 and later. | Entity's name |
|
| name | string / list | **Optional** | Card name. Accepts a [structured name](#structured-names) on Home Assistant 2026.4 and later. | Entity's name |
|
||||||
| link_values | boolean | **Optional** | If enabled, will change hour when minutes overflow. E.g. will go from 11:55 to 12:00, instead of 11:00 | `false` |
|
| link_values | boolean | **Optional** | If enabled, will change hour when minutes overflow. E.g. will go from 11:55 to 12:00, instead of 11:00 | `false` |
|
||||||
| hour_mode | `12` or `24` | **Optional** | Hour format. If `12`, card will show AM/PM picker | `24` |
|
| hour_mode | `12` or `24` | **Optional** | Hour format. If `12`, card will show AM/PM picker | `24` |
|
||||||
| hour_step | number | **Optional** | Hour change when clicking arrows | `1` |
|
| hour_step | number | **Optional** | Hour change when clicking arrows | `1` |
|
||||||
| minute_step | number | **Optional** | Minute change when clicking arrows | `5` |
|
| minute_step | number | **Optional** | Minute change when clicking arrows | `5` |
|
||||||
| delay | number | **Optional** | Delay in ms before updating entity | `0` |
|
| second_step | number | **Optional** | Second change when clicking arrows (only with `hide.seconds: false`) | `5` |
|
||||||
| layout | object | **Optional** | Card Layout configuration | `none` |
|
| delay | number | **Optional** | Delay in ms before updating entity | `0` |
|
||||||
| hide | object | **Optional** | Hide object | `none` |
|
| layout | object | **Optional** | Card Layout configuration | `none` |
|
||||||
| tap_action | action | **Optional** | Home assistant action to perform on tap | `more-info` |
|
| hide | object | **Optional** | Hide object | `none` |
|
||||||
| double_tap_action | action | **Optional** | Home assistant action to perform on tap | `more-info` |
|
| 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` |
|
| double_tap_action | action | **Optional** | Home assistant action to perform on tap | `more-info` |
|
||||||
|
| hold_action | action | **Optional** | Home assistant action to perform on tap | `more-info` |
|
||||||
|
|
||||||
### Layout Object
|
### Layout Object
|
||||||
|
|
||||||
@@ -158,7 +159,7 @@ you can use the following variables in your theme's config file:
|
|||||||
|
|
||||||
### Structured names
|
### Structured names
|
||||||
|
|
||||||
*Requires Home Assistant 2026.4 or later. On earlier versions a structured `name` falls back to the entity's friendly name.*
|
_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
|
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
|
(entity, device, area, floor) rather than one `friendly_name` string. `name` can
|
||||||
@@ -194,9 +195,9 @@ See the [Home Assistant developer documentation](https://developers.home-assista
|
|||||||
[downloads-link]: https://github.com/GeorgeSG/lovelace-time-picker-card/releases
|
[downloads-link]: https://github.com/GeorgeSG/lovelace-time-picker-card/releases
|
||||||
[releases-shield]: https://img.shields.io/github/release/GeorgeSG/lovelace-time-picker-card.svg
|
[releases-shield]: https://img.shields.io/github/release/GeorgeSG/lovelace-time-picker-card.svg
|
||||||
[releases-link]: https://github.com/GeorgeSG/lovelace-time-picker-card/releases
|
[releases-link]: https://github.com/GeorgeSG/lovelace-time-picker-card/releases
|
||||||
[ci-shield]: https://img.shields.io/github/workflow/status/GeorgeSG/lovelace-time-picker-card/CI?label=CI&logo=github&
|
[ci-shield]: https://img.shields.io/github/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
|
[ci-link]: https://github.com/GeorgeSG/lovelace-time-picker-card/actions?query=workflow%3ACI
|
||||||
[maintenance-shield]: https://img.shields.io/maintenance/yes/2022.svg
|
[maintenance-shield]: https://img.shields.io/maintenance/yes/2026.svg
|
||||||
[maintenance-link]: https://github.com/GeorgeSG/lovelace-time-picker-card
|
[maintenance-link]: https://github.com/GeorgeSG/lovelace-time-picker-card
|
||||||
[license-shield]: https://img.shields.io/github/license/GeorgeSG/lovelace-time-picker-card?color=brightgreen
|
[license-shield]: https://img.shields.io/github/license/GeorgeSG/lovelace-time-picker-card?color=brightgreen
|
||||||
[license-link]: https://github.com/GeorgeSG/lovelace-time-picker-card/blob/master/LICENSE
|
[license-link]: https://github.com/GeorgeSG/lovelace-time-picker-card/blob/master/LICENSE
|
||||||
|
|||||||
@@ -6,18 +6,12 @@ homeassistant:
|
|||||||
|
|
||||||
logger:
|
logger:
|
||||||
default: info
|
default: info
|
||||||
logs:
|
|
||||||
hacs: debug
|
|
||||||
|
|
||||||
lovelace:
|
lovelace:
|
||||||
mode: yaml
|
resource_mode: yaml
|
||||||
resources:
|
resources:
|
||||||
- type: module
|
- type: module
|
||||||
url: /hacsfiles/button-card/button-card.js
|
url: http://127.0.0.1:5100/time-picker-card.js
|
||||||
- type: module
|
|
||||||
url: /hacsfiles/lovelace-state-switch/state-switch.js
|
|
||||||
- type: module
|
|
||||||
url: http://127.0.0.1:5000/time-picker-card.js
|
|
||||||
dashboards:
|
dashboards:
|
||||||
lovelace-home:
|
lovelace-home:
|
||||||
mode: yaml
|
mode: 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'
|
||||||
@@ -6,7 +6,7 @@ views:
|
|||||||
- type: vertical-stack
|
- type: vertical-stack
|
||||||
cards:
|
cards:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
content: "# 🕰 Time Picker Card dev"
|
content: '# 🕰 Time Picker Card dev'
|
||||||
- type: entities
|
- type: entities
|
||||||
entities:
|
entities:
|
||||||
- input_datetime.test_date_time
|
- input_datetime.test_date_time
|
||||||
@@ -21,20 +21,20 @@ views:
|
|||||||
action: call-service
|
action: call-service
|
||||||
service: logbook.log
|
service: logbook.log
|
||||||
service_data:
|
service_data:
|
||||||
name: "Time picker card · Tap Action"
|
name: 'Time picker card · Tap Action'
|
||||||
message: "Debug"
|
message: 'Debug'
|
||||||
double_tap_action:
|
double_tap_action:
|
||||||
action: call-service
|
action: call-service
|
||||||
service: logbook.log
|
service: logbook.log
|
||||||
service_data:
|
service_data:
|
||||||
name: "Time picker card · Double Tap Action"
|
name: 'Time picker card · Double Tap Action'
|
||||||
message: "Debug"
|
message: 'Debug'
|
||||||
hold_action:
|
hold_action:
|
||||||
action: call-service
|
action: call-service
|
||||||
service: logbook.log
|
service: logbook.log
|
||||||
service_data:
|
service_data:
|
||||||
name: "Time picker card · Hold action"
|
name: 'Time picker card · Hold action'
|
||||||
message: "Debug"
|
message: 'Debug'
|
||||||
- type: custom:time-picker-card
|
- type: custom:time-picker-card
|
||||||
name: Hour mode 12, thin, single, hidden name
|
name: Hour mode 12, thin, single, hidden name
|
||||||
entity: input_datetime.test_date_time
|
entity: input_datetime.test_date_time
|
||||||
@@ -60,7 +60,7 @@ views:
|
|||||||
thin: true
|
thin: true
|
||||||
- type: custom:time-picker-card
|
- type: custom:time-picker-card
|
||||||
entity: input_datetime.test_date_time
|
entity: input_datetime.test_date_time
|
||||||
name: Hidden seconds
|
name: Shown seconds
|
||||||
hide:
|
hide:
|
||||||
seconds: false
|
seconds: false
|
||||||
|
|
||||||
@@ -120,7 +120,6 @@ views:
|
|||||||
cards:
|
cards:
|
||||||
- type: vertical-stack
|
- type: vertical-stack
|
||||||
cards:
|
cards:
|
||||||
|
|
||||||
- type: entities
|
- type: entities
|
||||||
entities:
|
entities:
|
||||||
- input_datetime.test_date_time
|
- input_datetime.test_date_time
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]);
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
preset: 'ts-jest/presets/js-with-babel',
|
|
||||||
testRegex: '/test/.*\\.test?\\.ts$',
|
|
||||||
transformIgnorePatterns: [
|
|
||||||
'node_modules/(?!(testing-library__dom|@open-wc|lit-html|lit-element|pure-lit|lit-element-state-decoupler)/)',
|
|
||||||
],
|
|
||||||
moduleFileExtensions: ['ts', 'js'],
|
|
||||||
};
|
|
||||||
Generated
+2240
-4187
File diff suppressed because it is too large
Load Diff
+35
-23
@@ -13,38 +13,50 @@
|
|||||||
"lovelace-custom-card"
|
"lovelace-custom-card"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
"repository": "git@github.com:GeorgeSG/lovelace-time-picker-card.git",
|
"repository": "git@github.com:GeorgeSG/lovelace-time-picker-card.git",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24",
|
||||||
|
"npm": ">=11"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "rollup -c --watch --bundleConfigAsCjs",
|
"start": "rollup -c --watch",
|
||||||
"build": "npm run lint && npm run rollup",
|
"build": "npm run lint && npm run typecheck && npm run rollup",
|
||||||
"lint": "eslint src/*.ts",
|
"lint": "eslint .",
|
||||||
"rollup": "rollup -c --bundleConfigAsCjs",
|
"rollup": "rollup -c",
|
||||||
"test": "jest",
|
"test": "vitest run",
|
||||||
"release-patch": "npm version patch && git push origin master --tags",
|
"release-patch": "npm version patch && git push origin master --tags",
|
||||||
"release-minor": "npm version minor && git push origin master --tags",
|
"release-minor": "npm version minor && git push origin master --tags",
|
||||||
"release-major": "npm version major && git push origin master --tags",
|
"release-major": "npm version major && git push origin master --tags",
|
||||||
"start:ha": "docker run --rm -p8124:8123 -v $(pwd)/devtools/homeassistant:/config homeassistant/home-assistant:latest"
|
"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": {
|
"dependencies": {
|
||||||
"custom-card-helpers": "^1.9.0",
|
"custom-card-helpers": "^2.0.0",
|
||||||
"home-assistant-js-websocket": "^8.0.0",
|
"home-assistant-js-websocket": "^9.7.0",
|
||||||
"lit": "^2.8.0"
|
"lit": "^3.3.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@el3um4s/rollup-plugin-terser": "^1.0.2",
|
"@eslint/js": "^10.0.1",
|
||||||
"@rollup/plugin-babel": "^6.0.2",
|
|
||||||
"@rollup/plugin-commonjs": "^28.0.1",
|
|
||||||
"@rollup/plugin-json": "^6.1.0",
|
"@rollup/plugin-json": "^6.1.0",
|
||||||
"@rollup/plugin-node-resolve": "^15.0.1",
|
"@rollup/plugin-node-resolve": "^16.0.3",
|
||||||
"@rollup/plugin-typescript": "^12.1.1",
|
"@rollup/plugin-terser": "^1.0.0",
|
||||||
"@types/jest": "^29.2.1",
|
"@rollup/plugin-typescript": "^12.3.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.13.0",
|
"eslint": "^10.10.0",
|
||||||
"eslint": "^8.26.0",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"jest": "^29.2.2",
|
"eslint-plugin-lit": "^2.3.1",
|
||||||
"prettier": "^3.3.3",
|
"eslint-plugin-wc": "^3.1.0",
|
||||||
"rollup": "^4.24.4",
|
"globals": "^17.12.0",
|
||||||
"rollup-plugin-serve": "^2.0.1",
|
"happy-dom": "^20.14.5",
|
||||||
"ts-jest": "^29.0.3",
|
"prettier": "^3.9.6",
|
||||||
"typescript": "^5.6.3"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-7
@@ -1,16 +1,15 @@
|
|||||||
import nodeResolve from '@rollup/plugin-node-resolve';
|
import nodeResolve from '@rollup/plugin-node-resolve';
|
||||||
import json from '@rollup/plugin-json';
|
import json from '@rollup/plugin-json';
|
||||||
import typescript from '@rollup/plugin-typescript';
|
import typescript from '@rollup/plugin-typescript';
|
||||||
import { terser } from '@el3um4s/rollup-plugin-terser';
|
import terser from '@rollup/plugin-terser';
|
||||||
import babel from '@rollup/plugin-babel';
|
|
||||||
import serve from 'rollup-plugin-serve';
|
import serve from 'rollup-plugin-serve';
|
||||||
|
|
||||||
const dev = process.env.ROLLUP_WATCH;
|
const dev = process.env.ROLLUP_WATCH;
|
||||||
|
|
||||||
const serveOptions = {
|
const serveOptions = {
|
||||||
contentBase: ['./dist'],
|
contentBase: ['./dist'],
|
||||||
host: 'localhost',
|
host: '127.0.0.1', // 'localhost' resolves to ::1 on Node 17+, but HA loads the card from 127.0.0.1
|
||||||
port: 5000,
|
port: 5100, // 5000 is taken by the macOS AirPlay Receiver
|
||||||
allowCrossOrigin: true,
|
allowCrossOrigin: true,
|
||||||
headers: {
|
headers: {
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
@@ -27,9 +26,6 @@ export default {
|
|||||||
typescript({ exclude: ['test/**/*'] }),
|
typescript({ exclude: ['test/**/*'] }),
|
||||||
nodeResolve(),
|
nodeResolve(),
|
||||||
json(),
|
json(),
|
||||||
babel({
|
|
||||||
exclude: 'node_modules/**',
|
|
||||||
}),
|
|
||||||
dev && serve(serveOptions),
|
dev && serve(serveOptions),
|
||||||
!dev && terser(),
|
!dev && terser(),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { noChange } from 'lit';
|
import { noChange } from 'lit';
|
||||||
import { AttributePart, directive, Directive, DirectiveParameters } from 'lit/directive.js';
|
import { AttributePart, directive, Directive, DirectiveParameters } from 'lit/directive.js';
|
||||||
|
|
||||||
import { fireEvent } from 'custom-card-helpers';
|
import { ActionHandlerDetail, ActionHandlerOptions, fireEvent } from 'custom-card-helpers';
|
||||||
import { ActionHandlerDetail, ActionHandlerOptions } from 'custom-card-helpers/dist/types';
|
|
||||||
|
|
||||||
const isTouch =
|
const isTouch =
|
||||||
'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.maxTouchPoints > 0;
|
'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.maxTouchPoints > 0;
|
||||||
@@ -10,7 +9,7 @@ const isTouch =
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
||||||
interface ActionHandler extends HTMLElement {
|
interface ActionHandler extends HTMLElement {
|
||||||
holdTime: number;
|
holdTime: number;
|
||||||
bind(element: Element, options): void;
|
bind(element: Element, options?: ActionHandlerOptions): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActionHandlerElement extends HTMLElement {
|
interface ActionHandlerElement extends HTMLElement {
|
||||||
@@ -69,7 +68,7 @@ class ActionHandler extends HTMLElement implements ActionHandler {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bind(element: ActionHandlerElement, options): void {
|
public bind(element: ActionHandlerElement, options: ActionHandlerOptions = {}): void {
|
||||||
if (element.actionHandler) {
|
if (element.actionHandler) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -201,7 +200,7 @@ export const actionHandler = directive(
|
|||||||
return noChange;
|
return noChange;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars, @typescript-eslint/explicit-function-return-type
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/explicit-function-return-type
|
||||||
render(_options?: ActionHandlerOptions) {}
|
render(_options?: ActionHandlerOptions) {}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+3
-3
@@ -4,7 +4,7 @@ import { html, LitElement, TemplateResult } from 'lit';
|
|||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { TimePickerCardConfig } from './types';
|
import { TimePickerCardConfig } from './types';
|
||||||
|
|
||||||
const NAME_TO_LABEL_MAP = {
|
const NAME_TO_LABEL_MAP: Record<string, string> = {
|
||||||
entity: 'input_datetime entity id',
|
entity: 'input_datetime entity id',
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
hour_step: 'Hour step',
|
hour_step: 'Hour step',
|
||||||
@@ -128,7 +128,7 @@ export class TimePickerCardEditor extends LitElement implements LovelaceCardEdit
|
|||||||
@property({ type: Object }) hass!: HomeAssistant;
|
@property({ type: Object }) hass!: HomeAssistant;
|
||||||
@property() private config!: TimePickerCardConfig;
|
@property() private config!: TimePickerCardConfig;
|
||||||
|
|
||||||
private computeLabel({ name }): string {
|
private computeLabel({ name }: { name: string }): string {
|
||||||
return NAME_TO_LABEL_MAP[name] || name;
|
return NAME_TO_LABEL_MAP[name] || name;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ export class TimePickerCardEditor extends LitElement implements LovelaceCardEdit
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
setConfig(config): void {
|
setConfig(config: TimePickerCardConfig): void {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -41,7 +41,7 @@ type HassWithEntityNames = HomeAssistant & {
|
|||||||
export function computeEntityName(
|
export function computeEntityName(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
stateObj: HassEntity | undefined,
|
stateObj: HassEntity | undefined,
|
||||||
name: EntityName | undefined
|
name: EntityName | undefined,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
const configuredName = typeof name === 'string' ? name : undefined;
|
const configuredName = typeof name === 'string' ? name : undefined;
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -8,7 +8,11 @@ import { TimeUnit } from './time-unit';
|
|||||||
export class Hour extends TimeUnit {
|
export class Hour extends TimeUnit {
|
||||||
private static readonly VALUE_LIMIT = 24;
|
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);
|
super(value, step, Hour.VALUE_LIMIT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -19,7 +19,11 @@ export abstract class TimeUnit {
|
|||||||
* @param _step how much to increase / decrease the value when step-changing
|
* @param _step how much to increase / decrease the value when step-changing
|
||||||
* @param _limit value upper limit
|
* @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 {
|
get value(): number {
|
||||||
return this._value;
|
return this._value;
|
||||||
@@ -40,7 +44,11 @@ export abstract class TimeUnit {
|
|||||||
* @param direction
|
* @param direction
|
||||||
*/
|
*/
|
||||||
stepUpdate(direction: Direction, step: number = this._step): void {
|
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 {
|
toString(): string {
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ export class Time {
|
|||||||
public hour: Hour,
|
public hour: Hour,
|
||||||
public minute: Minute,
|
public minute: Minute,
|
||||||
public second: Second,
|
public second: Second,
|
||||||
private _linkValues: boolean = false
|
private _linkValues: boolean = false,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
hourStep(direction: Direction): void {
|
hourStep(direction: Direction): void {
|
||||||
|
|||||||
+29
-23
@@ -36,7 +36,7 @@ import { Layout, Period, TimePickerCardConfig, TimePickerHideConfig } from './ty
|
|||||||
console.info(
|
console.info(
|
||||||
`%c TIME-PICKER-CARD \n%c Version ${CARD_VERSION} `,
|
`%c TIME-PICKER-CARD \n%c Version ${CARD_VERSION} `,
|
||||||
'color: orange; font-weight: bold; background: black',
|
'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 = window.customCards || [];
|
||||||
@@ -133,7 +133,7 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
|
|||||||
private renderNestedName(
|
private renderNestedName(
|
||||||
name: string,
|
name: string,
|
||||||
entity: HassEntity,
|
entity: HassEntity,
|
||||||
hide?: TimePickerHideConfig
|
hide?: TimePickerHideConfig,
|
||||||
): TemplateResult {
|
): TemplateResult {
|
||||||
const icon = html`<state-badge
|
const icon = html`<state-badge
|
||||||
class="entity-icon"
|
class="entity-icon"
|
||||||
@@ -177,7 +177,7 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
|
|||||||
if (!this.entity.attributes.has_time) {
|
if (!this.entity.attributes.has_time) {
|
||||||
return Partial.error(
|
return Partial.error(
|
||||||
`You must set an ${ENTITY_DOMAIN} entity that sets has_time: true`,
|
`You must set an ${ENTITY_DOMAIN} entity that sets has_time: true`,
|
||||||
this.config
|
this.config,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,9 +192,11 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
|
|||||||
<ha-card class=${classMap(this.haCardClass)}>
|
<ha-card class=${classMap(this.haCardClass)}>
|
||||||
${this.hasNameInHeader ? this.renderHeaderName(this.name!) : ''}
|
${this.hasNameInHeader ? this.renderHeaderName(this.name!) : ''}
|
||||||
<div class=${classMap(this.rowClass)}>
|
<div class=${classMap(this.rowClass)}>
|
||||||
${this.hasNameInside
|
${
|
||||||
? this.renderNestedName(this.name!, this.entity, this.config.hide)
|
this.hasNameInside
|
||||||
: ''}
|
? this.renderNestedName(this.name!, this.entity, this.config.hide)
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
|
||||||
<div class=${classMap(this.contentClass)}>
|
<div class=${classMap(this.contentClass)}>
|
||||||
<time-unit
|
<time-unit
|
||||||
@@ -208,28 +210,32 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
|
|||||||
@stepChange=${this.onMinuteStepChange}
|
@stepChange=${this.onMinuteStepChange}
|
||||||
@update=${this.debouncedCallHassService}
|
@update=${this.debouncedCallHassService}
|
||||||
></time-unit>
|
></time-unit>
|
||||||
${this.config.hide?.seconds === false
|
${
|
||||||
? html`<div class="time-separator">:</div>
|
this.config.hide?.seconds === false
|
||||||
<time-unit
|
? html`<div class="time-separator">:</div>
|
||||||
.unit=${this.time.second}
|
<time-unit
|
||||||
@stepChange=${this.onSecondStepChange}
|
.unit=${this.time.second}
|
||||||
@update=${this.debouncedCallHassService}
|
@stepChange=${this.onSecondStepChange}
|
||||||
></time-unit>`
|
@update=${this.debouncedCallHassService}
|
||||||
: ''}
|
></time-unit>`
|
||||||
${this.shouldShowPeriod
|
: ''
|
||||||
? html`<time-period
|
}
|
||||||
.period=${this.period}
|
${
|
||||||
.mode=${this.config.layout?.hour_mode ?? DEFAULT_LAYOUT_HOUR_MODE}
|
this.shouldShowPeriod
|
||||||
@toggle=${this.onPeriodToggle}
|
? html`<time-period
|
||||||
></time-period>`
|
.period=${this.period}
|
||||||
: ''}
|
.mode=${this.config.layout?.hour_mode ?? DEFAULT_LAYOUT_HOUR_MODE}
|
||||||
|
@toggle=${this.onPeriodToggle}
|
||||||
|
></time-period>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ha-card>
|
</ha-card>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
setConfig(config): void {
|
setConfig(config: TimePickerCardConfig): void {
|
||||||
if (!config) {
|
if (!config) {
|
||||||
throw new Error('Invalid configuration');
|
throw new Error('Invalid configuration');
|
||||||
}
|
}
|
||||||
@@ -382,7 +388,7 @@ export class TimePickerCard extends LitElement implements LovelaceCard {
|
|||||||
|
|
||||||
static getStubConfig(
|
static getStubConfig(
|
||||||
_: HomeAssistant,
|
_: HomeAssistant,
|
||||||
entities: Array<string>
|
entities: Array<string>,
|
||||||
): Omit<TimePickerCardConfig, 'type'> {
|
): Omit<TimePickerCardConfig, 'type'> {
|
||||||
const datetimeEntity = entities.find((entityId) => computeDomain(entityId) === ENTITY_DOMAIN);
|
const datetimeEntity = entities.find((entityId) => computeDomain(entityId) === ENTITY_DOMAIN);
|
||||||
|
|
||||||
|
|||||||
@@ -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,3 +1,4 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { Hour } from '../../src/models/hour';
|
import { Hour } from '../../src/models/hour';
|
||||||
import { Direction } from '../../src/types';
|
import { Direction } from '../../src/types';
|
||||||
import { DEFAULT_HOUR_STEP } from '../../src/const';
|
import { DEFAULT_HOUR_STEP } from '../../src/const';
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { Minute } from '../../src/models/minute';
|
import { Minute } from '../../src/models/minute';
|
||||||
import { Direction } from '../../src/types';
|
import { Direction } from '../../src/types';
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { Second } from '../../src/models/second';
|
import { Second } from '../../src/models/second';
|
||||||
import { Direction } from '../../src/types';
|
import { Direction } from '../../src/types';
|
||||||
|
|
||||||
|
|||||||
@@ -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,171 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { LitElement } from 'lit';
|
||||||
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
|
import { HassEntity } from 'home-assistant-js-websocket';
|
||||||
|
import '../src/time-picker-card';
|
||||||
|
import { TimePickerCard } from '../src/time-picker-card';
|
||||||
|
import { TimePickerCardConfig } from '../src/types';
|
||||||
|
|
||||||
|
// The card renders Home Assistant's error card for invalid configs; stub the
|
||||||
|
// element so the test can read the error it was given.
|
||||||
|
class ErrorCardStub extends HTMLElement {
|
||||||
|
config?: { error: string };
|
||||||
|
setConfig(config: { error: string }): void {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('hui-error-card', ErrorCardStub);
|
||||||
|
|
||||||
|
const ENTITY_ID = 'input_datetime.alarm';
|
||||||
|
|
||||||
|
/** YAML is untyped at runtime, so setConfig has to cope with shapes the type forbids. */
|
||||||
|
const invalidConfig = (config: Record<string, unknown>): TimePickerCardConfig =>
|
||||||
|
config as unknown as TimePickerCardConfig;
|
||||||
|
|
||||||
|
const alarm = (hour: number, minute = 30, second = 0): HassEntity => ({
|
||||||
|
entity_id: ENTITY_ID,
|
||||||
|
state: `${hour}:${minute}:${second}`,
|
||||||
|
attributes: { friendly_name: 'Alarm', has_time: true, has_date: false, hour, minute, second },
|
||||||
|
last_changed: '',
|
||||||
|
last_updated: '',
|
||||||
|
context: { id: '', user_id: null, parent_id: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const createHass = (entity: HassEntity = alarm(7)): HomeAssistant =>
|
||||||
|
({
|
||||||
|
states: { [entity.entity_id]: entity },
|
||||||
|
config: { version: '2026.9.2' },
|
||||||
|
callService: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}) as unknown as HomeAssistant;
|
||||||
|
|
||||||
|
const renderCard = async (
|
||||||
|
config: Partial<TimePickerCardConfig>,
|
||||||
|
hass: HomeAssistant = createHass(),
|
||||||
|
): Promise<TimePickerCard> => {
|
||||||
|
const card = document.createElement('time-picker-card') as TimePickerCard;
|
||||||
|
card.setConfig({ type: 'custom:time-picker-card', entity: ENTITY_ID, ...config });
|
||||||
|
card.hass = hass;
|
||||||
|
document.body.appendChild(card);
|
||||||
|
await settle(card);
|
||||||
|
return card;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Waits for the card and its nested Lit children to finish rendering. */
|
||||||
|
const settle = async (card: TimePickerCard): Promise<void> => {
|
||||||
|
await card.updateComplete;
|
||||||
|
const children = card.shadowRoot!.querySelectorAll('time-unit, time-period');
|
||||||
|
await Promise.all(Array.from(children).map((el) => (el as LitElement).updateComplete));
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputValues = (card: TimePickerCard): string[] =>
|
||||||
|
Array.from(card.shadowRoot!.querySelectorAll('time-unit')).map(
|
||||||
|
(unit) => unit.shadowRoot!.querySelector<HTMLInputElement>('input')!.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('time-picker-card', () => {
|
||||||
|
let card: TimePickerCard;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
card?.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setConfig', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
card = document.createElement('time-picker-card') as TimePickerCard;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a missing entity', () => {
|
||||||
|
expect(() => card.setConfig(invalidConfig({ type: 'custom:time-picker-card' }))).toThrow(
|
||||||
|
'You must set an entity',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown hour mode', () => {
|
||||||
|
expect(() =>
|
||||||
|
card.setConfig(
|
||||||
|
invalidConfig({ type: 'custom:time-picker-card', entity: ENTITY_ID, hour_mode: 10 }),
|
||||||
|
),
|
||||||
|
).toThrow('Invalid hour_mode');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('shows hour and minute inputs with the entity time', async () => {
|
||||||
|
card = await renderCard({});
|
||||||
|
expect(inputValues(card)).toEqual(['07', '30']);
|
||||||
|
expect(card.shadowRoot!.querySelector('time-period')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the entity name in the header by default', async () => {
|
||||||
|
card = await renderCard({});
|
||||||
|
expect(card.shadowRoot!.querySelector('.time-picker-header')!.textContent!.trim()).toEqual(
|
||||||
|
'Alarm',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows seconds and an AM/PM toggle when configured', async () => {
|
||||||
|
card = await renderCard({ hour_mode: 12, hide: { seconds: false } });
|
||||||
|
expect(inputValues(card)).toEqual(['07', '30', '00']);
|
||||||
|
expect(card.shadowRoot!.querySelector('time-period')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-renders when hass delivers a new entity state', async () => {
|
||||||
|
card = await renderCard({});
|
||||||
|
card.hass = createHass(alarm(9, 45));
|
||||||
|
await settle(card);
|
||||||
|
expect(inputValues(card)).toEqual(['09', '45']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an error card when the entity is missing', async () => {
|
||||||
|
card = await renderCard({ entity: 'input_datetime.missing' });
|
||||||
|
const error = card.shadowRoot!.querySelector('hui-error-card') as ErrorCardStub;
|
||||||
|
expect(error.config?.error).toEqual('Entity not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an error card when the entity has no time', async () => {
|
||||||
|
const dateOnly = { ...alarm(7), attributes: { friendly_name: 'Date', has_time: false } };
|
||||||
|
card = await renderCard({}, createHass(dateOnly));
|
||||||
|
const error = card.shadowRoot!.querySelector('hui-error-card') as ErrorCardStub;
|
||||||
|
expect(error.config?.error).toContain('has_time: true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('interaction', () => {
|
||||||
|
it('calls set_datetime when an hour arrow is clicked', async () => {
|
||||||
|
const hass = createHass();
|
||||||
|
card = await renderCard({}, hass);
|
||||||
|
const hourUnit = card.shadowRoot!.querySelector('time-unit')!;
|
||||||
|
hourUnit.shadowRoot!.querySelector<HTMLElement>('.time-picker-icon')!.click();
|
||||||
|
|
||||||
|
expect(hass.callService).toHaveBeenCalledWith('input_datetime', 'set_datetime', {
|
||||||
|
entity_id: ENTITY_ID,
|
||||||
|
time: '8:30:0',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debounces the service call when a delay is configured', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const hass = createHass();
|
||||||
|
card = await renderCard({ delay: 500 }, hass);
|
||||||
|
const [up] = Array.from(
|
||||||
|
card
|
||||||
|
.shadowRoot!.querySelector('time-unit')!
|
||||||
|
.shadowRoot!.querySelectorAll<HTMLElement>('.time-picker-icon'),
|
||||||
|
);
|
||||||
|
up.click();
|
||||||
|
up.click();
|
||||||
|
expect(hass.callService).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
expect(hass.callService).toHaveBeenCalledTimes(1);
|
||||||
|
expect(hass.callService).toHaveBeenCalledWith('input_datetime', 'set_datetime', {
|
||||||
|
entity_id: ENTITY_ID,
|
||||||
|
time: '9:30:0',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+15
-8
@@ -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": {
|
"compilerOptions": {
|
||||||
"target": "es2017",
|
"target": "ES2021",
|
||||||
"module": "esnext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "bundler",
|
||||||
"lib": ["es2017", "dom", "dom.iterable"],
|
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noImplicitReturns": true,
|
"noImplicitReturns": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"strict": true,
|
|
||||||
"noImplicitAny": false,
|
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"resolveJsonModule": 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,
|
"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',
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user