Compare commits

...
37 Commits
Author SHA1 Message Date
GeorgeSG fe93d385e9 1.0.0 2020-07-17 20:22:38 +03:00
GeorgeSG 05e0952624 Implements #5: Add link_values option 2020-07-17 20:20:42 +03:00
GeorgeSG 3c10a9ab44 Fixes #6: Inherit border-radius from theme. Add variable for border radius 2020-07-17 19:09:34 +03:00
GeorgeSG 9a2a57af5c Update README 2020-05-18 22:22:32 +03:00
GeorgeSG 74949a3df2 0.1.1 2020-05-10 00:22:45 +03:00
GeorgeSG 224ee3b043 Remove commit activity from README 2020-05-10 00:18:38 +03:00
GeorgeSG 2e604a66d6 Update README 2020-05-10 00:17:43 +03:00
GeorgeSG b7ad71d979 Rename CI task. Don't lint twice 2020-05-10 00:15:20 +03:00
GeorgeSG 79418592c9 Fix 12AM representation. Add model tests 2020-05-10 00:13:29 +03:00
GeorgeSG 0297c9aba4 Update README 2020-05-09 20:42:35 +03:00
GeorgeSG 588cead35d 0.1.0 2020-05-09 20:39:14 +03:00
GeorgeSG 04462e096c Update README 2020-05-09 20:38:29 +03:00
GeorgeSG 03312aa5f7 Add new screenshot and reorganize README. Add code examples 2020-05-09 20:37:23 +03:00
GeorgeSG 4488b303d2 Add name position option. Refactor types 2020-05-09 20:24:37 +03:00
GeorgeSG 491e571aab Add layout.align option 2020-05-09 19:39:36 +03:00
GeorgeSG ae7dfb7668 Add test step in ci 2020-05-09 16:20:11 +03:00
GeorgeSG cc22b482b4 Add some tests 2020-05-09 16:17:53 +03:00
GeorgeSG 812e7f3016 Bump custom-card-helpers. Refactor types 2020-05-09 13:40:26 +03:00
GeorgeSG 20cba88ef2 Update README - add full URLs to images 2020-05-09 13:17:44 +03:00
GeorgeSG 5b6c9d0af0 Update README - add downloads shield 2020-05-09 12:58:27 +03:00
GeorgeSG 993fda7d34 Update README - HACS default 2020-05-09 12:50:11 +03:00
GeorgeSG df9de8bd00 Refactor editor datetimeEntities 2020-05-08 00:58:58 +03:00
GeorgeSG a7b98e31a4 0.0.5 2020-05-08 00:35:47 +03:00
GeorgeSG a92bb7915c Add Visual Editor support 2020-05-08 00:34:45 +03:00
GeorgeSG 6258b55518 Update README 2020-05-07 22:02:46 +03:00
GeorgeSG b4663377a4 Update screenshots 2020-05-07 21:05:55 +03:00
GeorgeSG 1dc6152986 Update README 2020-05-07 20:53:03 +03:00
GeorgeSG 8e11f71b48 0.0.4 2020-05-07 20:37:44 +03:00
GeorgeSG c646fc9c9e Update default colors and variables 2020-05-07 20:37:33 +03:00
GeorgeSG 2791d8109c 0.0.3 2020-05-07 19:55:59 +03:00
GeorgeSG 14cc1a2c10 Add option to configure the AM/PM layout 2020-05-07 19:55:45 +03:00
GeorgeSG 76a52f0c9f Add some comments 2020-05-07 01:52:24 +03:00
GeorgeSG ce22ceb808 Refactor constants 2020-05-07 01:45:31 +03:00
GeorgeSG 45d3364959 Use ENTITY_DOMAIN in callService 2020-05-07 01:40:29 +03:00
GeorgeSG 6ac3d62823 Use computeDomain 2020-05-07 01:38:05 +03:00
GeorgeSG f86aada0e3 0.0.2 2020-05-07 00:25:34 +03:00
GeorgeSG e806a915a5 Use hass-chevron icons. Update README and screenshots 2020-05-07 00:24:32 +03:00
30 changed files with 3422 additions and 183 deletions
+3 -2
View File
@@ -13,7 +13,8 @@
"rules": { "rules": {
"@typescript-eslint/camelcase": 0, "@typescript-eslint/camelcase": 0,
"@typescript-eslint/no-non-null-assertion": 0, "@typescript-eslint/no-non-null-assertion": 0,
"@typescript-eslint/explicit-function-return-type": [1, { "allowExpressions": true }] "@typescript-eslint/explicit-function-return-type": [1, { "allowExpressions": true }],
"@typescript-eslint/no-namespace": 0
}, },
"ignorePatterns": ["dist/", "node_modules/"] "ignorePatterns": ["dist/", "node_modules/", "*.js"]
} }
+3 -2
View File
@@ -9,12 +9,13 @@ on:
- master - master
jobs: jobs:
build: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Build - name: Build
run: | run: |
npm install npm install
npm run lint
npm run build npm run build
- name: Test
run: npm run test
+5
View File
@@ -0,0 +1,5 @@
{
"files.exclude": {
"dist/**": true
}
}
+115 -44
View File
@@ -1,29 +1,33 @@
# Time Picker Card by [@georgesg](https://www.github.com/georgesg) # Time Picker Card
[![hacs_badge][hacs-shield]][hacs] [![HACS][hacs-shield]][hacs-link]
[![GitHub Release][releases-shield]][releases] [![Downloads][downloads-shield]][downloads-link]
![CI](https://github.com/GeorgeSG/lovelace-time-picker-card/workflows/CI/badge.svg) [![GitHub Release][releases-shield]][releases-link]
[![Project Maintenance][maintenance-shield]][maintenance] [![CI][ci-shield]][ci-link]
[![GitHub Activity][commits-shield]][commits] [![Project Maintenance][maintenance-shield]][maintenance-link]
[![License][license-shield]][license] [![License][license-shield]][license-link]
## Overview ## Overview
This is a Time Picker card for [Home Assistant](https://www.home-assistant.io/)'s [Lovelace UI](https://www.home-assistant.io/lovelace). 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`). Requires an [Input Datetime](https://www.home-assistant.io/integrations/input_datetime/) that has time (`has_time: true`).
## Screenshots
![Default theme with card name](examples/default_with_name.png)
![Default theme with no card name](examples/default_without_name.png)
![Cudtom theme ](examples/custom.png)
## Installation ## Installation
Install using [HACS](https://hacs.xyz) or follow this [guide](https://github.com/thomasloven/hass-config/wiki/Lovelace-Plugins) ### HACS
Install using [HACS](https://hacs.xyz) and add the following to your config:
```yaml
resources:
- url: /hacsfiles/lovelace-time-picker-card/time-picker-card.js
type: module
```
### Manual
Download time-picker-card.js from the [latest realease](https://github.com/GeorgeSG/lovelace-time-picker-card/releases/latest) and place it in your `config/www` folder. Add the following to your config:
```yaml ```yaml
resources: resources:
@@ -33,17 +37,65 @@ resources:
## Usage ## Usage
### Visual Editor
Time Picker Card supports Lovelace's Visual Editor. Click the + button to add a card and search for time picker.
![Visual Editor](https://raw.githubusercontent.com/GeorgeSG/lovelace-time-picker-card/master/examples/visual_editor.png)
## Examples
### Default config - card name shown, 24 hour mode
![Default theme with card name](https://raw.githubusercontent.com/GeorgeSG/lovelace-time-picker-card/master/examples/default_with_name.png)
```yaml ```yaml
type: 'custom:time-picker-card' type: 'custom:time-picker-card'
entity: input_datetime.alarm_time entity: input_datetime.alarm_time
name: 'Choose a time'
hour_mode: 24
hour_step: 1
minute_step: 5
hide:
name: false
``` ```
### Custom config - hidden card name, 12 hour mode
![Default theme with no card name](https://raw.githubusercontent.com/GeorgeSG/lovelace-time-picker-card/master/examples/default_without_name.png)
```yaml
type: 'custom:time-picker-card'
entity: input_datetime.alarm_time
hour_mode: 12
hide:
name: true
```
### Custom config - hidden card name, 12 hour mode with a "single" hour mode picker
![Default theme with single hour mode](https://raw.githubusercontent.com/GeorgeSG/lovelace-time-picker-card/master/examples/single_hour_mode.png)
```yaml
type: 'custom:time-picker-card'
entity: input_datetime.alarm_time
hour_mode: 12
layout:
hour_mode: single
hide:
name: true
```
### Custom config - card name inside card and controls aligned right
![Default theme with single hour mode](https://raw.githubusercontent.com/GeorgeSG/lovelace-time-picker-card/master/examples/name_inside.png)
```yaml
type: 'custom:time-picker-card'
entity: input_datetime.alarm_time
layout:
name: inside
align_controls: right
```
### With a custom lovelace theme
![Custom theme](https://raw.githubusercontent.com/GeorgeSG/lovelace-time-picker-card/master/examples/custom.png)
## Options ## Options
| Name | Type | Requirement | Description | Default | | Name | Type | Requirement | Description | Default |
@@ -51,43 +103,62 @@ hide:
| type | string | **Required** | `custom:time-picker-card` | | | type | string | **Required** | `custom:time-picker-card` | |
| entity | string | **Required** | [Input Datetime](https://www.home-assistant.io/integrations/input_datetime/) entity with `has_time: true` | | | entity | string | **Required** | [Input Datetime](https://www.home-assistant.io/integrations/input_datetime/) entity with `has_time: true` | |
| name | string | **Optional** | Card name | Entity's `friendly_name` | | name | string | **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_mode | `12` or `24` | **Optional** | Hour format. If `12`, card will show AM/PM picker | `24` |
| hour_step | number | **Optional** | Hour change when clicking arrows | `1` | | hour_step | number | **Optional** | Hour change when clicking arrows | `1` |
| minute_step | number | **Optional** | Minute change when clicking arrows | `5` | | minute_step | number | **Optional** | Minute change when clicking arrows | `5` |
| layout | object | **Optional** | Card Layout configuration | `none` |
| hide | object | **Optional** | Hide object | `none` | | hide | object | **Optional** | Hide object | `none` |
## Hide Object ### Layout Object
| Name | Value | Requirement | Description | Default |
| -------------- | ------------------------- | ------------ | -------------------------------------------------------------------------------------------------- | -------- |
| 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` |
### Hide Object
| Name | Type | Requirement | Description | Default | | Name | Type | Requirement | Description | Default |
| ---- | ------- | ------------ | ------------------- | ------- | | ---- | ------- | ------------ | ------------------- | ------- |
| name | boolean | **Optional** | Hides the card name | `false` | | name | boolean | **Optional** | Hides the card name | `false` |
## Theme Variables ### Theme Variables
| Name | Default | Description | 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:
| --tpc-elements-background-color | `var(--dark-primary-color)` | Background colro for header and ui elements |
| --tpc-icon-color | `var(--primary-text-color)` | Arrow color | | Name | Default | Description |
| --tpc-text-color | `var(--primary-text-color)` | Text color | | ------------------------------------- | ------------------------------ | -------------------------------------- |
| --tpc-accent-color | `var(--accent-color)` | AM / PM active color | | time-picker-elements-background-color | `var(--primary-color)` | Background color for header and inputs |
| time-picker-icon-color | `var(--primary-text-color)` | Arrow color |
| time-picker-text-color | `white` | Text color |
| time-picker-accent-color | `var(--primary-color)` | AM / PM active color |
| time-picker-off-color | `var(--disabled-text-color)` | AM / PM inactive color |
| time-picker-border-radius | `var(--ha-card-border-radius)` | Border radius of the card |
## Meta ## Meta
**Georgi Gardev** **Georgi Gardev**
- [gar.dev](https://gar.dev) - [gar.dev](https://gar.dev)
- [![GitHub][github-icon]](https://github.com/GeorgeSG/) [GeorgeSG](https://github.com/GeorgeSG/) - [![GitHub][github-icon]][github-link] [GeorgeSG][github-link]
- [![Twitter][twitter-icon]](https://twitter.com/georgesg92) [@georgesg92](https://twitter.com/georgesg92) - [![Twitter][twitter-icon]][twitter-link] [@georgesg92][twitter-link]
[commits-shield]: https://img.shields.io/github/commit-activity/y/GeorgeSG/lovelace-time-picker-card?style=flat-square [hacs-shield]: https://img.shields.io/badge/HACS-Default-brightgreen.svg
[commits]: https://github.com/GeorgeSG/lovelace-time-picker-card/commits/master [hacs-link]: https://github.com/custom-components/hacs
[license-shield]: https://img.shields.io/github/license/GeorgeSG/lovelace-time-picker-card?style=flat-square [downloads-shield]: https://img.shields.io/github/downloads/GeorgeSG/lovelace-time-picker-card/latest/total?color=brightgreen&logo=github
[license]: https://github.com/GeorgeSG/lovelace-time-picker-card/blob/master/LICENSE [downloads-link]: https://github.com/GeorgeSG/lovelace-time-picker-card/releases
[maintenance-shield]: https://img.shields.io/maintenance/yes/2020.svg?style=flat-square [releases-shield]: https://img.shields.io/github/release/GeorgeSG/lovelace-time-picker-card.svg
[maintenance]: https://github.com/GeorgeSG/lovelace-time-picker-card [releases-link]: https://github.com/GeorgeSG/lovelace-time-picker-card/releases
[releases-shield]: https://img.shields.io/github/release/GeorgeSG/lovelace-time-picker-card.svg?style=flat-square [ci-shield]: https://img.shields.io/github/workflow/status/GeorgeSG/lovelace-time-picker-card/CI?label=CI&logo=github&
[releases]: https://github.com/GeorgeSG/lovelace-time-picker-card/releases [ci-link]: https://github.com/GeorgeSG/lovelace-time-picker-card/actions?query=workflow%3ACI
[hacs-shield]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=flat-square [maintenance-shield]: https://img.shields.io/maintenance/yes/2020.svg
[hacs]: https://github.com/custom-components/hacs [maintenance-link]: https://github.com/GeorgeSG/lovelace-time-picker-card
[twitter-icon]: http://i.imgur.com/wWzX9uB.png [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
[github-icon]: http://i.imgur.com/9I6NRUm.png [github-icon]: http://i.imgur.com/9I6NRUm.png
[github-link]: https://github.com/GeorgeSG/
[twitter-icon]: http://i.imgur.com/wWzX9uB.png
[twitter-link]: https://twitter.com/georgesg92
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

+2233 -27
View File
File diff suppressed because it is too large Load Diff
+13 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "time-picker-card", "name": "time-picker-card",
"version": "0.0.1", "version": "1.0.0",
"author": "Georgi Gardev <georgi@gardev.com>", "author": "Georgi Gardev <georgi@gardev.com>",
"description": "Lovelace time-picker-card", "description": "Lovelace time-picker-card",
"keywords": [ "keywords": [
@@ -19,31 +19,42 @@
"build": "npm run lint && npm run rollup", "build": "npm run lint && npm run rollup",
"lint": "eslint src/*.ts", "lint": "eslint src/*.ts",
"rollup": "rollup -c", "rollup": "rollup -c",
"test": "./node_modules/karma/bin/karma start ./test/karma.conf.js",
"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"
}, },
"dependencies": { "dependencies": {
"custom-card-helpers": "^1.5.0", "custom-card-helpers": "^1.6.4",
"home-assistant-js-websocket": "^5.1.0", "home-assistant-js-websocket": "^5.1.0",
"lit-element": "^2.3.1", "lit-element": "^2.3.1",
"lit-html": "^1.2.1" "lit-html": "^1.2.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.9.0", "@babel/core": "^7.9.0",
"@open-wc/testing": "^2.5.16",
"@rollup/plugin-commonjs": "^11.1.0",
"@rollup/plugin-json": "^4.0.3", "@rollup/plugin-json": "^4.0.3",
"@rollup/plugin-node-resolve": "^7.1.1", "@rollup/plugin-node-resolve": "^7.1.1",
"@rollup/plugin-typescript": "^4.0.0", "@rollup/plugin-typescript": "^4.0.0",
"@types/chai": "^4.2.11",
"@typescript-eslint/eslint-plugin": "^2.30.0", "@typescript-eslint/eslint-plugin": "^2.30.0",
"@typescript-eslint/parser": "^2.30.0", "@typescript-eslint/parser": "^2.30.0",
"eslint": "^6.8.0", "eslint": "^6.8.0",
"eslint-config-prettier": "^6.11.0", "eslint-config-prettier": "^6.11.0",
"eslint-plugin-prettier": "^3.1.3", "eslint-plugin-prettier": "^3.1.3",
"karma": "^5.0.5",
"karma-chai": "^0.1.0",
"karma-chrome-launcher": "^3.1.0",
"karma-mocha": "^2.0.1",
"karma-rollup-preprocessor": "^7.0.5",
"mocha": "^7.1.2",
"prettier": "^2.0.5", "prettier": "^2.0.5",
"rollup": "^1.32.1", "rollup": "^1.32.1",
"rollup-plugin-babel": "^4.4.0", "rollup-plugin-babel": "^4.4.0",
"rollup-plugin-serve": "^1.0.1", "rollup-plugin-serve": "^1.0.1",
"rollup-plugin-terser": "^5.3.0", "rollup-plugin-terser": "^5.3.0",
"sinon": "^9.0.2",
"typescript": "^3.8.3" "typescript": "^3.8.3"
} }
} }
+29 -14
View File
@@ -8,22 +8,30 @@ import {
TemplateResult, TemplateResult,
} from 'lit-element'; } from 'lit-element';
import { ClassInfo, classMap } from 'lit-html/directives/class-map'; import { ClassInfo, classMap } from 'lit-html/directives/class-map';
import { Period } from '../types'; import { Layout, Period } from '../types';
/**
* Renders a Time Period selector with an input for a value and two arrows for step-chaning
* the value up and down.
*/
@customElement('time-period') @customElement('time-period')
export class TimePeriodComponent extends LitElement { export class TimePeriodComponent extends LitElement {
static readonly EVENT_TOGGLE = 'toggle'; static readonly EVENT_TOGGLE = 'toggle';
@property() private period!: Period; @property() private period!: Period;
@property() private mode!: Layout.HourMode;
private get amClass(): ClassInfo {
return { 'time-period': true, active: this.period === Period.AM };
}
private get pmClass(): ClassInfo {
return { 'time-period': true, active: this.period === Period.PM };
}
render(): TemplateResult { render(): TemplateResult {
return html` <div class="time-period-selector"> return html`<div class="time-period-selector">
<div class=${classMap(this.amClass)} @click=${this.onTimePeriodChange}> ${this.mode === 'single' ? this.renderSingle() : this.renderDouble()}
AM<mwc-ripple></mwc-ripple>
</div>
<div class=${classMap(this.pmClass)} @click=${this.onTimePeriodChange}>
PM<mwc-ripple></mwc-ripple>
</div>
</div>`; </div>`;
} }
@@ -32,12 +40,19 @@ export class TimePeriodComponent extends LitElement {
this.dispatchEvent(event); this.dispatchEvent(event);
} }
private get amClass(): ClassInfo { private renderSingle(): TemplateResult {
return { 'time-period': true, active: this.period === Period.AM }; return html`<div class="time-period active" @click=${this.onTimePeriodChange}>
${this.period}<mwc-ripple></mwc-ripple>
</div>`;
} }
private get pmClass(): ClassInfo { private renderDouble(): TemplateResult {
return { 'time-period': true, active: this.period === Period.PM }; return html`<div class=${classMap(this.amClass)} @click=${this.onTimePeriodChange}>
AM<mwc-ripple></mwc-ripple>
</div>
<div class=${classMap(this.pmClass)} @click=${this.onTimePeriodChange}>
PM<mwc-ripple></mwc-ripple>
</div>`;
} }
static get styles(): CSSResult { static get styles(): CSSResult {
@@ -49,8 +64,8 @@ export class TimePeriodComponent extends LitElement {
.time-period { .time-period {
width: 30px; width: 30px;
padding: 8px; padding: 8px;
background: var(--tpc-elements-background-color); background: var(--tpc-off-color);
color: var(--tpc-text-color, #fff); color: var(--tpc-text-color);
text-align: center; text-align: center;
font-size: 1em; font-size: 1em;
cursor: pointer; cursor: pointer;
+9 -9
View File
@@ -10,9 +10,13 @@ import {
import { TimeUnit } from '../models/time-unit'; import { TimeUnit } from '../models/time-unit';
import { Direction } from '../types'; import { Direction } from '../types';
/**
* Renders an AM/PM picker.
*/
@customElement('time-unit') @customElement('time-unit')
export class TimeUnitComponent extends LitElement { export class TimeUnitComponent extends LitElement {
static readonly EVENT_UPDATE = 'update'; static readonly EVENT_UPDATE = 'update';
static readonly EVENT_STEP_CHANGE = 'stepChange';
@property() private unit!: TimeUnit; @property() private unit!: TimeUnit;
@@ -24,7 +28,7 @@ export class TimeUnitComponent extends LitElement {
class="time-input" class="time-input"
type="number" type="number"
placeholder="MM" placeholder="MM"
min="0" min=${this.unit.minValue}
max=${this.unit.maxValue} max=${this.unit.maxValue}
.value="${this.unit.toString()}" .value="${this.unit.toString()}"
@change=${this.onInputChange} @change=${this.onInputChange}
@@ -36,23 +40,19 @@ export class TimeUnitComponent extends LitElement {
onInputChange({ target: { value } }: { target: HTMLInputElement }): void { onInputChange({ target: { value } }: { target: HTMLInputElement }): void {
this.unit.setStringValue(value); this.unit.setStringValue(value);
this.emitUpdate(); const event = new CustomEvent(TimeUnitComponent.EVENT_UPDATE);
this.dispatchEvent(event);
} }
onStepChangerClick(direction: Direction): void { onStepChangerClick(direction: Direction): void {
this.unit.stepUpdate(direction); const event = new CustomEvent(TimeUnitComponent.EVENT_STEP_CHANGE, { detail: { direction } });
this.emitUpdate();
}
private emitUpdate(): void {
const event = new CustomEvent(TimeUnitComponent.EVENT_UPDATE);
this.dispatchEvent(event); this.dispatchEvent(event);
} }
private renderStepChanger(direction: Direction): TemplateResult { private renderStepChanger(direction: Direction): TemplateResult {
return html` return html`
<div class="time-picker-icon" @click=${() => this.onStepChangerClick(direction)}> <div class="time-picker-icon" @click=${() => this.onStepChangerClick(direction)}>
<ha-icon .icon="mdi:arrow-${direction}"></ha-icon> <ha-icon .icon="hass:chevron-${direction}"></ha-icon>
<mwc-ripple id="ripple"></mwc-ripple> <mwc-ripple id="ripple"></mwc-ripple>
</div> </div>
`; `;
+10
View File
@@ -1,4 +1,14 @@
import * as pkg from '../package.json'; import * as pkg from '../package.json';
import { Layout } from './types';
export const CARD_VERSION = pkg.version; export const CARD_VERSION = pkg.version;
export const CARD_SIZE = 3; export const CARD_SIZE = 3;
export const ENTITY_DOMAIN = 'input_datetime';
// Config defaults
export const DEFAULT_HOUR_STEP = 1;
export const DEFAULT_MINUTE_STEP = 5;
export const DEFAULT_LAYOUT_HOUR_MODE = 'double';
export const DEFAULT_LAYOUT_ALIGN_CONTROLS = Layout.AlignControls.CENTER;
export const DEFAULT_LAYOUT_NAME = Layout.Name.HEADER;
+244
View File
@@ -0,0 +1,244 @@
import { computeDomain, HomeAssistant, LovelaceCardEditor } from 'custom-card-helpers';
import { HassEntity } from 'home-assistant-js-websocket';
import {
css,
CSSResult,
customElement,
html,
LitElement,
property,
TemplateResult,
} from 'lit-element';
import {
DEFAULT_HOUR_STEP,
DEFAULT_MINUTE_STEP,
ENTITY_DOMAIN,
DEFAULT_LAYOUT_ALIGN_CONTROLS,
DEFAULT_LAYOUT_NAME,
} from './const';
import { TimePickerCardConfig, Layout, HourMode } from './types';
@customElement('time-picker-card-editor')
export class TimePickerCardEditor extends LitElement implements LovelaceCardEditor {
private static readonly NUMBER_PROPERTIES = ['hour_step', 'minute_step', 'hour_mode'];
private static readonly CONFIG_CHANGED_EVENT = 'config-changed';
@property({ type: Object }) hass!: HomeAssistant;
@property() private config!: TimePickerCardConfig;
private get entity(): HassEntity | undefined {
return this.hass.states[this.config.entity];
}
private get datetimeEntities(): Array<string> {
return Object.keys(this.hass.states).filter(
(entityId) =>
computeDomain(entityId) === ENTITY_DOMAIN &&
this.hass.states[entityId].attributes.has_time === true
);
}
render(): TemplateResult {
return html`<div class="card-config">
<paper-dropdown-menu
style="width: 100%"
label="Entity (Required)"
.configValue=${'entity'}
@value-changed=${this.onValueChange}
>
<paper-listbox
slot="dropdown-content"
.selected=${this.datetimeEntities.indexOf(this.config.entity)}
>
${this.datetimeEntities.map((entity) => html`<paper-item>${entity}</paper-item>`)}
</paper-listbox>
</paper-dropdown-menu>
<paper-input
label="Name (Optional)"
.configValue=${'name'}
.value=${this.config.name}
.placeholder=${this.entity?.attributes.friendly_name}
@value-changed=${this.onValueChange}
></paper-input>
<div class="side-by-side">
<div>
<ha-switch
style="margin-left: 10px"
.checked="${!Boolean(this.config.hide?.name)}"
.configValue="${'hide_name'}"
@change="${this.onHideNameChange}"
></ha-switch>
Show name?
</div>
<paper-dropdown-menu
style="width: 100%"
label="Name Position (Optional)"
@value-changed=${this.onLayoutNameChange}
>
<paper-listbox
slot="dropdown-content"
.selected=${Object.values(Layout.Name).indexOf(
this.config.layout?.name ?? DEFAULT_LAYOUT_NAME
)}
>
${Object.values(Layout.Name).map((name) => html`<paper-item>${name}</paper-item>`)}
</paper-listbox>
</paper-dropdown-menu>
</div>
<div class="side-by-side">
<paper-input
type="number"
label="Hour Step (Optional)"
.configValue=${'hour_step'}
.value=${this.config.hour_step || DEFAULT_HOUR_STEP}
@value-changed=${this.onValueChange}
></paper-input>
<paper-input
type="number"
label="Minute Step (Optional)"
.configValue=${'minute_step'}
.value=${this.config.minute_step || DEFAULT_MINUTE_STEP}
@value-changed=${this.onValueChange}
></paper-input>
</div>
<div class="side-by-side">
<div>
<ha-switch
.checked="${this.config.hour_mode === 12}"
.configValue="${'hour_mode'}"
@change="${this.onHourModeChange}"
></ha-switch>
12-Hour mode
</div>
${this.config.hour_mode === 12
? html`<div>
<ha-switch
.checked="${this.config.layout?.hour_mode === 'single'}"
.configValue="${'hour_mode_layout'}"
@change="${this.onHourModeLayoutChange}"
></ha-switch>
"Single" hour mode layout
</div>`
: ''}
</div>
<div class="side-by-side">
<paper-dropdown-menu
style="width: 100%"
label="Align Controls (Optional)"
@value-changed=${this.onLayoutAlignChange}
>
<paper-listbox
slot="dropdown-content"
.selected=${Object.values(Layout.AlignControls).indexOf(
this.config.layout?.align_controls ?? DEFAULT_LAYOUT_ALIGN_CONTROLS
)}
>
${Object.values(Layout.AlignControls).map((a) => html`<paper-item>${a}</paper-item>`)}
</paper-listbox>
</paper-dropdown-menu>
<div>
<ha-switch
.checked="${this.config.link_values}"
.configValue="${'link_values'}"
@change="${this.onLinkValuesChange}"
></ha-switch>
Link Values
</div>
</div>
</div>`;
}
setConfig(config): void {
this.config = config;
}
private onHourModeChange({ target: { checked } }): void {
const hourMode: HourMode = checked ? 12 : 24;
const newConfig = { ...this.config, hour_mode: hourMode };
this.dispatch(newConfig);
}
private onHideNameChange({ target: { checked } }): void {
const newConfig = { ...this.config, hide: { name: !checked } };
this.dispatch(newConfig);
}
private onLayoutAlignChange({ detail: { value } }: CustomEvent): void {
const layout = { ...this.config.layout, align_controls: value };
const newConfig = { ...this.config, layout };
this.dispatch(newConfig);
}
private onLayoutNameChange({ detail: { value } }: CustomEvent): void {
const layout = { ...this.config.layout, name: value };
const newConfig = { ...this.config, layout };
this.dispatch(newConfig);
}
private onHourModeLayoutChange({ target: { checked } }): void {
const hourModeLayout: Layout.HourMode = checked ? 'single' : 'double';
const newConfig = {
...this.config,
layout: { ...this.config.layout, hour_mode: hourModeLayout },
};
this.dispatch(newConfig);
}
private onLinkValuesChange({ target: { checked } }): void {
const newConfig = { ...this.config, link_values: checked };
this.dispatch(newConfig);
}
private onValueChange(e: CustomEvent): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const property = (e.target as any).configValue;
let value = e.detail.value;
if (TimePickerCardEditor.NUMBER_PROPERTIES.indexOf(property) > -1) {
value = parseInt(value);
if (isNaN(value)) {
return;
}
}
if (this.config[property] === value) {
return;
}
const newConfig = { ...this.config, [property]: value };
this.dispatch(newConfig);
}
private dispatch(config: TimePickerCardConfig): void {
const event = new CustomEvent(TimePickerCardEditor.CONFIG_CHANGED_EVENT, {
bubbles: true,
composed: true,
detail: { config },
});
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;
}
`;
}
}
+21 -14
View File
@@ -1,16 +1,23 @@
import { TimeUnit } from './time-unit'; import { DEFAULT_HOUR_STEP } from '../const';
import { HourMode } from '../types'; import { HourMode } from '../types';
import { TimeUnit } from './time-unit';
/**
* Represents the hour value of a datetime.
*/
export class Hour extends TimeUnit { export class Hour extends TimeUnit {
private static readonly DEFAULT_STEP = 1; private static readonly VALUE_LIMIT = 24;
private static readonly MAX = 24;
constructor(value: number, step = Hour.DEFAULT_STEP, private hourMode: HourMode) { constructor(value: number, step = DEFAULT_HOUR_STEP, private hourMode?: HourMode) {
super(value, step, Hour.MAX); super(value, step, Hour.VALUE_LIMIT);
}
get minValue(): number {
return this.hourMode === 12 ? 1 : 0; // there is no 00:00 AM -> it's 12:00 AM.
} }
get maxValue(): number { get maxValue(): number {
return this.hourMode || Hour.MAX; return this.hourMode === 12 ? 12 : Hour.VALUE_LIMIT - 1;
} }
togglePeriod(): void { togglePeriod(): void {
@@ -18,15 +25,15 @@ export class Hour extends TimeUnit {
} }
toString(): string { toString(): string {
const value = this.hourMode === 12 ? (this.value + 12) % 12 : this.value; let value;
if (this.hourMode === 12) {
value = (this.value + 12) % 12;
value = value === 0 ? 12 : value;
} else {
value = this.value;
}
return value < 10 ? `0${value}` : value.toString(); return value < 10 ? `0${value}` : value.toString();
} }
protected isValidString(valueStr: string): boolean {
const value = parseInt(valueStr);
const limit = this.hourMode || this._limit;
return !isNaN(value) && value >= 0 && value <= limit;
}
} }
+17 -8
View File
@@ -1,17 +1,26 @@
import { DEFAULT_MINUTE_STEP } from '../const';
import { TimeUnit } from './time-unit'; import { TimeUnit } from './time-unit';
import { Direction } from '../types';
/**
* * Represents the minute value of a datetime.
*/
export class Minute extends TimeUnit { export class Minute extends TimeUnit {
private static readonly DEFAULT_STEP = 5; private static readonly VALUE_LIMIT = 60;
private static readonly MAX = 60;
maxValue = Minute.MAX; minValue = 0;
maxValue = Minute.VALUE_LIMIT - 1;
constructor(value: number, step = Minute.DEFAULT_STEP) { constructor(value: number, step = DEFAULT_MINUTE_STEP) {
super(value, step, Minute.MAX); super(value, step, Minute.VALUE_LIMIT);
} }
protected isValidString(valueStr: string): boolean { /**
const value = parseInt(valueStr); * Returns true if the minutes will overflow to a different hour when changed in {@param direction}.
return !isNaN(value) && value >= 0 && value <= this._limit; * @param direction
*/
willOverflow(direction: Direction): boolean {
const newValue = direction === Direction.UP ? this.value + this._step : this.value - this._step;
return newValue >= this._limit || newValue < 0;
} }
} }
+33 -14
View File
@@ -1,13 +1,15 @@
import { Direction } from '../types'; import { Direction } from '../types';
/**
* Represents a single unit of a datetime oobject - e.g. hours or minutes.
*/
export abstract class TimeUnit { export abstract class TimeUnit {
/** /**
* Return true if the valueStr can be set as a value of this instance. * The min allowed UI value for this instance.
*/ */
protected abstract isValidString(valueStr: string): boolean; abstract minValue: number;
/** /**
* The max allowed value for this instance. Used for UI validation. * The max allowed value for this instance.
*/ */
abstract maxValue: number; abstract maxValue: number;
@@ -23,33 +25,50 @@ export abstract class TimeUnit {
return this._value; return this._value;
} }
/**
* Sets a value from {@param stringValue}, if it can be parsed and is valid.
* @param stringValue
*/
setStringValue(stringValue: string): void { setStringValue(stringValue: string): void {
if (this.isValidString(stringValue)) { if (this.isValidString(stringValue)) {
this.setValue(parseInt(stringValue)); this.setValue(parseInt(stringValue));
} }
} }
stepUpdate(direction: Direction): void { /**
direction === Direction.UP ? this.increment() : this.decrement(); * Updates the value in {@param direction} by the step size specified in the constructoor.
} * @param direction
*/
increment(): void { stepUpdate(direction: Direction, step: number = this._step): void {
this.setValue(this.value + this._step); direction === Direction.UP ? this.increment(step) : this.decrement(step);
}
decrement(): void {
this.setValue(this.value - this._step);
} }
toString(): string { toString(): string {
return this.value < 10 ? `0${this.value}` : this.value.toString(); return this.value < 10 ? `0${this.value}` : this.value.toString();
} }
private increment(step: number = this._step): void {
this.setValue(this.value + step);
}
private decrement(step: number = this._step): void {
this.setValue(this.value - step);
}
protected setValue(newValue: number): void { protected setValue(newValue: number): void {
if (isNaN(newValue)) {
return;
}
if (newValue >= this._limit || newValue < 0) { if (newValue >= this._limit || newValue < 0) {
newValue = (newValue + this._limit) % this._limit; newValue = (newValue + this._limit) % this._limit;
} }
this._value = newValue; this._value = newValue;
} }
protected isValidString(valueStr: string): boolean {
const value = parseInt(valueStr);
return !isNaN(value) && value >= 0 && value < this._limit;
}
} }
+23
View File
@@ -0,0 +1,23 @@
import { Hour } from './hour';
import { Minute } from './minute';
import { Direction } from '../types';
export class Time {
constructor(public hour: Hour, public minute: Minute, private _linkValues: boolean = false) {}
hourStep(direction: Direction): void {
this.hour.stepUpdate(direction);
}
minuteStep(direction: Direction): void {
if (this._linkValues && this.minute.willOverflow(direction)) {
this.hour.stepUpdate(direction, 1);
}
this.minute.stepUpdate(direction);
}
get value(): string {
return `${this.hour.value}:${this.minute.value}`;
}
}
+8 -2
View File
@@ -1,6 +1,7 @@
import { html, TemplateResult } from 'lit-element'; import { html, TemplateResult } from 'lit-element';
import { TimePickerCardConfig } from './types'; import { TimePickerCardConfig } from './types';
import { LovelaceCard } from 'custom-card-helpers'; import { LovelaceCard } from 'custom-card-helpers';
import { HassEntity } from 'home-assistant-js-websocket';
export class Partial { export class Partial {
static error(error: string, origConfig: TimePickerCardConfig): TemplateResult { static error(error: string, origConfig: TimePickerCardConfig): TemplateResult {
@@ -14,11 +15,16 @@ export class Partial {
return html`${errorCard}`; return html`${errorCard}`;
} }
static header(title: string, icon?: string): TemplateResult | null { static headerName(title: string): TemplateResult {
return html` return html`
<div class="time-picker-header"> <div class="time-picker-header">
${icon ? html`<ha-icon class="icon" icon=${icon}></ha-icon>` : ''}${title} ${title}
</div> </div>
`; `;
} }
static nestedName(name: string, entity: HassEntity): TemplateResult {
return html`<state-badge .stateObj=${entity}></state-badge>
<div class="entity-name-inside">${name}</div>`;
}
} }
+156 -39
View File
@@ -1,4 +1,4 @@
import { HomeAssistant } from 'custom-card-helpers'; import { computeDomain, HomeAssistant, LovelaceCard } from 'custom-card-helpers';
import { HassEntity } from 'home-assistant-js-websocket'; import { HassEntity } from 'home-assistant-js-websocket';
import { import {
css, css,
@@ -9,13 +9,23 @@ import {
property, property,
TemplateResult, TemplateResult,
} from 'lit-element'; } from 'lit-element';
import './components/time-period.component'; import { ClassInfo, classMap } from 'lit-html/directives/class-map';
import './components/time-unit.component'; import {
import { CARD_SIZE, CARD_VERSION } from './const'; CARD_SIZE,
CARD_VERSION,
DEFAULT_LAYOUT_HOUR_MODE,
ENTITY_DOMAIN,
DEFAULT_LAYOUT_ALIGN_CONTROLS,
} from './const';
import { Hour } from './models/hour'; import { Hour } from './models/hour';
import { Minute } from './models/minute'; import { Minute } from './models/minute';
import { Time } from './models/time';
import { Partial } from './partials'; import { Partial } from './partials';
import { Period, TimePickerCardConfig } from './types'; import { Period, TimePickerCardConfig, Layout } from './types';
import './components/time-period.component';
import './components/time-unit.component';
import './editor';
console.info( console.info(
`%c TIME-PICKER-CARD \n%c Version ${CARD_VERSION} `, `%c TIME-PICKER-CARD \n%c Version ${CARD_VERSION} `,
@@ -23,20 +33,38 @@ console.info(
'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.',
});
@customElement('time-picker-card') @customElement('time-picker-card')
export class TimePickerCard extends LitElement { export class TimePickerCard extends LitElement implements LovelaceCard {
@property() private hass!: HomeAssistant; @property({ type: Object }) hass!: HomeAssistant;
@property() private config!: TimePickerCardConfig; @property() private config!: TimePickerCardConfig;
@property() private hour!: Hour; @property() private time!: Time;
@property() private minute!: Minute;
@property() private period!: Period; @property() private period!: Period;
private get entity(): HassEntity | undefined { private get entity(): HassEntity | undefined {
return this.hass.states[this.config.entity]; return this.hass.states[this.config.entity];
} }
private get shouldShowName(): boolean { private get hasNameInHeader(): boolean {
return Boolean(this.config.hide?.name) === false && Boolean(this.name); return (
Boolean(this.name) &&
Boolean(this.config.hide?.name) === false &&
this.config.layout?.name !== Layout.Name.INSIDE
);
}
private get hasNameInside(): boolean {
return (
Boolean(this.name) &&
Boolean(this.config.hide?.name) === false &&
this.config.layout?.name === Layout.Name.INSIDE
);
} }
private get name(): string | undefined { private get name(): string | undefined {
@@ -47,41 +75,73 @@ export class TimePickerCard extends LitElement {
return this.config.hour_mode === 12; return this.config.hour_mode === 12;
} }
private get layoutAlign(): Layout.AlignControls {
return this.config.layout?.align_controls ?? DEFAULT_LAYOUT_ALIGN_CONTROLS;
}
private get rowClass(): ClassInfo {
return {
'time-picker-row': true,
'with-header-name': this.hasNameInHeader,
};
}
private get contentClass(): ClassInfo {
return {
'time-picker-content': true,
[`layout-${this.layoutAlign}`]: true,
};
}
render(): TemplateResult | null { render(): TemplateResult | null {
if (!this.entity) { if (!this.entity) {
return Partial.error('Entity not found', this.config); return Partial.error('Entity not found', this.config);
} }
if (!this.entity.entity_id.startsWith('input_datetime')) { if (computeDomain(this.entity.entity_id) !== ENTITY_DOMAIN) {
return Partial.error('You must set an input_datetime entity', this.config); return Partial.error(`You must set an ${ENTITY_DOMAIN} entity`, this.config);
} }
if (!this.entity.attributes.has_time) { if (!this.entity.attributes.has_time) {
return Partial.error( return Partial.error(
'You must set an input_datetime entity that sets has_time: true', `You must set an ${ENTITY_DOMAIN} entity that sets has_time: true`,
this.config this.config
); );
} }
const { hour, minute } = this.entity!.attributes; const { hour, minute } = this.entity!.attributes;
this.hour = new Hour(hour, this.config.hour_step, this.config.hour_mode); const hourInstance = new Hour(hour, this.config.hour_step, this.config.hour_mode);
this.minute = new Minute(minute, this.config.minute_step); const minuteInstance = new Minute(minute, this.config.minute_step);
this.period = this.hour.value >= 12 ? Period.PM : Period.AM; this.time = new Time(hourInstance, minuteInstance, this.config.link_values);
this.period = hourInstance.value >= 12 ? Period.PM : Period.AM;
return html` return html`
<ha-card> <ha-card>
${this.shouldShowName ? Partial.header(this.name!) : ''} ${this.hasNameInHeader ? Partial.headerName(this.name!) : ''}
<div class="time-picker-content"> <div class=${classMap(this.rowClass)}>
<time-unit .unit=${this.hour} @update=${this.callHassService}></time-unit> ${this.hasNameInside ? Partial.nestedName(this.name!, this.entity) : ''}
<div class="time-separator">:</div>
<time-unit .unit=${this.minute} @update=${this.callHassService}></time-unit>
${this.shouldShowPeriod <div class=${classMap(this.contentClass)}>
? html`<time-period <time-unit
.period=${this.period} .unit=${this.time.hour}
@toggle=${this.onPeriodToggle} @stepChange=${this.onHourStepChange}
></time-period>` @update=${this.callHassService}
: ''} ></time-unit>
<div class="time-separator">:</div>
<time-unit
.unit=${this.time.minute}
@stepChange=${this.onMinuteStepChange}
@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>`
: ''}
</div>
</div> </div>
</ha-card> </ha-card>
`; `;
@@ -108,7 +168,17 @@ export class TimePickerCard extends LitElement {
} }
private onPeriodToggle(): void { private onPeriodToggle(): void {
this.hour.togglePeriod(); this.time.hour.togglePeriod();
this.callHassService();
}
private onHourStepChange(event: CustomEvent): void {
this.time.hourStep(event.detail.direction);
this.callHassService();
}
private onMinuteStepChange(event: CustomEvent): void {
this.time.minuteStep(event.detail.direction);
this.callHassService(); this.callHassService();
} }
@@ -117,11 +187,9 @@ export class TimePickerCard extends LitElement {
throw new Error('Unable to update datetime'); throw new Error('Unable to update datetime');
} }
const time = `${this.hour.value}:${this.minute.value}:00`; return this.hass.callService(ENTITY_DOMAIN, 'set_datetime', {
return this.hass.callService('input_datetime', 'set_datetime', {
entity_id: this.entity!.entity_id, entity_id: this.entity!.entity_id,
time, time: this.time.value,
}); });
} }
@@ -130,29 +198,78 @@ export class TimePickerCard extends LitElement {
:host { :host {
--tpc-elements-background-color: var( --tpc-elements-background-color: var(
--time-picker-elements-background-color, --time-picker-elements-background-color,
var(--dark-primary-color) var(--primary-color)
); );
--tpc-icon-color: var(--time-picker-icon-color, var(--primary-text-color)); --tpc-icon-color: var(--time-picker-icon-color, var(--primary-text-color));
--tpc-text-color: var(--time-picker-text-color, var(--primary-text-color)); --tpc-text-color: var(--time-picker-text-color, #fff);
--tpc-accent-color: var(--time-picker-accent-color, var(--accent-color)); --tpc-accent-color: var(--time-picker-accent-color, var(--primary-color));
--tpc-off-color: var(--time-picker-off-color, var(--disabled-text-color));
--tpc-border-radius: var(--time-picker-border-radius, var(--ha-card-border-radius, 4px));
} }
.time-picker-header { .time-picker-header {
padding: 16px; padding: 16px;
color: var(--tpc-text-color, #fff); color: var(--tpc-text-color);
background-color: var(--tpc-elements-background-color); background-color: var(--tpc-elements-background-color);
border-top-left-radius: var(--tpc-border-radius);
border-top-right-radius: var(--tpc-border-radius);
font-size: 1em; font-size: 1em;
text-align: center; text-align: center;
} }
.time-picker-content { .time-picker-row {
padding: 8px 16px 16px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
padding: 16px;
}
.time-picker-row.with-header-name {
padding: 8px 16px 16px;
}
.time-picker-content {
display: flex;
flex-direction: row;
align-items: center;
flex: 1 0 auto;
}
.time-picker-content.layout-left {
justify-content: flex-start;
}
.time-picker-content.layout-center {
justify-content: center; justify-content: center;
} }
.time-picker-content.layout-right {
justify-content: flex-end;
}
.entity-name-inside {
margin-left: 16px;
}
`; `;
} }
static getStubConfig(
_: HomeAssistant,
entities: Array<string>
): Omit<TimePickerCardConfig, 'type'> {
const datetimeEntity = entities.find((entityId) => computeDomain(entityId) === ENTITY_DOMAIN);
return {
entity: datetimeEntity || 'input_datetime.example_entity',
hour_mode: 24,
hour_step: 1,
minute_step: 5,
};
}
static getConfigElement(): LovelaceCard {
return document.createElement('time-picker-card-editor') as LovelaceCard;
}
} }
+28 -5
View File
@@ -3,14 +3,39 @@ import { LovelaceCardConfig } from 'custom-card-helpers';
export interface TimePickerCardConfig extends LovelaceCardConfig { export interface TimePickerCardConfig extends LovelaceCardConfig {
entity: string; entity: string;
name?: string; name?: string;
link_values?: boolean;
hour_mode?: HourMode; hour_mode?: HourMode;
hour_step?: number; hour_step?: number;
minute_step?: number; minute_step?: number;
layout?: TimePickerLayoutConfig;
hide?: TimePickerHideConfig; hide?: TimePickerHideConfig;
} }
export type HourMode = 12 | 24 | undefined;
export interface TimePickerLayoutConfig {
align_controls?: Layout.AlignControls;
name?: Layout.Name;
hour_mode?: Layout.HourMode;
}
export namespace Layout {
export type HourMode = 'single' | 'double';
export enum AlignControls {
LEFT = 'left',
CENTER = 'center',
RIGHT = 'right',
}
export enum Name {
HEADER = 'header',
INSIDE = 'inside',
}
}
export interface TimePickerHideConfig { export interface TimePickerHideConfig {
name: boolean; name?: boolean;
} }
export enum Direction { export enum Direction {
@@ -19,8 +44,6 @@ export enum Direction {
} }
export enum Period { export enum Period {
AM = 'am', AM = 'AM',
PM = 'pm', PM = 'PM',
} }
export type HourMode = 12 | 24 | undefined;
+12
View File
@@ -0,0 +1,12 @@
export interface CustomCardEntry {
type: string;
name?: string;
description?: string;
preview?: boolean;
}
declare global {
interface Window {
customCards: Array<CustomCardEntry>;
}
}
+17
View File
@@ -0,0 +1,17 @@
import { LovelaceCard } from 'custom-card-helpers';
import { customElement, html, LitElement, TemplateResult } from 'lit-element';
@customElement('hui-error-card')
export class HuiErrorCard extends LitElement implements LovelaceCard {
setConfig(): void {
return;
}
render(): TemplateResult {
return html``;
}
getCardSize(): number {
return 1;
}
}
+111
View File
@@ -0,0 +1,111 @@
import { expect, fixture, html } from '@open-wc/testing';
import { HomeAssistant } from 'custom-card-helpers';
import { HassEntity } from 'home-assistant-js-websocket';
import '../../src/time-picker-card';
import { TimePickerCard } from '../../src/time-picker-card';
import { TimePickerCardConfig, HourMode } from '../../src/types';
import './test-error-card';
async function render(config?: {
entityId?: string;
friendlyName?: string;
hasTime?: boolean;
hourMode?: HourMode;
hideName?: boolean;
}): Promise<TimePickerCard> {
const { entityId, friendlyName, hasTime, hourMode, hideName } = Object.assign(
{
entityId: 'input_datetime.wake_time',
friendlyName: 'Wake Time',
hasTime: true,
hideName: false,
},
config
);
const testEntity: HassEntity = {
entity_id: entityId,
attributes: {
friendly_name: friendlyName,
has_time: hasTime,
},
state: '00:10:00',
last_changed: '',
last_updated: '',
context: {
id: entityId,
user_id: null,
},
};
const hass: Partial<HomeAssistant> = {
states: {
[entityId]: testEntity,
},
};
const testConfig: TimePickerCardConfig = {
type: 'custom:time-picker-card',
entity: entityId,
hour_mode: hourMode,
hide: {
name: hideName,
},
};
const card = await fixture<TimePickerCard>(
html`<time-picker-card .hass=${hass} .config=${testConfig}></time-picker-card>`
);
card.setConfig(testConfig);
return card;
}
function errorCard(): string {
return html`<hui-error-card></hui-error-card>`.getHTML();
}
describe('test', () => {
it('renders', async () => {
const card = await render();
expect(card).shadowDom.to.match('time-picker-card');
});
it('errors if entity is not an input_datetime', async () => {
const card = await render({ entityId: 'sensor.test_sensor' });
expect(card).shadowDom.to.equal(errorCard());
});
it("errors if entity doesn't have time", async () => {
const card = await render({ hasTime: false });
expect(card).shadowDom.to.equal(errorCard());
});
it('renders card name', async () => {
const card = await render();
expect(card.shadowRoot?.innerHTML).to.match(/Wake Time/);
});
it('hides card name', async () => {
const card = await render({ hideName: true });
expect(card.shadowRoot?.innerHTML).not.to.match(/Wake Time/);
});
it('renders time units', async () => {
const card = await render();
expect(card.shadowRoot?.innerHTML.match(/<time-unit>/g)!.length).to.equal(2);
});
describe('time-period', () => {
it('is not rendered by default', async () => {
const card = await render();
expect(card.shadowRoot?.innerHTML.match(/<time-period>/g)).to.be.null;
});
it('renders in 12-hour mode', async () => {
const card = await render({ hourMode: 12 });
expect(card.shadowRoot?.innerHTML.match(/<time-period>/g)!.length).to.equal(1);
});
});
});
+45
View File
@@ -0,0 +1,45 @@
module.exports = function (config) {
return config.set({
basePath: './',
frameworks: ['mocha', 'chai'],
files: ['**/*.test.ts'],
preprocessors: {
'**/*.test.ts': ['rollup'],
},
rollupPreprocessor: {
plugins: [
require('@rollup/plugin-node-resolve')(),
require('@rollup/plugin-json')(),
require('@rollup/plugin-typescript')(),
require('@rollup/plugin-commonjs')({
namedExports: {
chai: ['expect'],
},
}),
],
output: {
format: 'umd',
sourcemap: 'inline',
},
onwarn: function (message) {
if (/Circular dependency/.test(message)) return;
console.warn(message);
},
},
reporters: ['dots'],
port: 9876,
colors: true,
autoWatch: false,
singleRun: true,
concurrency: Infinity,
browsers: ['ChromeHeadless'],
client: {
captureConsole: false,
},
});
};
+166
View File
@@ -0,0 +1,166 @@
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', () => {
beforeEach(() => {
hour = new Hour(2);
});
it('returns value', () => {
expect(hour.value).to.equal(2);
});
it('has correct max value', () => {
expect(hour.maxValue).to.equal(23);
});
describe('toString', () => {
it('returns formatted string', () => {
expect(hour.toString()).to.equal('02');
});
it('renders values correctly', () => {
hour = new Hour(0);
expect(hour.toString()).to.equal('00');
hour = new Hour(6);
expect(hour.toString()).to.equal('06');
hour = new Hour(12);
expect(hour.toString()).to.equal('12');
hour = new Hour(18);
expect(hour.toString()).to.equal('18');
hour = new Hour(23);
expect(hour.toString()).to.equal('23');
});
});
describe('stepUpdate', () => {
it('updates up', () => {
hour.stepUpdate(Direction.UP);
expect(hour.value).to.equal(3);
});
it('updates down', () => {
hour.stepUpdate(Direction.DOWN);
expect(hour.value).to.equal(1);
});
it('goes up from 23 to 0', () => {
hour = new Hour(23);
hour.stepUpdate(Direction.UP);
expect(hour.value).to.equal(0);
});
it('goes down from 0 to 23', () => {
hour = new Hour(0);
hour.stepUpdate(Direction.DOWN);
expect(hour.value).to.equal(23);
});
});
describe('setStringValue', () => {
it('sets value from valid string', () => {
hour.setStringValue('3');
expect(hour.value).to.equal(3);
});
it("doesn't set value from invalid string", () => {
hour.setStringValue('test');
expect(hour.value).to.equal(2);
});
it("doesn't set value from outside of scope", () => {
hour.setStringValue('24');
expect(hour.value).to.equal(2);
});
});
});
context('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);
hour.stepUpdate(Direction.UP);
hour.stepUpdate(Direction.UP);
hour.stepUpdate(Direction.UP);
hour.stepUpdate(Direction.UP);
expect(hour.value).to.equal(3);
});
it('goes down correclty', () => {
hour.stepUpdate(Direction.DOWN);
expect(hour.value).to.equal(21);
});
});
context('in 12 hour mode', () => {
beforeEach(() => {
hour = new Hour(2, DEFAULT_HOUR_STEP, 12);
});
it('has correct max value', () => {
expect(hour.maxValue).to.equal(12);
});
describe('togglePeriod', () => {
it('changes values correclty', () => {
hour = new Hour(2, DEFAULT_HOUR_STEP, 12);
hour.togglePeriod();
expect(hour.value).to.equal(14);
hour = new Hour(0, DEFAULT_HOUR_STEP, 12);
hour.togglePeriod();
expect(hour.value).to.equal(12);
hour = new Hour(23, DEFAULT_HOUR_STEP, 12);
hour.togglePeriod();
expect(hour.value).to.equal(11);
});
it('has no side effects', () => {
const value = hour.value;
hour.togglePeriod();
hour.togglePeriod();
expect(hour.value).to.equal(value);
});
});
describe('toString', () => {
it('renders values above 12 orrectly', () => {
hour = new Hour(13, DEFAULT_HOUR_STEP, 12);
expect(hour.toString()).to.equal('01');
});
it('renders values correctly', () => {
hour = new Hour(0, DEFAULT_HOUR_STEP, 12);
expect(hour.toString()).to.equal('12');
hour = new Hour(6, DEFAULT_HOUR_STEP, 12);
expect(hour.toString()).to.equal('06');
hour = new Hour(12, DEFAULT_HOUR_STEP, 12);
expect(hour.toString()).to.equal('12');
hour = new Hour(18, DEFAULT_HOUR_STEP, 12);
expect(hour.toString()).to.equal('06');
hour = new Hour(23, DEFAULT_HOUR_STEP, 12);
expect(hour.toString()).to.equal('11');
});
});
});
});
+119
View File
@@ -0,0 +1,119 @@
import { Minute } from '../../src/models/minute';
import { Direction } from '../../src/types';
import { expect } from 'chai';
describe('Minute', () => {
let minute: Minute;
context('with default config', () => {
beforeEach(() => {
minute = new Minute(2);
});
it('returns value', () => {
expect(minute.value).to.equal(2);
});
it('has correct max value', () => {
expect(minute.maxValue).to.equal(59);
});
describe('toString', () => {
it('renders values correctly', () => {
minute = new Minute(0);
expect(minute.toString()).to.equal('00');
minute = new Minute(5);
expect(minute.toString()).to.equal('05');
minute = new Minute(59);
expect(minute.toString()).to.equal('59');
});
});
describe('stepUpdate', () => {
it('updates up', () => {
minute.stepUpdate(Direction.UP);
expect(minute.value).to.equal(7);
});
it('updates down', () => {
minute.stepUpdate(Direction.DOWN);
expect(minute.value).to.equal(57);
});
it('goes up from 59 to 4', () => {
minute = new Minute(59);
minute.stepUpdate(Direction.UP);
expect(minute.value).to.equal(4);
});
it('goes down from 0 to 55', () => {
minute = new Minute(0);
minute.stepUpdate(Direction.DOWN);
expect(minute.value).to.equal(55);
});
});
describe('setStringValue', () => {
it('sets value from valid string', () => {
minute.setStringValue('3');
expect(minute.value).to.equal(3);
});
it("doesn't set value from invalid string", () => {
minute.setStringValue('test');
expect(minute.value).to.equal(2);
});
it("doesn't set value from outside of scope", () => {
minute.setStringValue('60');
expect(minute.value).to.equal(2);
minute.setStringValue('65');
expect(minute.value).to.equal(2);
});
});
});
context('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);
minute.stepUpdate(Direction.UP);
minute.stepUpdate(Direction.UP);
minute.stepUpdate(Direction.UP);
expect(minute.value).to.equal(2);
minute.stepUpdate(Direction.UP);
expect(minute.value).to.equal(17);
});
it('goes down correclty', () => {
minute.stepUpdate(Direction.DOWN);
expect(minute.value).to.equal(47);
});
});
describe('will overflow', () => {
it('returns true if the minutes will overflow an hour up', () => {
minute.setStringValue('59');
expect(minute.willOverflow(Direction.UP)).to.be.true;
});
it('returns true if the minutes will overflow an hour down', () => {
minute.setStringValue('0');
expect(minute.willOverflow(Direction.DOWN)).to.be.true;
});
it('returns true if the minutes will not overflow', () => {
minute.setStringValue('55');
expect(minute.willOverflow(Direction.DOWN)).to.be.false;
});
});
});
+2 -1
View File
@@ -12,6 +12,7 @@
"noImplicitAny": false, "noImplicitAny": false,
"skipLibCheck": true, "skipLibCheck": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"experimentalDecorators": true "experimentalDecorators": true,
"allowSyntheticDefaultImports": true // for importing sinon in tests
} }
} }