Compare commits

...
27 Commits
Author SHA1 Message Date
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
26 changed files with 2948 additions and 137 deletions
+3 -2
View File
@@ -13,7 +13,8 @@
"rules": {
"@typescript-eslint/camelcase": 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"]
}
+2
View File
@@ -18,3 +18,5 @@ jobs:
npm install
npm run lint
npm run build
- name: Test
run: npm run test
+108 -35
View File
@@ -1,26 +1,19 @@
# Time Picker Card by [@georgesg](https://www.github.com/georgesg)
# Time Picker Card
[![hacs_badge][hacs-shield]][hacs]
[![GitHub Release][releases-shield]][releases]
![CI](https://github.com/GeorgeSG/lovelace-time-picker-card/workflows/CI/badge.svg)
[![Project Maintenance][maintenance-shield]][maintenance]
[![GitHub Activity][commits-shield]][commits]
[![License][license-shield]][license]
[![HACS][hacs-shield]][hacs-link]
[![Downloads][downloads-shield]][downloads-link]
[![GitHub Release][releases-shield]][releases-link]
[![CI][ci-shield]][ci-link]
[![Project Maintenance][maintenance-shield]][maintenance-link]
[![GitHub Activity][commits-shield]][commits-link]
[![License][license-shield]][license-link]
## 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`).
## 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
Install using [HACS](https://hacs.xyz) or follow this [guide](https://github.com/thomasloven/hass-config/wiki/Lovelace-Plugins)
@@ -33,6 +26,14 @@ resources:
## 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)
### YAML
```yaml
type: 'custom:time-picker-card'
entity: input_datetime.alarm_time
@@ -44,6 +45,59 @@ hide:
name: false
```
## 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
type: 'custom:time-picker-card'
entity: input_datetime.alarm_time
```
### 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
| Name | Type | Requirement | Description | Default |
@@ -54,8 +108,17 @@ hide:
| hour_mode | `12` or `24` | **Optional** | Hour format. If `12`, card will show AM/PM picker | `24` |
| hour_step | number | **Optional** | Hour change when clicking arrows | `1` |
| minute_step | number | **Optional** | Minute change when clicking arrows | `5` |
| layout | object | **Optional** | Card Layout configuration | `none` |
| hide | object | **Optional** | Hide object | `none` |
## 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 |
@@ -64,30 +127,40 @@ hide:
## Theme Variables
| Name | Default | Description |
| ------------------------------- | --------------------------- | ------------------------------------------- |
| --tpc-elements-background-color | `var(--dark-primary-color)` | Background colro for header and ui elements |
| --tpc-icon-color | `var(--primary-text-color)` | Arrow color |
| --tpc-text-color | `var(--primary-text-color)` | Text color |
| --tpc-accent-color | `var(--accent-color)` | AM / PM active color |
Time Picker Card will automatically pick up colors from your lovelace theme, but if you want to customize some of them,
you can use the following variables in your theme's config file:
| Name | Default | Description |
| ------------------------------------- | ---------------------------- | -------------------------------------- |
| time-picker-elements-background-color | `var(--primary-color)` | Background color for header and inputs |
| time-picker-icon-color | `var(--primary-text-color)` | Arrow color |
| time-picker-text-color | `white` | Text color |
| time-picker-accent-color | `var(--primary-color)` | AM / PM active color |
| time-picker-off-color | `var(--disabled-text-color)` | AM / PM inactive color |
## Meta
**Georgi Gardev**
- [gar.dev](https://gar.dev)
- [![GitHub][github-icon]](https://github.com/GeorgeSG/) [GeorgeSG](https://github.com/GeorgeSG/)
- [![Twitter][twitter-icon]](https://twitter.com/georgesg92) [@georgesg92](https://twitter.com/georgesg92)
- [![GitHub][github-icon]][github-link] [GeorgeSG][github-link]
- [![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
[commits]: https://github.com/GeorgeSG/lovelace-time-picker-card/commits/master
[license-shield]: https://img.shields.io/github/license/GeorgeSG/lovelace-time-picker-card?style=flat-square
[license]: https://github.com/GeorgeSG/lovelace-time-picker-card/blob/master/LICENSE
[maintenance-shield]: https://img.shields.io/maintenance/yes/2020.svg?style=flat-square
[maintenance]: https://github.com/GeorgeSG/lovelace-time-picker-card
[releases-shield]: https://img.shields.io/github/release/GeorgeSG/lovelace-time-picker-card.svg?style=flat-square
[releases]: https://github.com/GeorgeSG/lovelace-time-picker-card/releases
[hacs-shield]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=flat-square
[hacs]: https://github.com/custom-components/hacs
[twitter-icon]: http://i.imgur.com/wWzX9uB.png
[hacs-shield]: https://img.shields.io/badge/HACS-Default-brightgreen.svg
[hacs-link]: https://github.com/custom-components/hacs
[downloads-shield]: https://img.shields.io/github/downloads/GeorgeSG/lovelace-time-picker-card/latest/total?color=brightgreen&logo=github
[downloads-link]: https://github.com/GeorgeSG/lovelace-time-picker-card/releases
[releases-shield]: https://img.shields.io/github/release/GeorgeSG/lovelace-time-picker-card.svg
[releases-link]: https://github.com/GeorgeSG/lovelace-time-picker-card/releases
[ci-shield]: https://img.shields.io/github/workflow/status/GeorgeSG/lovelace-time-picker-card/CI?label=CI&logo=github&
[ci-link]: https://github.com/GeorgeSG/lovelace-time-picker-card/actions?query=workflow%3ACI
[maintenance-shield]: https://img.shields.io/maintenance/yes/2020.svg
[maintenance-link]: https://github.com/GeorgeSG/lovelace-time-picker-card
[commits-shield]: https://img.shields.io/github/commit-activity/y/GeorgeSG/lovelace-time-picker-card
[commits-link]: https://github.com/GeorgeSG/lovelace-time-picker-card/commits/master
[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-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

+37
View File
@@ -0,0 +1,37 @@
module.exports = function (config) {
config.set({
basePath: 'test/',
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-babel')(),
],
output: {
format: 'umd',
sourcemap: 'inline',
},
},
// possible values: 'dots', 'progress'
// available reporters: https://npmjs.org/browse/keyword/karma-reporter
reporters: ['progress'],
port: 9876,
colors: true,
// possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
logLevel: config.LOG_INFO,
autoWatch: false,
singleRun: true,
browsers: ['ChromeHeadless'],
concurrency: Infinity,
});
};
+2180 -27
View File
File diff suppressed because it is too large Load Diff
+12 -2
View File
@@ -1,6 +1,6 @@
{
"name": "time-picker-card",
"version": "0.0.1",
"version": "0.1.0",
"author": "Georgi Gardev <georgi@gardev.com>",
"description": "Lovelace time-picker-card",
"keywords": [
@@ -19,31 +19,41 @@
"build": "npm run lint && npm run rollup",
"lint": "eslint src/*.ts",
"rollup": "rollup -c",
"test": "./node_modules/karma/bin/karma start",
"release-patch": "npm version patch && git push origin master --tags",
"release-minor": "npm version minor && git push origin master --tags",
"release-major": "npm version major && git push origin master --tags"
},
"dependencies": {
"custom-card-helpers": "^1.5.0",
"custom-card-helpers": "^1.6.4",
"home-assistant-js-websocket": "^5.1.0",
"lit-element": "^2.3.1",
"lit-html": "^1.2.1"
},
"devDependencies": {
"@babel/core": "^7.9.0",
"@open-wc/testing": "^2.5.16",
"@rollup/plugin-json": "^4.0.3",
"@rollup/plugin-node-resolve": "^7.1.1",
"@rollup/plugin-typescript": "^4.0.0",
"@types/chai": "^4.2.11",
"@typescript-eslint/eslint-plugin": "^2.30.0",
"@typescript-eslint/parser": "^2.30.0",
"eslint": "^6.8.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-prettier": "^3.1.3",
"karma": "^5.0.5",
"karma-chai": "^0.1.0",
"karma-chrome-launcher": "^3.1.0",
"karma-mocha": "^2.0.1",
"karma-rollup-preprocessor": "^7.0.5",
"mocha": "^7.1.2",
"prettier": "^2.0.5",
"rollup": "^1.32.1",
"rollup-plugin-babel": "^4.4.0",
"rollup-plugin-serve": "^1.0.1",
"rollup-plugin-terser": "^5.3.0",
"sinon": "^9.0.2",
"typescript": "^3.8.3"
}
}
+29 -14
View File
@@ -8,22 +8,30 @@ import {
TemplateResult,
} from 'lit-element';
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')
export class TimePeriodComponent extends LitElement {
static readonly EVENT_TOGGLE = 'toggle';
@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 {
return html` <div class="time-period-selector">
<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>
return html`<div class="time-period-selector">
${this.mode === 'single' ? this.renderSingle() : this.renderDouble()}
</div>`;
}
@@ -32,12 +40,19 @@ export class TimePeriodComponent extends LitElement {
this.dispatchEvent(event);
}
private get amClass(): ClassInfo {
return { 'time-period': true, active: this.period === Period.AM };
private renderSingle(): TemplateResult {
return html`<div class="time-period active" @click=${this.onTimePeriodChange}>
${this.period}<mwc-ripple></mwc-ripple>
</div>`;
}
private get pmClass(): ClassInfo {
return { 'time-period': true, active: this.period === Period.PM };
private renderDouble(): TemplateResult {
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 {
@@ -49,8 +64,8 @@ export class TimePeriodComponent extends LitElement {
.time-period {
width: 30px;
padding: 8px;
background: var(--tpc-elements-background-color);
color: var(--tpc-text-color, #fff);
background: var(--tpc-off-color);
color: var(--tpc-text-color);
text-align: center;
font-size: 1em;
cursor: pointer;
+4 -1
View File
@@ -10,6 +10,9 @@ import {
import { TimeUnit } from '../models/time-unit';
import { Direction } from '../types';
/**
* Renders an AM/PM picker.
*/
@customElement('time-unit')
export class TimeUnitComponent extends LitElement {
static readonly EVENT_UPDATE = 'update';
@@ -52,7 +55,7 @@ export class TimeUnitComponent extends LitElement {
private renderStepChanger(direction: Direction): TemplateResult {
return html`
<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>
</div>
`;
+10
View File
@@ -1,4 +1,14 @@
import * as pkg from '../package.json';
import { Layout } from './types';
export const CARD_VERSION = pkg.version;
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;
+225
View File
@@ -0,0 +1,225 @@
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">
<ha-switch
style="margin-left: 10px"
.checked="${!Boolean(this.config.hide?.name)}"
.configValue="${'hide_name'}"
@change="${this.onHideNameChange}"
>
Show name?
</ha-switch>
<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">
<ha-switch
.checked="${this.config.hour_mode === 12}"
.configValue="${'hour_mode'}"
@change="${this.onHourModeChange}"
>
12-Hour mode
</ha-switch>
<ha-switch
.checked="${this.config.layout?.hour_mode === 'single'}"
.configValue="${'hour_mode_layout'}"
@change="${this.onHourModeLayoutChange}"
>
"Single" hour mode layout (in 12-Hour mode only)
</ha-switch>
</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>
</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 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;
}
.side-by-side {
display: flex;
}
.side-by-side > * {
flex: 1;
padding-right: 4px;
}
.suffix {
margin: 0 8px;
}
`;
}
}
+9 -6
View File
@@ -1,16 +1,19 @@
import { TimeUnit } from './time-unit';
import { DEFAULT_HOUR_STEP } from '../const';
import { HourMode } from '../types';
import { TimeUnit } from './time-unit';
/**
* Represents the hour value of a datetime.
*/
export class Hour extends TimeUnit {
private static readonly DEFAULT_STEP = 1;
private static readonly MAX = 24;
private static readonly VALUE_LIMIT = 24;
constructor(value: number, step = Hour.DEFAULT_STEP, private hourMode: HourMode) {
super(value, step, Hour.MAX);
constructor(value: number, step = DEFAULT_HOUR_STEP, private hourMode: HourMode) {
super(value, step, Hour.VALUE_LIMIT);
}
get maxValue(): number {
return this.hourMode || Hour.MAX;
return this.hourMode || Hour.VALUE_LIMIT;
}
togglePeriod(): void {
+8 -5
View File
@@ -1,13 +1,16 @@
import { DEFAULT_MINUTE_STEP } from '../const';
import { TimeUnit } from './time-unit';
/**
* * Represents the minute value of a datetime.
*/
export class Minute extends TimeUnit {
private static readonly DEFAULT_STEP = 5;
private static readonly MAX = 60;
private static readonly VALUE_LIMIT = 60;
maxValue = Minute.MAX;
maxValue = Minute.VALUE_LIMIT;
constructor(value: number, step = Minute.DEFAULT_STEP) {
super(value, step, Minute.MAX);
constructor(value: number, step = DEFAULT_MINUTE_STEP) {
super(value, step, Minute.VALUE_LIMIT);
}
protected isValidString(valueStr: string): boolean {
+18 -7
View File
@@ -1,8 +1,11 @@
import { Direction } from '../types';
/**
* Represents a single unit of a datetime oobject - e.g. hours or minutes.
*/
export abstract class TimeUnit {
/**
* Return true if the valueStr can be set as a value of this instance.
* Whether {@param valueStr} can be set as a value of this instance.
*/
protected abstract isValidString(valueStr: string): boolean;
@@ -23,28 +26,36 @@ export abstract class TimeUnit {
return this._value;
}
/**
* Sets a value from {@param stringValue}, if it can be parsed and is valid.
* @param stringValue
*/
setStringValue(stringValue: string): void {
if (this.isValidString(stringValue)) {
this.setValue(parseInt(stringValue));
}
}
/**
* Updates the value in {@param direction} by the step size specified in the constructoor.
* @param direction
*/
stepUpdate(direction: Direction): void {
direction === Direction.UP ? this.increment() : this.decrement();
}
increment(): void {
toString(): string {
return this.value < 10 ? `0${this.value}` : this.value.toString();
}
private increment(): void {
this.setValue(this.value + this._step);
}
decrement(): void {
private decrement(): void {
this.setValue(this.value - this._step);
}
toString(): string {
return this.value < 10 ? `0${this.value}` : this.value.toString();
}
protected setValue(newValue: number): void {
if (newValue >= this._limit || newValue < 0) {
newValue = (newValue + this._limit) % this._limit;
+8 -2
View File
@@ -1,6 +1,7 @@
import { html, TemplateResult } from 'lit-element';
import { TimePickerCardConfig } from './types';
import { LovelaceCard } from 'custom-card-helpers';
import { HassEntity } from 'home-assistant-js-websocket';
export class Partial {
static error(error: string, origConfig: TimePickerCardConfig): TemplateResult {
@@ -14,11 +15,16 @@ export class Partial {
return html`${errorCard}`;
}
static header(title: string, icon?: string): TemplateResult | null {
static headerName(title: string): TemplateResult {
return html`
<div class="time-picker-header">
${icon ? html`<ha-icon class="icon" icon=${icon}></ha-icon>` : ''}${title}
${title}
</div>
`;
}
static nestedName(name: string, entity: HassEntity): TemplateResult {
return html`<state-badge .stateObj=${entity}></state-badge>
<div class="entity-name-inside">${name}</div>`;
}
}
+126 -30
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 {
css,
@@ -9,13 +9,22 @@ import {
property,
TemplateResult,
} from 'lit-element';
import './components/time-period.component';
import './components/time-unit.component';
import { CARD_SIZE, CARD_VERSION } from './const';
import { ClassInfo, classMap } from 'lit-html/directives/class-map';
import {
CARD_SIZE,
CARD_VERSION,
DEFAULT_LAYOUT_HOUR_MODE,
ENTITY_DOMAIN,
DEFAULT_LAYOUT_ALIGN_CONTROLS,
} from './const';
import { Hour } from './models/hour';
import { Minute } from './models/minute';
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(
`%c TIME-PICKER-CARD \n%c Version ${CARD_VERSION} `,
@@ -23,9 +32,16 @@ console.info(
'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')
export class TimePickerCard extends LitElement {
@property() private hass!: HomeAssistant;
export class TimePickerCard extends LitElement implements LovelaceCard {
@property({ type: Object }) hass!: HomeAssistant;
@property() private config!: TimePickerCardConfig;
@property() private hour!: Hour;
@property() private minute!: Minute;
@@ -35,8 +51,20 @@ export class TimePickerCard extends LitElement {
return this.hass.states[this.config.entity];
}
private get shouldShowName(): boolean {
return Boolean(this.config.hide?.name) === false && Boolean(this.name);
private get hasNameInHeader(): boolean {
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 {
@@ -47,18 +75,36 @@ export class TimePickerCard extends LitElement {
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 {
if (!this.entity) {
return Partial.error('Entity not found', this.config);
}
if (!this.entity.entity_id.startsWith('input_datetime')) {
return Partial.error('You must set an input_datetime entity', this.config);
if (computeDomain(this.entity.entity_id) !== ENTITY_DOMAIN) {
return Partial.error(`You must set an ${ENTITY_DOMAIN} entity`, this.config);
}
if (!this.entity.attributes.has_time) {
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
);
}
@@ -70,18 +116,23 @@ export class TimePickerCard extends LitElement {
return html`
<ha-card>
${this.shouldShowName ? Partial.header(this.name!) : ''}
<div class="time-picker-content">
<time-unit .unit=${this.hour} @update=${this.callHassService}></time-unit>
<div class="time-separator">:</div>
<time-unit .unit=${this.minute} @update=${this.callHassService}></time-unit>
${this.hasNameInHeader ? Partial.headerName(this.name!) : ''}
<div class=${classMap(this.rowClass)}>
${this.hasNameInside ? Partial.nestedName(this.name!, this.entity) : ''}
${this.shouldShowPeriod
? html`<time-period
.period=${this.period}
@toggle=${this.onPeriodToggle}
></time-period>`
: ''}
<div class=${classMap(this.contentClass)}>
<time-unit .unit=${this.hour} @update=${this.callHassService}></time-unit>
<div class="time-separator">:</div>
<time-unit .unit=${this.minute} @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>
</ha-card>
`;
@@ -119,7 +170,7 @@ export class TimePickerCard extends LitElement {
const time = `${this.hour.value}:${this.minute.value}:00`;
return this.hass.callService('input_datetime', 'set_datetime', {
return this.hass.callService(ENTITY_DOMAIN, 'set_datetime', {
entity_id: this.entity!.entity_id,
time,
});
@@ -130,29 +181,74 @@ export class TimePickerCard extends LitElement {
:host {
--tpc-elements-background-color: var(
--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-text-color: var(--time-picker-text-color, var(--primary-text-color));
--tpc-accent-color: var(--time-picker-accent-color, var(--accent-color));
--tpc-text-color: var(--time-picker-text-color, #fff);
--tpc-accent-color: var(--time-picker-accent-color, var(--primary-color));
--tpc-off-color: var(--time-picker-off-color, var(--disabled-text-color));
}
.time-picker-header {
padding: 16px;
color: var(--tpc-text-color, #fff);
color: var(--tpc-text-color);
background-color: var(--tpc-elements-background-color);
font-size: 1em;
text-align: center;
}
.time-picker-content {
padding: 8px 16px 16px;
.time-picker-row {
display: flex;
flex-direction: row;
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;
}
.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;
}
}
+27 -5
View File
@@ -6,11 +6,35 @@ export interface TimePickerCardConfig extends LovelaceCardConfig {
hour_mode?: HourMode;
hour_step?: number;
minute_step?: number;
layout?: TimePickerLayoutConfig;
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 {
name: boolean;
name?: boolean;
}
export enum Direction {
@@ -19,8 +43,6 @@ export enum Direction {
}
export enum Period {
AM = 'am',
PM = 'pm',
AM = 'AM',
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);
});
});
});
+2 -1
View File
@@ -12,6 +12,7 @@
"noImplicitAny": false,
"skipLibCheck": true,
"resolveJsonModule": true,
"experimentalDecorators": true
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true // for importing sinon in tests
}
}