├── .eslintrc.js
├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
└── workflows
│ ├── build.yml
│ ├── release.yml
│ └── validate.yaml
├── .gitignore
├── .prettierrc.js
├── CONTRIBUTE.md
├── LICENSE
├── README.md
├── assets
├── card-editor.png
├── examples
│ ├── action-custom-icon.png
│ ├── action-custom.png
│ ├── action-minimal.png
│ ├── climate.gif
│ ├── cover.gif
│ ├── fan.gif
│ ├── general-compact.png
│ ├── general-minimal.png
│ ├── icon-icon-override.png
│ ├── icon-minimal.png
│ ├── lock.gif
│ ├── media.gif
│ ├── slider-force-square.png
│ ├── slider-minimal.png
│ ├── slider-show-track.png
│ ├── slider-state-color.png
│ └── switch.gif
├── grid-full-width.png
├── grid-not-square.png
├── preview-2.gif
└── preview.gif
├── dist
└── slider-button-card.js
├── hacs.json
├── package-lock.json
├── package.json
├── rollup.config.dev.js
├── rollup.config.js
├── src
├── action-handler-directive.ts
├── const.ts
├── controllers
│ ├── climate-controller.ts
│ ├── controller.ts
│ ├── cover-controller.ts
│ ├── fan-controller.ts
│ ├── get-controller.ts
│ ├── input-boolean-controller.ts
│ ├── light-controller.ts
│ ├── lock-controller.ts
│ ├── media-controller.ts
│ └── switch-controller.ts
├── editor.ts
├── localize
│ ├── languages
│ │ ├── de.json
│ │ ├── en.json
│ │ ├── fr.json
│ │ ├── he.json
│ │ ├── ko.json
│ │ ├── nl.json
│ │ ├── pl.json
│ │ ├── pt.json
│ │ └── ru.json
│ └── localize.ts
├── slider-button-card.ts
├── types.ts
└── utils.ts
└── tsconfig.json
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parser: "@typescript-eslint/parser", // Specifies the ESLint parser
3 | parserOptions: {
4 | ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features
5 | sourceType: "module" // Allows for the use of imports
6 | },
7 | extends: [
8 | "plugin:@typescript-eslint/recommended" // Uses the recommended rules from the @typescript-eslint/eslint-plugin
9 | ],
10 | rules: {
11 | // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs
12 | // e.g. "@typescript-eslint/explicit-function-return-type": "off",
13 | }
14 | };
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [mattieha]
2 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: bug
6 | assignees: ''
7 | ---
8 |
9 |
14 |
15 | **Checklist:**
16 |
17 | - [ ] I updated to the latest version available
18 | - [ ] I cleared the cache of my browser
19 |
20 | **Release with the issue:**
21 |
22 | **Last working release (if known):**
23 |
24 | **Browser and Operating System:**
25 |
26 |
29 |
30 | **Description of problem:**
31 |
32 |
35 |
36 | **Javascript errors shown in the web inspector (if applicable):**
37 |
38 | ```
39 |
40 | ```
41 |
42 | **Additional information:**
43 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: feature request
6 | assignees: ''
7 | ---
8 |
9 | **Is your feature request related to a problem? Please describe.**
10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
11 |
12 | **Describe the solution you'd like**
13 | A clear and concise description of what you want to happen.
14 |
15 | **Describe alternatives you've considered**
16 | A clear and concise description of any alternative solutions or features you've considered.
17 |
18 | **Additional context**
19 | Add any other context or screenshots about the feature request here.
20 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: 'Build'
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | branches:
9 | - main
10 |
11 | jobs:
12 | build:
13 | name: Test build
14 | runs-on: ubuntu-latest
15 | steps:
16 | - uses: actions/checkout@v1
17 | - name: Build
18 | run: |
19 | npm install
20 | npm run build
21 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | release:
5 | types: [published]
6 |
7 | jobs:
8 | release:
9 | name: Prepare release
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v1
13 |
14 | # Build
15 | - name: Build the file
16 | run: |
17 | cd /home/runner/work/slider-button-card/slider-button-card
18 | npm install
19 | npm run build
20 |
21 | # Upload build file to the releas as an asset.
22 | - name: Upload zip to release
23 | uses: svenstaro/upload-release-action@v1-release
24 |
25 | with:
26 | repo_token: ${{ secrets.GITHUB_TOKEN }}
27 | file: /home/runner/work/slider-button-card/slider-button-card/dist/slider-button-card.js
28 | asset_name: slider-button-card.js
29 | tag: ${{ github.ref }}
30 | overwrite: true
31 |
--------------------------------------------------------------------------------
/.github/workflows/validate.yaml:
--------------------------------------------------------------------------------
1 | name: Validate
2 |
3 | on:
4 | push:
5 | pull_request:
6 | schedule:
7 | - cron: "0 0 * * *"
8 |
9 | jobs:
10 | validate:
11 | runs-on: "ubuntu-latest"
12 | steps:
13 | - uses: "actions/checkout@v2"
14 | - name: HACS validation
15 | uses: "hacs/action@main"
16 | with:
17 | CATEGORY: "plugin"
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules/
2 | /.rpt2_cache/
3 | /.idea/
4 | /.vscode/
5 | /.devcontainer/
6 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | semi: true,
3 | trailingComma: 'all',
4 | singleQuote: true,
5 | printWidth: 120,
6 | tabWidth: 2,
7 | };
--------------------------------------------------------------------------------
/CONTRIBUTE.md:
--------------------------------------------------------------------------------
1 | # Contributing to the Slider button card by
2 |
3 | Thanks for taking the time to contribute to this card.
4 |
5 | ## Adding a new translation
6 | Steps for adding a new translation:
7 | ### Json translation file
8 | Translation files are located under `src/localize/languages`
9 | 1. Make a copy of `en.json`
10 | 2. Rename it to your two characters language `xx.json`
11 | 3. Translate only the values
12 | ### Make it available
13 | For the card to be able to use the translation it needs to be added to `src/localize/localize.ts`
14 | 1. Add an import statement: `import * as xx from './languages/xx.json';`
15 | 2. Add it to the language variable
16 | ```` typescript
17 | const languages: any = {
18 | en: en,
19 | he: he,
20 | nl: nl,
21 | pl: pl,
22 | ru: ru,
23 | xx: xx, // Add alphabeticly (without this comment)
24 | };
25 | ````
26 |
27 | ### Update Readme
28 | Add your translation to the `README.md` under the languages section:
29 | ```markdown
30 | - English
31 | - Hebrew
32 | - Nederlands (Dutch)
33 | - Polish (polski)
34 | - Russian
35 | - Your new language
36 | ```
37 |
38 | ### Create a PR
39 | Commit your changes and create a [Pull Request](https://github.com/mattieha/slider-button-card/pulls)
40 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Custom cards for Home Assistant
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Slider button card by [@mattieha](https://www.github.com/mattieha)
2 | [![GitHub Release][releases-shield]][releases]
3 | [](https://github.com/custom-components/hacs)
4 |
5 | A button card with integrated slider for `light, switch, fan, cover, input_boolean, media_player, climate, lock` entities.
6 |
7 | ![Preview][preview]
8 | ![Preview 2][preview-2]
9 |
10 | #### Please ⭐️ this repo if you find it useful
11 |
12 | ## TOC
13 | - [Installation](#installation)
14 | - [HACS](#hacs)
15 | - [Manual](#manual)
16 | - [Configuration](#configuration)
17 | - [Visual Editor](#visual-editor)
18 | - [Options](#options)
19 | - [Icon options](#icon-options)
20 | - [Slider options](#slider-options)
21 | - [Action button options](#action-button-options)
22 | - [Tap action](#action-options)
23 | - [Styles](#styles)
24 | - [Examples](#examples)
25 | - [Minimal working config](#minimal-working-config)
26 | - [Per feature](#per-feature)
27 | - [General](#general)
28 | - [Icon](#icon)
29 | - [Action button](#action-button)
30 | - [Slider](#slider)
31 | - [Full examples](#full-examples)
32 | - [Fan](#fan)
33 | - [Switch](#switch)
34 | - [Cover](#cover)
35 | - [Media player](#media-player)
36 | - [Climate](#climate)
37 | - [Lock](#lock)
38 | - [In a grid](#grid)
39 | - [Group support](#groups)
40 | - [Known issues](#known-issues)
41 | - [Languages](#languages)
42 | - [Credits](#credits)
43 |
44 | ## Installation
45 |
46 | ### HACS
47 | This card is available in [HACS][hacs] (Home Assistant Community Store).
48 | Just search for `Slider Button Card` in Frontend tab.
49 |
50 | ### Manual
51 |
52 | 1. Download `slider-button-card.js` file from the [latest-release].
53 | 2. Put `slider-button-card.js` file into your `config/www` folder.
54 | 3. Go to _Configuration_ → _Lovelace Dashboards_ → _Resources_ → Click Plus button → Set _Url_ as `/local/slider-button-card.js` → Set _Resource type_ as `JavaScript Module`.
55 | 4. Add `custom:slider-button-card` to Lovelace UI as any other card (using either editor or YAML configuration).
56 |
57 | ## Configuration
58 |
59 | ### Visual Editor
60 |
61 | Slider Button Card supports Lovelace's Visual Editor.
62 |
63 | Show screenshot
64 |
65 | ![Visual Editor][visual-editor]
66 |
67 |
68 |
69 | ### Options
70 |
71 | | Name | Type | Requirement | Description | Default |
72 | | ----------------- | ------- | ------------ | ------------------------------------------- | ------------------- |
73 | | type | string | **Required** | `custom:slider-button-card` |
74 | | entity | string | **Required** | HA entity ID from domain `light, switch, fan, cover, input_boolean, media_player, climate, lock` | |
75 | | name | string | **Optional** | Name | `entity.friendly_name` |
76 | | show_name | boolean | **Optional** | Show name | `true` |
77 | | show_state | boolean | **Optional** | Show state | `true` |
78 | | compact | boolean | **Optional** | Compact mode, display name and state inline with icon. Useful for full width cards. | `false` |
79 | | icon | object | **Optional** | [Icon options](#icon-options) | |
80 | | slider | object | **Optional** | [Slider options](#slider-options) | |
81 | | action_button | object | **Optional** | [Action button options](#action-button-options) | |
82 |
83 | ### Icon Options
84 |
85 | | Name | Type | Requirement | Description | Default |
86 | | ----------------- | ------- | ------------ | ------------------------------------------- | ------------------- |
87 | | icon | string | **Optional** | Icon | `default entity icon` |
88 | | show | boolean | **Optional** | Show icon | `true` |
89 | | use_state_color | boolean | **Optional** | Use state color | `true` |
90 | | tap_action | object | **Optional** | [Action](#action-options) to take on tap | `action: more-info` |
91 |
92 | ### Slider Options
93 |
94 | | Name | Type | Requirement | Description | Default |
95 | | ----------------- | ------- | ------------ | ------------------------------------------- | ------------------- |
96 | | direction | string | **Optional** | Direction `left-right, top-bottom, bottom-top` | `left-right` |
97 | | background | string | **Optional** | Background `solid, gradient, triangle, striped, custom` | `gradient` |
98 | | use_state_color | boolean | **Optional** | Use state color | `true` |
99 | | use_percentage_bg_opacity | boolean | **Optional** | Apply opacity to background based on percentage | `true` |
100 | | show_track | boolean | **Optional** | Show track when state is on | `false` |
101 | | force_square | boolean | **Optional** | Force the button as a square | `false` |
102 | | toggle_on_click | boolean | **Optional** | Force the slider to act as a toggle, if `true` sliding is disabled | `false` |
103 | | attribute | string | **Optional** | Control an [attribute](#attributes) for `light` or `cover` entities | |
104 | | invert | boolean | **Optional** | Invert calculation of state and percentage, useful for `cover` entities | `false` `true` for `cover` |
105 |
106 | ### Attributes
107 | Light:
108 | - `brightness_pct` **default**
109 | - `brightness`
110 | - `color_temp`
111 | - `hue`
112 | - `saturation`
113 |
114 | _Warning options other than `brightness_pct` and `brightness` may give strange results_
115 |
116 | For example when `color_temp` is selected as attribute and the current `color_mode` of the light is **not** `color_temp` there is no value available for the slider, so the min value will be displayed. Same for `hue` and `saturation`, slider will only show correct value when the `color_mode` is `hs`.
117 |
118 | Cover:
119 | - `position` **default**
120 | - `tilt`
121 | ### Action button Options
122 |
123 | | Name | Type | Requirement | Description | Default |
124 | | ----------------- | ------- | ------------ | ------------------------------------------- | ------------------- |
125 | | mode | string | **Optional** | Mode `toggle, custom` | `toggle` |
126 | | show | boolean | **Optional** | Show the action button | `true` |
127 | | icon | string | **Optional** | Icon when mode is `custom` | `mdi:power` |
128 | | show_spinner | boolean | **Optional** | Show spinner when mode is `custom` | `true` |
129 | | tap_action | object | **Optional** | [Action](#action-options) to take on tap | `action: toggle` |
130 |
131 | ### Action Options
132 |
133 | | Name | Type | Requirement | Description | Default |
134 | | --------------- | ------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
135 | | action | string | **Required** | Action to perform (more-info, toggle, call-service, navigate url, none) | `more-info` |
136 | | navigation_path | string | **Optional** | Path to navigate to (e.g. /lovelace/0/) when action defined as navigate | `none` |
137 | | url | string | **Optional** | URL to open on click when action is url. The URL will open in a new tab | `none` |
138 | | service | string | **Optional** | Service to call (e.g. media_player.media_play_pause) when action defined as call-service | `none` |
139 | | service_data | object | **Optional** | Service data to include (e.g. entity_id: media_player.bedroom) when action defined as call-service | `none` |
140 | | haptic | string | **Optional** | Haptic feedback for the [Beta IOS App](http://home-assistant.io/ios/beta) _success, warning, failure, light, medium, heavy, selection_ | `none` |
141 | | repeat | number | **Optional** | How often to repeat the `hold_action` in milliseconds. | `non` |
142 | ### Styles
143 | Custom styles can be set by using [Card mod](https://github.com/thomasloven/lovelace-card-mod)
144 | ```yaml
145 | style: |
146 | :host {
147 | --VARIABLE: VALUE;
148 | }
149 | ```
150 |
151 | | Variable | Description | Default |
152 | | ----------------------- | ------------------------------------------- | ------------------- |
153 | | `--icon-color` | Color of the icon when `icon.use_state_color === false` | `var(--paper-item-icon-color)` |
154 | | `--label-color-on` | Color of the label when state is on | `var(--primary-text-color, white)` |
155 | | `--label-color-off` | Color of the label when state is off | `var(--primary-text-color, white)` |
156 | | `--state-color-on` | Color of the state value when state is on | `var(--label-badge-text-color, white)` |
157 | | `--state-color-off` | Color of the state value when state is off | `var(--disabled-text-color)` |
158 | | `--action-icon-color-on` | Color of the action button icon when state is on | `var(--paper-item-icon-color, black)` |
159 | | `--action-icon-color-off` | Color of the action button icon when state is off | `var(--paper-item-icon-color, black)` |
160 | | `--action-spinner-color` | Color of the spinner action button | `var(--label-badge-text-color, white)` |
161 |
162 | ## Examples
163 |
164 | ### Minimal working config
165 |
166 |
167 |
168 | Minimal working config
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 | ```yaml
177 | type: custom:slider-button-card
178 | entity: light.couch
179 | slider:
180 | direction: left-right
181 | background: gradient
182 | icon:
183 | tap_action:
184 | action: more-info
185 | action_button:
186 | mode: toggle
187 | ```
188 |
189 |
190 |
191 |
192 | ### Per feature
193 |
194 | #### General
195 |
196 |
197 |
198 |
199 | Compact, best used in full width (not in grid)
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 | ```yaml
208 | compact: true
209 | ```
210 |
211 |
212 |
213 |
214 | #### Icon
215 |
216 |
217 |
218 |
219 | Minimal config
220 |
221 |
222 |
223 |
224 |
225 |
226 |
227 | ```yaml
228 | icon:
229 | tap_action:
230 | action: more-info
231 | ```
232 |
233 |
234 |
235 |
236 |
237 |
238 |
239 | Icon override
240 |
241 |
242 |
243 |
244 |
245 |
246 |
247 | ```yaml
248 | icon:
249 | icon: mdi:lightbulb
250 | tap_action:
251 | action: more-info
252 | ```
253 |
254 |
255 |
256 |
257 |
258 | #### Action button
259 |
260 |
261 |
262 |
263 | Minimal config
264 |
265 |
266 |
267 |
268 |
269 |
270 |
271 | ```yaml
272 | action_button:
273 | mode: toggle
274 | show: true
275 | ```
276 |
277 |
278 |
279 |
280 |
281 |
282 |
283 | Custom
284 |
285 |
286 |
287 |
288 |
289 |
290 |
291 | ```yaml
292 | action_button:
293 | mode: custom
294 | show: true
295 | tap_action:
296 | action: toggle
297 | ```
298 |
299 |
300 |
301 |
302 |
303 |
304 |
305 | Custom icon and tap action
306 |
307 |
308 |
309 |
310 |
311 |
312 |
313 | ```yaml
314 | action_button:
315 | mode: custom
316 | show: true
317 | icon: mdi:palette
318 | tap_action:
319 | action: call-service
320 | service: scene.turn_on
321 | service_data:
322 | entity_id: scene.test
323 | ```
324 |
325 |
326 |
327 |
328 | #### Slider
329 |
330 |
331 |
332 |
333 | Minimal config
334 |
335 |
336 |
337 |
338 |
339 |
340 |
341 | ```yaml
342 | slider:
343 | direction: left-right
344 | background: gradient
345 | ```
346 |
347 |
348 |
349 |
350 |
351 |
352 |
353 | Background uses color or color_temp if available
354 |
355 |
356 |
357 |
358 |
359 |
360 |
361 | ```yaml
362 | slider:
363 | direction: left-right
364 | background: gradient
365 | use_state_color: true
366 | ```
367 |
368 |
369 |
370 |
371 |
372 |
373 |
374 | Show track, best used in full width or triangle
375 |
376 |
377 |
378 |
379 |
380 |
381 |
382 | ```yaml
383 | slider:
384 | direction: left-right
385 | background: triangle
386 | use_state_color: true
387 | show_track: true
388 | ```
389 |
390 |
391 |
392 |
393 |
394 |
395 |
396 | Force square
397 |
398 |
399 |
400 |
401 |
402 |
403 |
404 | ```yaml
405 | slider:
406 | direction: left-right
407 | background: triangle
408 | use_state_color: true
409 | show_track: true
410 | force_square: true
411 | ```
412 |
413 |
414 |
415 |
416 |
417 |
418 | ### Full examples
419 | #### Fan
420 | For fan entities the icon auto rotates based on the speed of the fan.
421 |
422 |
423 |
424 | Icon rotate animation
425 |
426 |
427 |
428 |
429 |
430 |
431 |
432 | ```yaml
433 | type: custom:slider-button-card
434 | entity: fan.living_fan
435 | slider:
436 | direction: left-right
437 | background: triangle
438 | show_track: true
439 | icon:
440 | tap_action:
441 | action: more-info
442 | action_button:
443 | tap_action:
444 | action: toggle
445 | mode: custom
446 | name: Fan
447 | ```
448 |
449 |
450 |
451 |
452 | #### Switch
453 | Use `slider.toggle_on_click: true` so the slider acts as a toggle (sliding is disabled).
454 |
455 |
456 |
457 | Toggle on click
458 |
459 |
460 |
461 |
462 |
463 |
464 |
465 | ```yaml
466 | type: custom:slider-button-card
467 | entity: switch.socket
468 | slider:
469 | direction: left-right
470 | background: custom
471 | toggle_on_click: true
472 | icon:
473 | use_state_color: true
474 | tap_action:
475 | action: more-info
476 | action_button:
477 | tap_action:
478 | action: toggle
479 | mode: custom
480 | name: Switch
481 | ```
482 |
483 |
484 |
485 |
486 | #### Cover
487 | For most use cases: set `slider.direction: top-bottom` and `slider.background: striped`;
488 |
489 |
490 |
491 | Direction top to bottom, custom action icon
492 |
493 |
494 |
495 |
496 |
497 |
498 |
499 | ```yaml
500 | type: custom:slider-button-card
501 | entity: cover.living_cover
502 | slider:
503 | direction: top-bottom
504 | background: striped
505 | icon:
506 | show: true
507 | tap_action:
508 | action: more-info
509 | action_button:
510 | tap_action:
511 | action: toggle
512 | mode: custom
513 | icon: mdi:swap-vertical
514 | name: Cover
515 | ```
516 |
517 |
518 |
519 |
520 | #### Media player
521 | Default behavior: slider is used for volume control, when there is an entity picture it will be used instead of the icon.
522 | In this example the action button is used to toggle play/pause.
523 |
524 |
525 |
526 | Action button to toggle play/pause
527 |
528 |
529 |
530 |
531 |
532 |
533 |
534 | ```yaml
535 | type: custom:slider-button-card
536 | entity: media_player.spotify_mha
537 | slider:
538 | direction: left-right
539 | background: triangle
540 | show_track: true
541 | icon:
542 | tap_action:
543 | action: more-info
544 | action_button:
545 | mode: custom
546 | icon: mdi:play-pause
547 | tap_action:
548 | action: call-service
549 | service: media_player.media_play_pause
550 | service_data:
551 | entity_id: media_player.spotify_mha
552 | name: Media
553 |
554 | ```
555 |
556 |
557 |
558 |
559 | #### Climate
560 | Default behavior: slider is used to set target temperature, it doesn't alter state.
561 |
562 |
563 |
564 | Target temperature and state disabled in card
565 |
566 |
567 |
568 |
569 |
570 |
571 |
572 | ```yaml
573 | type: custom:slider-button-card
574 | entity: climate.harmony_climate_controller
575 | slider:
576 | direction: left-right
577 | background: triangle
578 | show_track: true
579 | icon:
580 | tap_action:
581 | action: more-info
582 | action_button:
583 | mode: custom
584 | tap_action:
585 | action: toggle
586 | name: Airco
587 |
588 | ```
589 |
590 |
591 |
592 |
593 | #### Lock
594 | Default behavior: `slider.toggle_on_click: true`
595 |
596 |
597 |
598 | Action button hidden
599 |
600 |
601 |
602 |
603 |
604 |
605 |
606 | ```yaml
607 | type: custom:slider-button-card
608 | entity: lock.virtual_lock
609 | slider:
610 | direction: left-right
611 | background: solid
612 | toggle_on_click: true
613 | icon:
614 | use_state_color: true
615 | tap_action:
616 | action: more-info
617 | action_button:
618 | show: false
619 | name: Lock
620 | ```
621 |
622 |
623 |
624 |
625 | #### Grid
626 |
627 |
628 |
629 |
630 | 4 columns, square: false
631 |
632 |
633 |
634 |
635 |
636 |
637 |
638 | ```yaml
639 | type: grid
640 | cards:
641 | - type: custom:slider-button-card
642 | entity: light.couch
643 | slider:
644 | direction: left-right
645 | background: gradient
646 | use_state_color: true
647 | icon:
648 | tap_action:
649 | action: more-info
650 | use_state_color: true
651 | action_button:
652 | mode: toggle
653 | - type: custom:slider-button-card
654 | entity: switch.socket
655 | slider:
656 | direction: left-right
657 | background: custom
658 | toggle_on_click: true
659 | icon:
660 | use_state_color: true
661 | tap_action:
662 | action: more-info
663 | action_button:
664 | tap_action:
665 | action: toggle
666 | mode: toggle
667 | name: Switch
668 | - type: custom:slider-button-card
669 | entity: fan.living_fan
670 | slider:
671 | direction: left-right
672 | background: triangle
673 | show_track: true
674 | icon:
675 | tap_action:
676 | action: more-info
677 | action_button:
678 | tap_action:
679 | action: toggle
680 | mode: custom
681 | name: Fan
682 | - type: custom:slider-button-card
683 | entity: cover.living_cover
684 | slider:
685 | direction: top-bottom
686 | background: striped
687 | icon:
688 | show: true
689 | tap_action:
690 | action: more-info
691 | action_button:
692 | tap_action:
693 | action: toggle
694 | mode: custom
695 | icon: mdi:swap-vertical
696 | name: Cover
697 | square: false
698 | columns: 4
699 |
700 | ```
701 |
702 |
703 |
704 |
705 | ## Groups
706 | Mixed `group` entities are not supported, if you want to control multiple
707 | - lights use [Light group](https://www.home-assistant.io/integrations/light.group/)
708 | - covers use [Cover group](https://www.home-assistant.io/integrations/cover.group/)
709 | - media players use [Media player group](https://www.home-assistant.io/integrations/media_player.group/)
710 |
711 | ## Known issues
712 | When you discover any bugs please open an [issue](https://github.com/mattieha/slider-button-card/issues).
713 |
714 | ## Languages
715 |
716 | This card supports translations. Please, help to add more translations and improve existing ones. Here's a list of supported languages:
717 |
718 | - English
719 | - French
720 | - German
721 | - Hebrew
722 | - Nederlands (Dutch)
723 | - Polish (polski)
724 | - Portuguese
725 | - Russian
726 | - Korean
727 | - [_Your language?_][add-translation]
728 |
729 | ## Credits
730 | - Inspired by [Slider entity row](https://github.com/thomasloven/lovelace-slider-entity-row)
731 |
732 | ---
733 | [](https://www.buymeacoffee.com/mattijsha)
734 |
735 |
736 | [hacs]: https://hacs.xyz
737 | [add-translation]: https://github.com/mattieha/slider-button-card/blob/main/CONTRIBUTE.md#adding-a-new-translation
738 | [visual-editor]: https://raw.githubusercontent.com/mattieha/slider-button-card/main/assets/card-editor.png
739 | [preview]: https://raw.githubusercontent.com/mattieha/slider-button-card/main/assets/preview.gif
740 | [preview-2]: https://raw.githubusercontent.com/mattieha/slider-button-card/main/assets/preview-2.gif
741 | [grid]: https://raw.githubusercontent.com/mattieha/slider-button-card/main/assets/grid-not-square.png
742 | [full-width]: https://raw.githubusercontent.com/mattieha/slider-button-card/main/assets/grid-full-width.png
743 | [latest-release]: https://github.com/mattieha/slider-button-card/releases/latest
744 | [releases-shield]: https://img.shields.io/github/release/mattieha/slider-button-card.svg?style=for-the-badge
745 | [releases]: https://github.com/mattieha/slider-button-card/releases
746 | [icon-minimal]: https://raw.githubusercontent.com/mattieha/slider-button-card/main/assets/grid-full-width.png
747 |
--------------------------------------------------------------------------------
/assets/card-editor.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/card-editor.png
--------------------------------------------------------------------------------
/assets/examples/action-custom-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/action-custom-icon.png
--------------------------------------------------------------------------------
/assets/examples/action-custom.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/action-custom.png
--------------------------------------------------------------------------------
/assets/examples/action-minimal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/action-minimal.png
--------------------------------------------------------------------------------
/assets/examples/climate.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/climate.gif
--------------------------------------------------------------------------------
/assets/examples/cover.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/cover.gif
--------------------------------------------------------------------------------
/assets/examples/fan.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/fan.gif
--------------------------------------------------------------------------------
/assets/examples/general-compact.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/general-compact.png
--------------------------------------------------------------------------------
/assets/examples/general-minimal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/general-minimal.png
--------------------------------------------------------------------------------
/assets/examples/icon-icon-override.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/icon-icon-override.png
--------------------------------------------------------------------------------
/assets/examples/icon-minimal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/icon-minimal.png
--------------------------------------------------------------------------------
/assets/examples/lock.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/lock.gif
--------------------------------------------------------------------------------
/assets/examples/media.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/media.gif
--------------------------------------------------------------------------------
/assets/examples/slider-force-square.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/slider-force-square.png
--------------------------------------------------------------------------------
/assets/examples/slider-minimal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/slider-minimal.png
--------------------------------------------------------------------------------
/assets/examples/slider-show-track.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/slider-show-track.png
--------------------------------------------------------------------------------
/assets/examples/slider-state-color.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/slider-state-color.png
--------------------------------------------------------------------------------
/assets/examples/switch.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/examples/switch.gif
--------------------------------------------------------------------------------
/assets/grid-full-width.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/grid-full-width.png
--------------------------------------------------------------------------------
/assets/grid-not-square.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/grid-not-square.png
--------------------------------------------------------------------------------
/assets/preview-2.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/preview-2.gif
--------------------------------------------------------------------------------
/assets/preview.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mattieha/slider-button-card/877bc94e55b14c1dea0e1e419fb60255a4b1e9a3/assets/preview.gif
--------------------------------------------------------------------------------
/hacs.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Slider Button Card",
3 | "render_readme": true,
4 | "filename": "slider-button-card.js",
5 | "domains": ["light", "fan", "cover", "switch", "input_boolean", "media_player"]
6 | }
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "slider-button-card",
3 | "version": "1.10.3",
4 | "description": "Lovelace slider-button-card",
5 | "keywords": [
6 | "home-assistant",
7 | "homeassistant",
8 | "hass",
9 | "automation",
10 | "lovelace",
11 | "custom-cards"
12 | ],
13 | "module": "slider-button-card.js",
14 | "repository": {
15 | "url": "https://github.com/mattieha/slider-button-card"
16 | },
17 | "author": "M Hoog Antink",
18 | "license": "MIT",
19 | "dependencies": {
20 | "@ctrl/tinycolor": "^3.4.0",
21 | "custom-card-helpers": "^1.6.6",
22 | "fast-copy": "^2.1.1",
23 | "home-assistant-js-websocket": "^4.5.0",
24 | "lit-element": "^2.4.0",
25 | "lit-html": "^1.3.0"
26 | },
27 | "devDependencies": {
28 | "@babel/core": "^7.12.3",
29 | "@babel/plugin-proposal-class-properties": "^7.12.1",
30 | "@babel/plugin-proposal-decorators": "^7.12.1",
31 | "@rollup/plugin-json": "^4.1.0",
32 | "@typescript-eslint/eslint-plugin": "^2.34.0",
33 | "@typescript-eslint/parser": "^2.34.0",
34 | "eslint": "^6.8.0",
35 | "eslint-config-airbnb-base": "^14.2.1",
36 | "eslint-config-prettier": "^6.15.0",
37 | "eslint-plugin-import": "^2.22.1",
38 | "eslint-plugin-prettier": "^3.1.4",
39 | "prettier": "^1.19.1",
40 | "rollup": "^1.32.1",
41 | "rollup-plugin-babel": "^4.4.0",
42 | "rollup-plugin-commonjs": "^10.1.0",
43 | "rollup-plugin-node-resolve": "^5.2.0",
44 | "rollup-plugin-serve": "^1.1.0",
45 | "rollup-plugin-terser": "^5.3.1",
46 | "rollup-plugin-typescript2": "^0.24.3",
47 | "rollup-plugin-uglify": "^6.0.4",
48 | "typescript": "^3.9.7"
49 | },
50 | "scripts": {
51 | "start": "rollup -c rollup.config.dev.js --watch",
52 | "build": "npm run lint && npm run rollup",
53 | "lint": "eslint src/*.ts",
54 | "rollup": "rollup -c"
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/rollup.config.dev.js:
--------------------------------------------------------------------------------
1 | import resolve from "rollup-plugin-node-resolve";
2 | import typescript from "rollup-plugin-typescript2";
3 | import babel from "rollup-plugin-babel";
4 | import serve from "rollup-plugin-serve";
5 | import { terser } from "rollup-plugin-terser";
6 | import json from '@rollup/plugin-json';
7 |
8 | export default {
9 | input: ["src/slider-button-card.ts"],
10 | output: {
11 | dir: "./dist",
12 | format: "es",
13 | },
14 | plugins: [
15 | resolve(),
16 | typescript(),
17 | json(),
18 | babel({
19 | exclude: "node_modules/**",
20 | }),
21 | terser(),
22 | serve({
23 | contentBase: "./dist",
24 | host: "0.0.0.0",
25 | port: 5000,
26 | allowCrossOrigin: true,
27 | headers: {
28 | "Access-Control-Allow-Origin": "*",
29 | },
30 | }),
31 | ],
32 | };
33 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import typescript from 'rollup-plugin-typescript2';
2 | import commonjs from 'rollup-plugin-commonjs';
3 | import nodeResolve from 'rollup-plugin-node-resolve';
4 | import babel from 'rollup-plugin-babel';
5 | import { terser } from 'rollup-plugin-terser';
6 | import serve from 'rollup-plugin-serve';
7 | import json from '@rollup/plugin-json';
8 |
9 | const dev = process.env.ROLLUP_WATCH;
10 |
11 | const serveopts = {
12 | contentBase: ['./dist'],
13 | host: '0.0.0.0',
14 | port: 5000,
15 | allowCrossOrigin: true,
16 | headers: {
17 | 'Access-Control-Allow-Origin': '*',
18 | },
19 | };
20 |
21 | const plugins = [
22 | nodeResolve({}),
23 | commonjs(),
24 | typescript(),
25 | json(),
26 | babel({
27 | exclude: 'node_modules/**',
28 | }),
29 | dev && serve(serveopts),
30 | !dev && terser(),
31 | ];
32 |
33 | export default [
34 | {
35 | input: 'src/slider-button-card.ts',
36 | output: {
37 | dir: 'dist',
38 | format: 'es',
39 | },
40 | plugins: [...plugins],
41 | },
42 | ];
43 |
--------------------------------------------------------------------------------
/src/action-handler-directive.ts:
--------------------------------------------------------------------------------
1 | import { directive, PropertyPart } from 'lit-html';
2 |
3 | import { ActionHandlerDetail, ActionHandlerOptions } from 'custom-card-helpers/dist/types';
4 | import { fireEvent } from 'custom-card-helpers';
5 |
6 | const isTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
7 |
8 | interface ActionHandler extends HTMLElement {
9 | holdTime: number;
10 | bind(element: Element, options): void;
11 | }
12 | interface ActionHandlerElement extends HTMLElement {
13 | actionHandler?: boolean;
14 | }
15 |
16 | declare global {
17 | interface HASSDomEvents {
18 | action: ActionHandlerDetail;
19 | }
20 | }
21 |
22 | class ActionHandler extends HTMLElement implements ActionHandler {
23 | public holdTime = 500;
24 |
25 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
26 | public ripple: any;
27 |
28 | protected timer?: number;
29 |
30 | protected held = false;
31 |
32 | private dblClickTimeout?: number;
33 |
34 | constructor() {
35 | super();
36 | this.ripple = document.createElement('mwc-ripple');
37 | }
38 |
39 | public connectedCallback(): void {
40 | Object.assign(this.style, {
41 | position: 'absolute',
42 | width: isTouch ? '100px' : '50px',
43 | height: isTouch ? '100px' : '50px',
44 | transform: 'translate(-50%, -50%)',
45 | pointerEvents: 'none',
46 | zIndex: '999',
47 | });
48 |
49 | this.appendChild(this.ripple);
50 | this.ripple.primary = true;
51 |
52 | ['touchcancel', 'mouseout', 'mouseup', 'touchmove', 'mousewheel', 'wheel', 'scroll'].forEach(ev => {
53 | document.addEventListener(
54 | ev,
55 | () => {
56 | clearTimeout(this.timer);
57 | this.stopAnimation();
58 | this.timer = undefined;
59 | },
60 | { passive: true },
61 | );
62 | });
63 | }
64 |
65 | public bind(element: ActionHandlerElement, options): void {
66 | if (element.actionHandler) {
67 | return;
68 | }
69 | element.actionHandler = true;
70 |
71 | element.addEventListener('contextmenu', (ev: Event) => {
72 | const e = ev || window.event;
73 | if (e.preventDefault) {
74 | e.preventDefault();
75 | }
76 | if (e.stopPropagation) {
77 | e.stopPropagation();
78 | }
79 | e.cancelBubble = true;
80 | e.returnValue = false;
81 | return false;
82 | });
83 |
84 | const start = (ev: Event): void => {
85 | this.held = false;
86 | let x;
87 | let y;
88 | if ((ev as TouchEvent).touches) {
89 | x = (ev as TouchEvent).touches[0].pageX;
90 | y = (ev as TouchEvent).touches[0].pageY;
91 | } else {
92 | x = (ev as MouseEvent).pageX;
93 | y = (ev as MouseEvent).pageY;
94 | }
95 |
96 | this.timer = window.setTimeout(() => {
97 | this.startAnimation(x, y);
98 | this.held = true;
99 | }, this.holdTime);
100 | };
101 |
102 | const end = (ev: Event): void => {
103 | // Prevent mouse event if touch event
104 | ev.preventDefault();
105 | if (['touchend', 'touchcancel'].includes(ev.type) && this.timer === undefined) {
106 | return;
107 | }
108 | clearTimeout(this.timer);
109 | this.stopAnimation();
110 | this.timer = undefined;
111 | if (this.held) {
112 | fireEvent(element, 'action', { action: 'hold' });
113 | } else if (options.hasDoubleClick) {
114 | if ((ev.type === 'click' && (ev as MouseEvent).detail < 2) || !this.dblClickTimeout) {
115 | this.dblClickTimeout = window.setTimeout(() => {
116 | this.dblClickTimeout = undefined;
117 | fireEvent(element, 'action', { action: 'tap' });
118 | }, 250);
119 | } else {
120 | clearTimeout(this.dblClickTimeout);
121 | this.dblClickTimeout = undefined;
122 | fireEvent(element, 'action', { action: 'double_tap' });
123 | }
124 | } else {
125 | fireEvent(element, 'action', { action: 'tap' });
126 | }
127 | };
128 |
129 | const handleEnter = (ev: KeyboardEvent): void => {
130 | if (ev.keyCode !== 13) {
131 | return;
132 | }
133 | end(ev);
134 | };
135 |
136 | element.addEventListener('touchstart', start, { passive: true });
137 | element.addEventListener('touchend', end);
138 | element.addEventListener('touchcancel', end);
139 |
140 | element.addEventListener('mousedown', start, { passive: true });
141 | element.addEventListener('click', end);
142 |
143 | element.addEventListener('keyup', handleEnter);
144 | }
145 |
146 | private startAnimation(x: number, y: number): void {
147 | Object.assign(this.style, {
148 | left: `${x}px`,
149 | top: `${y}px`,
150 | display: null,
151 | });
152 | this.ripple.disabled = false;
153 | this.ripple.active = true;
154 | this.ripple.unbounded = true;
155 | }
156 |
157 | private stopAnimation(): void {
158 | this.ripple.active = false;
159 | this.ripple.disabled = true;
160 | this.style.display = 'none';
161 | }
162 | }
163 |
164 | // TODO You need to replace all instances of "action-handler-boilerplate" with "action-handler-"
165 | customElements.define('action-handler-slider-button', ActionHandler);
166 |
167 | const getActionHandler = (): ActionHandler => {
168 | const body = document.body;
169 | if (body.querySelector('action-handler-slider-button')) {
170 | return body.querySelector('action-handler-slider-button') as ActionHandler;
171 | }
172 |
173 | const actionhandler = document.createElement('action-handler-slider-button');
174 | body.appendChild(actionhandler);
175 |
176 | return actionhandler as ActionHandler;
177 | };
178 |
179 | export const actionHandlerBind = (element: ActionHandlerElement, options: ActionHandlerOptions): void => {
180 | const actionhandler: ActionHandler = getActionHandler();
181 | if (!actionhandler) {
182 | return;
183 | }
184 | actionhandler.bind(element, options);
185 | };
186 |
187 | export const actionHandler = directive((options: ActionHandlerOptions = {}) => (part: PropertyPart): void => {
188 | actionHandlerBind(part.committer.element as ActionHandlerElement, options);
189 | });
190 |
--------------------------------------------------------------------------------
/src/const.ts:
--------------------------------------------------------------------------------
1 | import * as pack from '../package.json';
2 | export const CARD_VERSION = pack.version;
3 |
--------------------------------------------------------------------------------
/src/controllers/climate-controller.ts:
--------------------------------------------------------------------------------
1 | import { STATES_OFF } from 'custom-card-helpers';
2 | import { capitalizeFirst } from '../utils';
3 | import { Controller } from './controller';
4 |
5 | export class ClimateController extends Controller {
6 | _targetValue;
7 | _invert = false;
8 |
9 | get _value(): number {
10 | return this.stateObj.attributes.temperature;
11 | }
12 |
13 | set _value(value) {
14 | this._hass.callService('climate', 'set_temperature', {
15 | // eslint-disable-next-line @typescript-eslint/camelcase
16 | entity_id: this.stateObj.entity_id,
17 | temperature: value,
18 | });
19 | }
20 |
21 | get isOff(): boolean {
22 | return STATES_OFF.includes(this.state);
23 | }
24 |
25 | get _step(): number {
26 | return this.stateObj.attributes?.target_temp_step || 1;
27 | }
28 |
29 | get _min(): number {
30 | return this.stateObj.attributes?.min_temp || 7;
31 | }
32 |
33 | get _max(): number {
34 | return this.stateObj.attributes?.max_temp || 35;
35 | }
36 |
37 | get isValuePercentage(): boolean {
38 | return false;
39 | }
40 |
41 | get label(): string {
42 | const unit = this._hass.config.unit_system.temperature;
43 | const mode = capitalizeFirst(this.state);
44 | // const current = this.stateObj.attributes?.current_temperature ? ` | ${this.stateObj.attributes.current_temperature}${unit}` : '';
45 | return `${this.targetValue}${unit} | ${mode}`;
46 | }
47 |
48 | }
49 |
--------------------------------------------------------------------------------
/src/controllers/controller.ts:
--------------------------------------------------------------------------------
1 | import { computeStateDomain, domainIcon, HomeAssistant } from 'custom-card-helpers';
2 | import { HassEntity } from 'home-assistant-js-websocket';
3 | import { SliderBackground, SliderButtonCardConfig, SliderDirections } from '../types';
4 | import { getLightColorBasedOnTemperature, normalize, percentageToValue, toPercentage } from '../utils';
5 |
6 | export interface Style {
7 | icon: ObjectStyle;
8 | slider: ObjectStyle;
9 | }
10 |
11 | export interface ObjectStyle {
12 | filter: string;
13 | color: string;
14 | rotateSpeed?: string;
15 | }
16 |
17 | export abstract class Controller {
18 | _config: SliderButtonCardConfig;
19 | _hass: any;
20 | _sliderPrevColor = '';
21 |
22 | abstract _value?: number;
23 | abstract _targetValue?: number;
24 | abstract _min?: number;
25 | abstract _max?: number;
26 | abstract _step?: number;
27 | abstract _invert?: boolean;
28 |
29 | protected constructor(config: SliderButtonCardConfig) {
30 | this._config = config;
31 | }
32 |
33 | set hass(hass: HomeAssistant) {
34 | this._hass = hass;
35 | }
36 |
37 | get stateObj(): any {
38 | return this._hass.states[this._config.entity] as HassEntity;
39 | }
40 |
41 | get domain(): string {
42 | return computeStateDomain(this.stateObj);
43 | }
44 |
45 | get name(): string {
46 | return this._config.name ? this._config.name : this.stateObj?.attributes?.friendly_name ? this.stateObj.attributes.friendly_name : '';
47 | }
48 |
49 | get icon(): string {
50 | if (typeof this._config.icon?.icon === 'string' && this._config.icon?.icon.length) {
51 | return this._config.icon.icon;
52 | }
53 | return this.stateObj.attributes?.icon ? this.stateObj.attributes.icon : domainIcon(this.domain, this.stateObj.state);
54 | }
55 |
56 | get value(): number {
57 | if (this._value) {
58 | return Math.round(this._value / this.step) * this.step;
59 | }
60 | return this.min;
61 | }
62 |
63 | set value(value: number) {
64 | if (value !== this.value) {
65 | this._value = value;
66 | // this._value = Math.round(value / this.step) * this.step;
67 | }
68 | }
69 |
70 | get targetValue(): number {
71 | if (this._targetValue === 0) {
72 | return 0;
73 | }
74 | if (this._targetValue) {
75 | return Math.round(this._targetValue / this.step) * this.step;
76 | }
77 | if (this.value) {
78 | return this.value;
79 | }
80 | return 0;
81 | }
82 |
83 | set targetValue(value: number) {
84 | if (value !== this.targetValue) {
85 | this._targetValue = value;
86 | // this._targetValue = Math.round(value / this.step) * this.step;
87 | }
88 | }
89 |
90 | get label(): string {
91 | return `${this.targetValue}`;
92 | }
93 |
94 | get hidden(): boolean {
95 | return false;
96 | }
97 |
98 | get hasSlider(): boolean {
99 | return true;
100 | }
101 |
102 | get hasToggle(): boolean {
103 | return this._config.slider?.toggle_on_click ?? false;
104 | }
105 |
106 | get toggleValue(): number {
107 | return this.value === this.min ? this.max : this.min;
108 | }
109 |
110 | get state(): string {
111 | return this.stateObj?.state;
112 | }
113 |
114 | get isOff(): boolean {
115 | return this.percentage === 0;
116 | }
117 |
118 | get isUnavailable(): boolean {
119 | return this.state ? this.state === 'unavailable' : true;
120 | }
121 |
122 | get isSliderDisabled(): boolean {
123 | return this.isUnavailable ? this.isUnavailable : this.hasToggle;
124 | }
125 |
126 | get min(): number {
127 | return this._config.slider?.min ?? this._min ?? 0;
128 | }
129 |
130 | get max(): number {
131 | return this._config.slider?.max ?? this._max ?? 100;
132 | }
133 |
134 | get step(): number {
135 | return this._config.slider?.step ?? this._step ?? 5;
136 | }
137 |
138 | get invert(): boolean {
139 | return this._config.slider?.invert ?? this._invert ?? false;
140 | }
141 |
142 | get isValuePercentage(): boolean {
143 | return true;
144 | }
145 |
146 | get percentage(): number {
147 | return Math.round(
148 | ((this.targetValue - (this.invert ? this.max : this.min)) * 100) / (this.max - this.min) * (this.invert ? -1 : 1)
149 | );
150 | }
151 |
152 | get valueFromPercentage(): number {
153 | return percentageToValue(this.percentage, this.min, this.max);
154 | }
155 |
156 | get allowedAttributes(): string[] {
157 | return [];
158 | }
159 |
160 | get style(): Style {
161 | return {
162 | icon: {
163 | filter: this.iconFilter,
164 | color: this.iconColor,
165 | rotateSpeed: this.iconRotateSpeed,
166 | },
167 | slider: {
168 | filter: this.sliderFilter,
169 | color: this.sliderColor,
170 | },
171 | };
172 | }
173 |
174 | get iconFilter(): string {
175 | if (!this._config.icon?.use_state_color || this.percentage === 0) {
176 | return 'brightness(100%)';
177 | }
178 | return `brightness(${(this.percentage + 100) / 2}%)`;
179 | }
180 |
181 | get iconColor(): string {
182 | if (this._config.icon?.use_state_color) {
183 | if (this.stateObj.attributes.hs_color) {
184 | const [hue, sat] = this.stateObj.attributes.hs_color;
185 | if (sat > 10) {
186 | return `hsl(${hue}, 100%, ${100 - sat / 2}%)`;
187 | }
188 | } else if (this.percentage > 0) {
189 | return 'var(--paper-item-icon-active-color, #fdd835)'
190 | } else {
191 | return 'var(--paper-item-icon-color, #44739e)'
192 | }
193 | }
194 | return '';
195 | }
196 |
197 | get iconRotateSpeed(): string {
198 | return '0s';
199 | }
200 |
201 | get sliderFilter(): string {
202 | if (!this._config.slider?.use_percentage_bg_opacity || this.percentage === 0 || this._config.slider.background === SliderBackground.GRADIENT) {
203 | return 'brightness(100%)';
204 | }
205 | return `brightness(${(this.percentage + 100) / 2}%)`;
206 | }
207 |
208 | get sliderColor(): string {
209 | if (this._config.slider?.use_state_color) {
210 | if (this.stateObj.attributes.hs_color) {
211 | const [hue, sat] = this.stateObj.attributes.hs_color;
212 | if (sat > 10) {
213 | const color = `hsl(${hue}, 100%, ${100 - sat / 2}%)`;
214 | this._sliderPrevColor = color;
215 | return color;
216 | }
217 | } else if (
218 | this.stateObj.attributes.color_temp &&
219 | this.stateObj.attributes.min_mireds &&
220 | this.stateObj.attributes.max_mireds
221 | ) {
222 | const color = getLightColorBasedOnTemperature(
223 | this.stateObj.attributes.color_temp,
224 | this.stateObj.attributes.min_mireds,
225 | this.stateObj.attributes.max_mireds,
226 | );
227 | this._sliderPrevColor = color;
228 | return color;
229 | } else if (this._sliderPrevColor.startsWith('hsl') || this._sliderPrevColor.startsWith('rgb')) {
230 | return this._sliderPrevColor;
231 | }
232 | }
233 | return 'inherit';
234 | }
235 |
236 | moveSlider(event: any, {left, top, width, height}): number {
237 | let percentage = this.calcMovementPercentage(event, {left, top, width, height});
238 | percentage = this.applyStep(percentage);
239 | percentage = normalize(percentage, 0, 100);
240 | if (!this.isValuePercentage) {
241 | percentage = percentageToValue(percentage, this.min, this.max);
242 | }
243 | return percentage;
244 | }
245 |
246 | calcMovementPercentage(event: any, {left, top, width, height}): number {
247 | let percentage;
248 | switch(this._config.slider?.direction) {
249 | case SliderDirections.LEFT_RIGHT:
250 | percentage = toPercentage(
251 | event.clientX,
252 | left,
253 | width
254 | );
255 | if (this.invert) {
256 | percentage = 100 - percentage;
257 | }
258 | break
259 | case SliderDirections.TOP_BOTTOM:
260 | percentage = toPercentage(
261 | event.clientY,
262 | top,
263 | height
264 | );
265 | if (this.invert) {
266 | percentage = 100 - percentage;
267 | }
268 | break
269 | case SliderDirections.BOTTOM_TOP:
270 | percentage = toPercentage(
271 | event.clientY,
272 | top,
273 | height
274 | );
275 | if (!this.invert) {
276 | percentage = 100 - percentage;
277 | }
278 | break
279 |
280 | }
281 | return percentage;
282 | }
283 |
284 | applyStep(value: number): number {
285 | return Math.round(value / this.step) * this.step;
286 | }
287 |
288 | log(name = '', value: string | number | object = ''): void {
289 | if (this._config.debug) {
290 | console.log(`${this._config.entity}: ${name}`, value)
291 | }
292 | }
293 | }
294 |
--------------------------------------------------------------------------------
/src/controllers/cover-controller.ts:
--------------------------------------------------------------------------------
1 | import { stateIcon } from 'custom-card-helpers';
2 | import { CoverAttributes } from '../types';
3 | import { getEnumValues } from '../utils';
4 | import { Controller } from './controller';
5 |
6 | export class CoverController extends Controller {
7 | _min = 0;
8 | _targetValue;
9 | _invert = true;
10 |
11 | get attribute(): string {
12 | if (this._config.slider?.attribute?.length && this.allowedAttributes.includes(this._config.slider?.attribute)) {
13 | return this._config.slider?.attribute;
14 | }
15 | return CoverAttributes.POSITION;
16 | }
17 |
18 | get icon(): string {
19 | if (typeof this._config.icon?.icon === 'string' && this._config.icon?.icon.length) {
20 | return this._config.icon.icon;
21 | }
22 | return stateIcon(this.stateObj);
23 | }
24 |
25 | get allowedAttributes(): string[] {
26 | return getEnumValues(CoverAttributes);
27 | }
28 | get _value(): number {
29 | switch(this.attribute) {
30 | case CoverAttributes.POSITION:
31 | return this.stateObj?.state === 'closed'
32 | ? 0
33 | : this.stateObj.attributes.current_position;
34 | case CoverAttributes.TILT:
35 | return this.stateObj.attributes.current_tilt_position;
36 | default:
37 | return 0;
38 | }
39 | }
40 |
41 | set _value(value) {
42 | if (!this.hasSlider) {
43 | const service = value > 0 ? 'open_cover' : 'close_cover';
44 | this._hass.callService('cover', service, {
45 | // eslint-disable-next-line @typescript-eslint/camelcase
46 | entity_id: this.stateObj.entity_id
47 | });
48 | } else {
49 | switch(this.attribute) {
50 | case CoverAttributes.POSITION:
51 | this._hass.callService('cover', 'set_cover_position', {
52 | // eslint-disable-next-line @typescript-eslint/camelcase
53 | entity_id: this.stateObj.entity_id,
54 | position: value
55 | });
56 | break;
57 | case CoverAttributes.TILT:
58 | this._hass.callService('cover', 'set_cover_tilt_position', {
59 | // eslint-disable-next-line @typescript-eslint/camelcase
60 | entity_id: this.stateObj.entity_id,
61 | // eslint-disable-next-line @typescript-eslint/camelcase
62 | tilt_position: value
63 | });
64 | break;
65 | default:
66 | }
67 | }
68 | }
69 |
70 | get _step(): number {
71 | return 1;
72 | }
73 |
74 | get label(): string {
75 | const defaultLabel = this._hass.localize(`component.cover.state._.${this.state}`);
76 | const closedLabel = this._hass.localize('component.cover.state._.closed');
77 | const openLabel = this._hass.localize('component.cover.state._.open');
78 | if (!this.hasSlider) {
79 | return defaultLabel;
80 | }
81 | switch(this.attribute) {
82 | case CoverAttributes.POSITION:
83 | if (this.percentage === 0) {
84 | return this.invert ? openLabel : closedLabel;
85 | }
86 | if (this.percentage === 100) {
87 | return this.invert ? closedLabel : openLabel;
88 | }
89 | return `${this.percentage}%`;
90 | case CoverAttributes.TILT:
91 | return `${this.percentage}`;
92 | }
93 | return defaultLabel;
94 | }
95 |
96 | get hasSlider(): boolean {
97 | switch(this.attribute) {
98 | case CoverAttributes.POSITION:
99 | if ('current_position' in this.stateObj.attributes) {
100 | return true;
101 | }
102 | if (
103 | 'supported_features' in this.stateObj.attributes &&
104 | this.stateObj.attributes.supported_features & 4
105 | ) {
106 | return true;
107 | }
108 | break;
109 | case CoverAttributes.TILT:
110 | if ('current_tilt_position' in this.stateObj.attributes) {
111 | return true;
112 | }
113 | if (
114 | 'supported_features' in this.stateObj.attributes &&
115 | this.stateObj.attributes.supported_features & 128
116 | ) {
117 | return true;
118 | }
119 | break;
120 | default:
121 | return false;
122 | }
123 | return false;
124 | }
125 |
126 | get _max(): number {
127 | return this.hasSlider ? 100 : 1;
128 | }
129 |
130 | }
131 |
--------------------------------------------------------------------------------
/src/controllers/fan-controller.ts:
--------------------------------------------------------------------------------
1 | import { STATES_OFF } from 'custom-card-helpers';
2 | import { Controller } from './controller';
3 |
4 | export class FanController extends Controller {
5 | _min = 0;
6 | _targetValue;
7 | _invert = false;
8 |
9 | get _value(): number {
10 | return this.isUnavailable || STATES_OFF.includes(this.state)
11 | ? 0
12 | : this.hasSlider ? this.stateObj.attributes.percentage : 1;
13 | }
14 |
15 | set _value(value) {
16 | const service = value > 0 ? 'turn_on' : 'turn_off';
17 | if (value > 0 && this.hasSlider) {
18 | this._hass.callService('fan', 'set_percentage', {
19 | // eslint-disable-next-line @typescript-eslint/camelcase
20 | entity_id: this.stateObj.entity_id,
21 | percentage: value
22 | });
23 | } else {
24 | this._hass.callService('fan', service, {
25 | // eslint-disable-next-line @typescript-eslint/camelcase
26 | entity_id: this.stateObj.entity_id
27 | });
28 | }
29 | }
30 |
31 | get _step(): number {
32 | return this.stateObj.attributes.percentage_step;
33 | }
34 |
35 | get label(): string {
36 | if (this.percentage > 0) {
37 | if (this.hasSlider) {
38 | return `${this.percentage}%`
39 | } else {
40 | return this._hass.localize('component.fan.state._.on');
41 | }
42 | }
43 | return this._hass.localize('component.fan.state._.off');
44 | }
45 |
46 | get hasSlider(): boolean {
47 | return 'speed' in this.stateObj.attributes;
48 | }
49 |
50 | get _max(): number {
51 | return this.hasSlider ? 100 : 1;
52 | }
53 |
54 | get iconRotateSpeed(): string {
55 | let speed = 0;
56 | if (this.percentage > 0) {
57 | speed = 3 - ((this.percentage / 100) * 2);
58 | }
59 | return `${speed}s`
60 | }
61 |
62 | }
63 |
--------------------------------------------------------------------------------
/src/controllers/get-controller.ts:
--------------------------------------------------------------------------------
1 | import { computeDomain } from 'custom-card-helpers';
2 | import { Domain, SliderButtonCardConfig } from '../types';
3 | import { ClimateController } from './climate-controller';
4 | import { Controller } from './controller';
5 | import { CoverController } from './cover-controller';
6 | import { FanController } from './fan-controller';
7 | import { InputBooleanController } from './input-boolean-controller';
8 | import { LightController } from './light-controller';
9 | import { LockController } from './lock-controller';
10 | import { MediaController } from './media-controller';
11 | import { SwitchController } from './switch-controller';
12 |
13 | export class ControllerFactory {
14 | static getInstance(config: SliderButtonCardConfig): Controller {
15 | const domain = computeDomain(config.entity);
16 | const mapping = {
17 | [Domain.LIGHT]: LightController,
18 | [Domain.FAN]: FanController,
19 | [Domain.SWITCH]: SwitchController,
20 | [Domain.COVER]: CoverController,
21 | [Domain.INPUT_BOOLEAN]: InputBooleanController,
22 | [Domain.MEDIA_PLAYER]: MediaController,
23 | [Domain.CLIMATE]: ClimateController,
24 | [Domain.LOCK]: LockController,
25 | };
26 | if (typeof mapping[domain] === 'undefined') {
27 | throw new Error(`Unsupported entity type: ${domain}`)
28 | }
29 | return new mapping[domain](config);
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/controllers/input-boolean-controller.ts:
--------------------------------------------------------------------------------
1 | import { STATES_OFF } from 'custom-card-helpers';
2 | import { Controller } from './controller';
3 |
4 | export class InputBooleanController extends Controller {
5 | _min = 0;
6 | _max = 1;
7 | _targetValue;
8 | _invert = false;
9 |
10 | get _value(): number {
11 | return !STATES_OFF.includes(this.stateObj.state)
12 | ? 1
13 | : 0;
14 | }
15 |
16 | set _value(value) {
17 | const service = value > 0 ? 'turn_on' : 'turn_off';
18 | this._hass.callService('input_boolean', service, {
19 | // eslint-disable-next-line @typescript-eslint/camelcase
20 | entity_id: this.stateObj.entity_id
21 | });
22 | }
23 |
24 | get _step(): number {
25 | return 1;
26 | }
27 |
28 | get label(): string {
29 | if (this.percentage > 0) {
30 | return this._hass.localize('component.input_boolean.state._.on');
31 | }
32 | return this._hass.localize('component.input_boolean.state._.off');
33 | }
34 |
35 | }
36 |
--------------------------------------------------------------------------------
/src/controllers/light-controller.ts:
--------------------------------------------------------------------------------
1 | import { STATES_OFF } from 'custom-card-helpers';
2 | import { LightAttributes, LightColorModes } from '../types';
3 | import { getEnumValues, getLightColorBasedOnTemperature } from '../utils';
4 | import { Controller } from './controller';
5 |
6 | const HS_INDEX = {
7 | hue: 0,
8 | saturation: 1
9 | };
10 |
11 | export class LightController extends Controller {
12 |
13 | _step = 1;
14 | _targetValue;
15 | _invert = false;
16 |
17 | get attribute(): string {
18 | const attr = this._config.slider?.attribute as LightAttributes;
19 | let useAttr = LightAttributes.BRIGHTNESS_PCT;
20 | let supported: string[] = [];
21 | if (Array.isArray(this.stateObj?.attributes?.supported_color_modes)) {
22 | supported = this.stateObj?.attributes?.supported_color_modes;
23 | }
24 | if (supported.length === 1 && supported[0] === LightAttributes.ON_OFF) {
25 | useAttr = LightAttributes.ON_OFF;
26 | }
27 | if (attr?.length && this.allowedAttributes.includes(attr)) {
28 |
29 | useAttr = attr;
30 | switch(attr) {
31 | case LightAttributes.COLOR_TEMP:
32 | if (!supported.includes('color_temp')) {
33 | useAttr = LightAttributes.BRIGHTNESS_PCT;
34 | }
35 | break;
36 | case LightAttributes.HUE:
37 | case LightAttributes.SATURATION:
38 | if (!supported.includes('hs')) {
39 | useAttr = LightAttributes.BRIGHTNESS_PCT;
40 | }
41 | break;
42 | }
43 | }
44 | return useAttr;
45 | }
46 |
47 | get allowedAttributes(): string[] {
48 | return getEnumValues(LightAttributes);
49 | }
50 |
51 | get colorMode(): LightColorModes | undefined {
52 | return this.stateObj?.attributes?.color_mode;
53 | }
54 |
55 | get _value(): number {
56 | if (!this.stateObj || STATES_OFF.includes(this.state)) {
57 | return this.isValuePercentage ? 0 : this.min;
58 | }
59 | const attr = this.stateObj.attributes;
60 | switch(this.attribute) {
61 | case LightAttributes.COLOR_TEMP:
62 | return attr.color_temp ? Math.round(attr.color_temp) : this.min;
63 | case LightAttributes.BRIGHTNESS:
64 | return Math.round(attr.brightness);
65 | case LightAttributes.BRIGHTNESS_PCT:
66 | return Math.round((attr.brightness * 100.0) / 255);
67 | case LightAttributes.ON_OFF:
68 | return 1;
69 | case LightAttributes.HUE:
70 | case LightAttributes.SATURATION:
71 | return attr.hs_color
72 | ? Math.round(attr.hs_color[HS_INDEX[this.attribute]])
73 | : 0;
74 | default:
75 | return 0;
76 | }
77 | }
78 |
79 | set _value(value) {
80 | if (!this.stateObj) {
81 | return;
82 | }
83 | let attr = this.attribute;
84 | let _value;
85 | let service = value > 0 ? 'turn_on' : 'turn_off';
86 | let data = {
87 | // eslint-disable-next-line @typescript-eslint/camelcase
88 | entity_id: this.stateObj.entity_id,
89 | }
90 | switch(attr) {
91 | case LightAttributes.BRIGHTNESS:
92 | case LightAttributes.BRIGHTNESS_PCT:
93 | value =
94 | attr === LightAttributes.BRIGHTNESS
95 | ? Math.round(value)
96 | : Math.round((value / 100.0) * 255);
97 | if (!value) {
98 | service = 'turn_off';
99 | } else {
100 | attr = 'brightness';
101 | data = {
102 | ...data,
103 | [attr]: value
104 | }
105 | }
106 | break;
107 | case LightAttributes.HUE:
108 | case LightAttributes.SATURATION:
109 | _value = this.stateObj.attributes.hs_color || [0, 0];
110 | _value[HS_INDEX[attr]] = value;
111 | value = _value;
112 | attr = 'hs_color';
113 | service = 'turn_on';
114 | data = {
115 | ...data,
116 | [attr]: value
117 | }
118 | break;
119 | case LightAttributes.COLOR_TEMP:
120 | attr = 'color_temp';
121 | service = 'turn_on';
122 | data = {
123 | ...data,
124 | [attr]: value
125 | }
126 | break;
127 | }
128 |
129 | this._hass.callService('light', service, {
130 | ...data
131 | });
132 | }
133 |
134 | get _min(): number {
135 | switch(this.attribute) {
136 | case LightAttributes.COLOR_TEMP:
137 | return this.stateObj ? this.stateObj.attributes?.min_mireds ? this.stateObj.attributes.min_mireds : 153 : 153;
138 | default:
139 | return 0;
140 | }
141 | }
142 |
143 | get _max(): number {
144 | switch(this.attribute) {
145 | case LightAttributes.COLOR_TEMP:
146 | return this.stateObj ? this.stateObj.attributes?.max_mireds ? this.stateObj.attributes.max_mireds : 500 : 500;
147 | case LightAttributes.BRIGHTNESS:
148 | return 255;
149 | case LightAttributes.HUE:
150 | return 360;
151 | case LightAttributes.ON_OFF:
152 | return 1;
153 | default:
154 | return 100;
155 | }
156 | }
157 |
158 | get isValuePercentage(): boolean {
159 | switch(this.attribute) {
160 | case LightAttributes.COLOR_TEMP:
161 | case LightAttributes.HUE:
162 | case LightAttributes.BRIGHTNESS:
163 | return false;
164 | default:
165 | return true;
166 | }
167 | }
168 |
169 | get isOff(): boolean {
170 | switch(this.attribute) {
171 | case LightAttributes.COLOR_TEMP:
172 | case LightAttributes.HUE:
173 | case LightAttributes.SATURATION:
174 | case LightAttributes.BRIGHTNESS:
175 | case LightAttributes.ON_OFF:
176 | return STATES_OFF.includes(this.state);
177 | default:
178 | return this.colorMode === LightColorModes.ON_OFF ? STATES_OFF.includes(this.state) : this.percentage === 0;
179 | }
180 | }
181 |
182 | get label(): string {
183 | if (this.isOff) {
184 | return this._hass.localize('component.light.state._.off');
185 | }
186 | if (this.colorMode === LightColorModes.ON_OFF) {
187 | return this._hass.localize('component.light.state._.on');
188 | }
189 | switch(this.attribute) {
190 | case LightAttributes.ON_OFF:
191 | return this._hass.localize('component.light.state._.on');
192 | case LightAttributes.COLOR_TEMP:
193 | case LightAttributes.BRIGHTNESS:
194 | return `${this.targetValue}`;
195 | case LightAttributes.BRIGHTNESS_PCT:
196 | case LightAttributes.SATURATION:
197 | return `${this.targetValue}%`;
198 | case LightAttributes.HUE:
199 | return `${this.targetValue}°`;
200 | default:
201 | return `${this.targetValue}`;
202 | }
203 | }
204 |
205 | get hasToggle(): boolean {
206 | let supported: string[] = [];
207 | if (Array.isArray(this.stateObj?.attributes?.supported_color_modes)) {
208 | supported = this.stateObj?.attributes?.supported_color_modes;
209 | }
210 | if (supported.length === 1 && supported[0] === LightAttributes.ON_OFF) {
211 | return true;
212 | }
213 | return this._config.slider?.toggle_on_click ?? false;
214 | }
215 |
216 | get hasSlider(): boolean {
217 | if (!this.stateObj) {
218 | return false;
219 | }
220 | switch(this.attribute) {
221 | case LightAttributes.ON_OFF:
222 | return false;
223 | case LightAttributes.BRIGHTNESS:
224 | case LightAttributes.BRIGHTNESS_PCT:
225 | if ('brightness' in this.stateObj.attributes) {
226 | return true;
227 | }
228 | return !!('supported_features' in this.stateObj.attributes &&
229 | this.stateObj?.attributes?.supported_features & 1);
230 |
231 | case LightAttributes.COLOR_TEMP:
232 | if ('color_temp' in this.stateObj.attributes) {
233 | return true;
234 | }
235 | return !!('supported_features' in this.stateObj.attributes &&
236 | this.stateObj.attributes.supported_features & 2);
237 |
238 | case LightAttributes.HUE:
239 | case LightAttributes.SATURATION:
240 | if ('hs_color' in this.stateObj.attributes) {
241 | return true;
242 | }
243 | return !!('supported_features' in this.stateObj.attributes &&
244 | this.stateObj.attributes.supported_features & 16);
245 |
246 | default:
247 | return false;
248 | }
249 | }
250 |
251 | get sliderColor(): string {
252 | let returnColor = 'inherit';
253 | if (this._config.slider?.use_state_color) {
254 | if (this.stateObj.attributes.hs_color && this.attribute !== LightAttributes.COLOR_TEMP) {
255 | const [hue, sat] = this.stateObj.attributes.hs_color;
256 | let useHue = hue;
257 | let useSat = sat;
258 | switch(this.attribute) {
259 | case LightAttributes.HUE:
260 | useHue = this.valueFromPercentage;
261 | break;
262 | case LightAttributes.SATURATION:
263 | useSat = this.percentage;
264 | break;
265 | }
266 | if (useSat > 10) {
267 | returnColor = `hsl(${useHue}, 100%, ${100 - useSat / 2}%)`;
268 | this._sliderPrevColor = returnColor;
269 | }
270 | } else if (
271 | this.attribute === LightAttributes.HUE || this.attribute === LightAttributes.SATURATION
272 | ) {
273 | let useHue = 0;
274 | let useSat = 20;
275 | switch(this.attribute) {
276 | case LightAttributes.HUE:
277 | useHue = this.valueFromPercentage;
278 | break;
279 | case LightAttributes.SATURATION:
280 | useSat = this.percentage;
281 | break;
282 | }
283 | if (useSat > 10) {
284 | returnColor = `hsl(${useHue}, 100%, ${100 - useSat / 2}%)`;
285 | this._sliderPrevColor = returnColor;
286 | }
287 | } else if (
288 | this.stateObj.attributes.color_temp &&
289 | this.stateObj.attributes.min_mireds &&
290 | this.stateObj.attributes.max_mireds
291 | ) {
292 | returnColor = getLightColorBasedOnTemperature(
293 | this.attribute === LightAttributes.COLOR_TEMP ? this.valueFromPercentage : this.stateObj.attributes.color_temp,
294 | this.stateObj.attributes.min_mireds,
295 | this.stateObj.attributes.max_mireds
296 | );
297 | this._sliderPrevColor = returnColor;
298 | } else if (this.attribute === LightAttributes.COLOR_TEMP) {
299 | returnColor = getLightColorBasedOnTemperature(
300 | this.valueFromPercentage,
301 | 153,
302 | 500
303 | );
304 | this._sliderPrevColor = returnColor;
305 | } else if (this._sliderPrevColor.startsWith('hsl') || this._sliderPrevColor.startsWith('rgb')) {
306 | returnColor = this._sliderPrevColor;
307 | }
308 | }
309 | return returnColor;
310 | }
311 | }
312 |
--------------------------------------------------------------------------------
/src/controllers/lock-controller.ts:
--------------------------------------------------------------------------------
1 | import { STATES_OFF } from 'custom-card-helpers';
2 | import { Controller } from './controller';
3 |
4 | export class LockController extends Controller {
5 | _min = 0;
6 | _max = 1;
7 | _targetValue;
8 | _invert = false;
9 |
10 | get _value(): number {
11 | return !STATES_OFF.includes(this.stateObj.state)
12 | ? 1
13 | : 0;
14 | }
15 |
16 | set _value(value) {
17 | const service = value > 0 ? 'lock' : 'unlock';
18 | this._hass.callService('lock', service, {
19 | // eslint-disable-next-line @typescript-eslint/camelcase
20 | entity_id: this.stateObj.entity_id
21 | });
22 | }
23 |
24 | get _step(): number {
25 | return 1;
26 | }
27 |
28 | get label(): string {
29 | if (this.percentage > 0) {
30 | return this._hass.localize('component.lock.state._.unlocked');
31 | }
32 | return this._hass.localize('component.lock.state._.locked');
33 | }
34 |
35 | }
36 |
--------------------------------------------------------------------------------
/src/controllers/media-controller.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/camelcase */
2 | import { Controller } from './controller';
3 |
4 | export class MediaController extends Controller {
5 | _min = 0;
6 | _max = 100;
7 | _step = 1;
8 | _targetValue;
9 | _invert = false;
10 |
11 | get _value(): number {
12 | return this.isUnavailable || this.stateObj?.attributes?.is_volume_muted
13 | ? 0
14 | : Math.floor(parseFloat(Number.parseFloat(this.stateObj.attributes.volume_level).toPrecision(2)) * 100.0);
15 | }
16 |
17 | set _value(value) {
18 | value = value / 100.0;
19 | this._hass.callService('media_player', 'volume_set', {
20 | entity_id: this.stateObj.entity_id,
21 | volume_level: value,
22 | });
23 | if (value)
24 | this._hass.callService('media_player', 'volume_mute', {
25 | entity_id: this.stateObj.entity_id,
26 | is_volume_muted: false,
27 | });
28 | }
29 |
30 | get isOff(): boolean {
31 | return this.stateObj.state === 'off';
32 | }
33 |
34 | get label(): string {
35 | if (this.stateObj.attributes.is_volume_muted) return '-';
36 | return !!this.stateObj.attributes.volume_level
37 | ? `${this.percentage}%`
38 | : this._hass.localize(`component.media_player.state._.${this.state}`);
39 | }
40 |
41 | }
42 |
--------------------------------------------------------------------------------
/src/controllers/switch-controller.ts:
--------------------------------------------------------------------------------
1 | import { STATES_OFF } from 'custom-card-helpers';
2 | import { Controller } from './controller';
3 |
4 | export class SwitchController extends Controller {
5 | _min = 0;
6 | _max = 1;
7 | _targetValue;
8 | _invert = false;
9 |
10 | get _value(): number {
11 | return !STATES_OFF.includes(this.stateObj.state)
12 | ? 1
13 | : 0;
14 | }
15 |
16 | set _value(value) {
17 | const service = value > 0 ? 'turn_on' : 'turn_off';
18 | this._hass.callService('switch', service, {
19 | // eslint-disable-next-line @typescript-eslint/camelcase
20 | entity_id: this.stateObj.entity_id
21 | });
22 | }
23 |
24 | get _step(): number {
25 | return 1;
26 | }
27 |
28 | get label(): string {
29 | if (this.percentage > 0) {
30 | return this._hass.localize('component.switch.state._.on');
31 | }
32 | return this._hass.localize('component.switch.state._.off');
33 | }
34 |
35 | }
36 |
--------------------------------------------------------------------------------
/src/editor.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | /* eslint-disable @typescript-eslint/camelcase */
3 | import copy from 'fast-copy';
4 | import {
5 | LitElement,
6 | html,
7 | customElement,
8 | property,
9 | TemplateResult,
10 | CSSResult,
11 | css,
12 | state
13 | } from 'lit-element';
14 | import { HomeAssistant, fireEvent, LovelaceCardEditor, stateIcon, computeDomain } from 'custom-card-helpers';
15 | import { localize } from './localize/localize';
16 | import { ActionButtonConfig, ActionButtonConfigDefault, ActionButtonMode, Domain, IconConfig, IconConfigDefault, SliderBackground, SliderButtonCardConfig, SliderConfig, SliderConfigDefault, SliderDirections } from './types';
17 | import { applyPatch, getEnumValues, getSliderDefaultForEntity } from './utils';
18 |
19 | @customElement('slider-button-card-editor')
20 | export class SliderButtonCardEditor extends LitElement implements LovelaceCardEditor {
21 | @property({ attribute: false }) public hass?: HomeAssistant;
22 | @state() private _config?: SliderButtonCardConfig;
23 | @state() private _helpers?: any;
24 | private _initialized = false;
25 | private directions = getEnumValues(SliderDirections);
26 | private backgrounds = getEnumValues(SliderBackground);
27 | private actionModes = getEnumValues(ActionButtonMode);
28 | private actions = [
29 | "more-info",
30 | "toggle",
31 | "navigate",
32 | "url",
33 | "call-service",
34 | "none",
35 | ];
36 |
37 |
38 | public async setConfig(config: SliderButtonCardConfig): Promise {
39 | this._config = config;
40 | if (this._helpers === undefined) {
41 | await this.loadCardHelpers();
42 | }
43 | }
44 |
45 | protected shouldUpdate(): boolean {
46 | if (!this._initialized) {
47 | this._initialize();
48 | }
49 |
50 | return true;
51 | }
52 |
53 | get _name(): string {
54 | return this._config?.name || '';
55 | }
56 |
57 | get _show_name(): boolean {
58 | return typeof this._config?.show_name === 'undefined' ? true : this._config?.show_name;
59 | }
60 |
61 | get _show_state(): boolean {
62 | return typeof this._config?.show_state === 'undefined' ? true : this._config?.show_state;
63 | }
64 |
65 | get _compact(): boolean {
66 | return typeof this._config?.compact !== 'boolean' ? false : this._config?.compact;
67 | }
68 |
69 | get _entity(): string {
70 | return this._config?.entity || '';
71 | }
72 |
73 | get _icon(): IconConfig {
74 | return this._config?.icon || IconConfigDefault;
75 | }
76 |
77 | get _slider(): SliderConfig {
78 | return this._config?.slider || SliderConfigDefault;
79 | }
80 |
81 | get _action_button(): ActionButtonConfig {
82 | return this._config?.action_button || ActionButtonConfigDefault;
83 | }
84 |
85 | protected render(): TemplateResult | void {
86 | if (!this.hass || !this._helpers) {
87 | return html``;
88 | }
89 | // The climate more-info has ha-switch and paper-dropdown-menu elements that are lazy loaded unless explicitly done here
90 | this._helpers.importMoreInfoControl('climate');
91 |
92 | return html`
93 |
94 |
95 |
96 |
97 |
${localize('tabs.general.title')}
98 |
99 |
108 |
115 |
116 |
117 |
122 |
123 |
124 |
129 |
130 |
131 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
${localize('tabs.icon.title')}
144 |
145 |
152 |
153 |
154 |
155 |
160 |
161 | ${this.renderStateColor('icon')}
162 |
163 |
171 |
172 |
173 |
174 |
175 |
176 |
${localize('tabs.slider.title')}
177 |
178 |
179 |
182 |
189 | ${this.directions.map(direction => {
190 | return html`
191 | ${localize(`direction.${direction}`)}
192 | `;
193 | })}
194 |
195 |
196 |
199 |
206 | ${this.backgrounds.map(background => {
207 | return html`
208 | ${localize(`background.${background}`)}
209 | `;
210 | })}
211 |
212 |
213 |
214 |
215 |
216 | ${this.renderBrightness('slider')}
217 | ${this.renderStateColor('slider')}
218 |
219 |
224 |
225 |
226 |
231 |
232 |
233 |
238 |
239 |
240 |
241 |
242 |
243 |
244 |
245 |
${localize('tabs.action_button.title')}
246 |
247 |
250 |
257 | ${this.actionModes.map(mode => {
258 | return html`
259 | ${localize(`mode.${mode}`)}
260 | `;
261 | })}
262 |
263 |
264 | ${this._action_button.mode === ActionButtonMode.CUSTOM
265 | ? html`
266 |
273 |
274 | `
275 | :
276 | ''}
277 |
278 |
279 |
284 |
285 | ${this._action_button.mode === ActionButtonMode.CUSTOM
286 | ? html`
287 |
288 |
293 |
294 | `
295 | :
296 | ''}
297 |
298 | ${this._action_button.mode === ActionButtonMode.CUSTOM
299 | ? html`
300 |
308 | `
309 | :
310 | ''}
311 |
312 |
313 |
314 |
315 | `;
316 | }
317 |
318 | protected renderBrightness(path: string): TemplateResult | void {
319 | const item = this[`_${path}`];
320 | return html`
321 |
322 |
327 |
328 | `;
329 | }
330 |
331 | protected renderStateColor(path: string): TemplateResult | void {
332 | const item = this[`_${path}`];
333 | return html`
334 |
335 |
340 |
341 | `;
342 | }
343 |
344 | private _initialize(): void {
345 | if (this.hass === undefined) return;
346 | if (this._config === undefined) return;
347 | if (this._helpers === undefined) return;
348 | this._initialized = true;
349 | }
350 |
351 | private async loadCardHelpers(): Promise {
352 | this._helpers = await (window as any).loadCardHelpers();
353 | }
354 |
355 | private _valueChangedSelect(ev): void {
356 | const value = ev.detail.value;
357 | if (!value) {
358 | return;
359 | }
360 | this._changeValue(value.parentElement?.configValue, value.itemValue);
361 | }
362 |
363 | private _valueChangedEntity(ev): void {
364 | const target = ev.target;
365 | const value = ev.detail?.value;
366 | const updateDefaults = computeDomain(value) !== computeDomain(this._config?.entity || 'light.dummy');
367 | this._changeValue('name', '');
368 | this._changeValue('icon.icon', '');
369 | this._changeValue(target.configValue, value);
370 | if (updateDefaults) {
371 | const cfg = copy(this._config);
372 | applyPatch(cfg, ['slider'], getSliderDefaultForEntity(value));
373 | this._config = cfg;
374 | fireEvent(this, 'config-changed', { config: this._config });
375 | }
376 | }
377 |
378 | private _valueChanged(ev): void {
379 | const target = ev.target;
380 | const value = ev.detail?.value;
381 | this._changeValue(target.configValue, target.checked !== undefined ? target.checked : value);
382 | }
383 |
384 | private _changeValue(configValue: string, value: string | boolean | number): void {
385 | if (!this._config || !this.hass) {
386 | return;
387 | }
388 | if (this[`_${configValue}`] !== undefined && this[`_${configValue}`] === value) {
389 | return;
390 | }
391 | if (configValue) {
392 | const cfg = copy(this._config);
393 | applyPatch(cfg, [...configValue.split('.')], value);
394 | this._config = cfg;
395 | if (value === '') {
396 | delete this._config[configValue];
397 | }
398 | }
399 | fireEvent(this, 'config-changed', { config: this._config });
400 | }
401 |
402 | static get styles(): CSSResult {
403 | return css`
404 | ha-switch {
405 | padding: 16px 6px;
406 | }
407 | .side-by-side {
408 | display: flex;
409 | flex-flow: row wrap;
410 | }
411 | .side-by-side > * {
412 | padding-right: 8px;
413 | width: 50%;
414 | flex-flow: column wrap;
415 | box-sizing: border-box;
416 | }
417 | .side-by-side > *:last-child {
418 | flex: 1;
419 | padding-right: 0;
420 | }
421 | .suffix {
422 | margin: 0 8px;
423 | }
424 | .group {
425 | padding: 15px;
426 | border: 1px solid var(--primary-text-color)
427 | }
428 | .tabs {
429 | overflow: hidden;
430 | }
431 | .tab {
432 | width: 100%;
433 | color: var(--primary-text-color);
434 | overflow: hidden;
435 | }
436 | .tab-label {
437 | display: flex;
438 | justify-content: space-between;
439 | padding: 1em 1em 1em 0em;
440 | border-bottom: 1px solid var(--secondary-text-color);
441 | font-weight: bold;
442 | cursor: pointer;
443 | }
444 | .tab-label:hover {
445 | /*background: #1a252f;*/
446 | }
447 | .tab-label::after {
448 | content: "❯";
449 | width: 1em;
450 | height: 1em;
451 | text-align: center;
452 | transition: all 0.35s;
453 | }
454 | .tab-content {
455 | max-height: 0;
456 | padding: 0 1em;
457 | background: var(--secondary-background-color);
458 | transition: all 0.35s;
459 | }
460 | input.tab-checkbox {
461 | position: absolute;
462 | opacity: 0;
463 | z-index: -1;
464 | }
465 | input.tab-checkbox:checked + .tab-label {
466 | border-color: var(--accent-color);
467 | }
468 | input.tab-checkbox:checked + .tab-label::after {
469 | transform: rotate(90deg);
470 | }
471 | input.tab-checkbox:checked ~ .tab-content {
472 | max-height: 100vh;
473 | padding: 1em;
474 | }
475 | `;
476 | }
477 | }
478 |
--------------------------------------------------------------------------------
/src/localize/languages/de.json:
--------------------------------------------------------------------------------
1 | {
2 | "common": {
3 | "version": "v",
4 | "invalid_configuration": "Ungültige Konfiguration",
5 | "show_warning": "Zeige Warnung",
6 | "show_error": "Zeige Fehler"
7 | },
8 | "tabs": {
9 | "general": {
10 | "title": "Allgemein",
11 | "entity": "Entiät (vorgeschrieben)",
12 | "name": "Name (optional)",
13 | "show_name": "Namen zeigen?",
14 | "show_state": "Zustand zeigen?",
15 | "compact": "Kompakt?"
16 | },
17 | "icon": {
18 | "title": "Icon",
19 | "icon": "Icon (optional)",
20 | "show_icon": "Icon zeigen?",
21 | "use_state_color": "Zustandsfarbe verwenden?",
22 | "tap_action": "Tap action"
23 | },
24 | "slider": {
25 | "title": "Schieberegler",
26 | "direction": "Richtung",
27 | "background": "Hintergrund",
28 | "use_brightness": "Helligkeit benutzen?",
29 | "show_track": "Spur anzeigen?",
30 | "toggle_on_click": "Als Schalter benutzen (schieben deaktivieren)",
31 | "force_square": "Quadrat erzwingen?"
32 | },
33 | "action_button": {
34 | "title": "Action-Knopf",
35 | "mode": "Modus",
36 | "icon": "Icon",
37 | "show_button": "Knopf zeigen?",
38 | "show_spinner": "Spinner anzeigen?",
39 | "tap_action": "Tap action"
40 | }
41 | },
42 | "state": {
43 | "off": "Aus",
44 | "on": "An"
45 | },
46 | "direction": {
47 | "left-right": "Links nach Rechts",
48 | "top-bottom": "Oben nach Unten",
49 | "bottom-top": "Unten nach Oben"
50 | },
51 | "background": {
52 | "striped": "gestreift",
53 | "gradient": "Farbverlauf",
54 | "solid": "Einfarbig",
55 | "triangle": "Dreieck",
56 | "custom": "benuzerdefiniert"
57 | },
58 | "mode": {
59 | "toggle": "Umschalter",
60 | "custom": "benuzerdefiniert"
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/localize/languages/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "common": {
3 | "version": "v",
4 | "invalid_configuration": "Invalid configuration",
5 | "show_warning": "Show Warning",
6 | "show_error": "Show Error"
7 | },
8 | "tabs": {
9 | "general": {
10 | "title": "General",
11 | "entity": "Entity (Required)",
12 | "name": "Name (Optional)",
13 | "show_name": "Show name?",
14 | "show_state": "Show state?",
15 | "compact": "Compact?"
16 | },
17 | "icon": {
18 | "title": "Icon",
19 | "icon": "Icon (Optional)",
20 | "show_icon": "Show icon?",
21 | "use_state_color": "Use state color?",
22 | "tap_action": "Tap action"
23 | },
24 | "slider": {
25 | "title": "Slider",
26 | "direction": "Direction",
27 | "background": "Background",
28 | "use_brightness": "Use brightness?",
29 | "show_track": "Show track?",
30 | "toggle_on_click": "Act as a toggle (disable sliding)",
31 | "force_square": "Force square?"
32 | },
33 | "action_button": {
34 | "title": "Action button",
35 | "mode": "Mode",
36 | "icon": "Icon",
37 | "show_button": "Show button?",
38 | "show_spinner": "Show spinner?",
39 | "tap_action": "Tap action"
40 | }
41 | },
42 | "state": {
43 | "off": "Off",
44 | "on": "On"
45 | },
46 | "direction": {
47 | "left-right": "Left to right",
48 | "top-bottom": "Top to bottom",
49 | "bottom-top": "Bottom to top"
50 | },
51 | "background": {
52 | "striped": "Striped",
53 | "gradient": "Gradient",
54 | "solid": "Solid",
55 | "triangle": "Triangle",
56 | "custom": "Custom"
57 | },
58 | "mode": {
59 | "toggle": "Toggle",
60 | "custom": "Custom"
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/localize/languages/fr.json:
--------------------------------------------------------------------------------
1 | {
2 | "common": {
3 | "version": "v",
4 | "invalid_configuration": "Configuration incorrecte",
5 | "show_warning": "Afficher les avertissement",
6 | "show_error": "Afficher les erreurs"
7 | },
8 | "tabs": {
9 | "general": {
10 | "title": "Général",
11 | "entity": "Entité (Obligatoire)",
12 | "name": "Nom (Optionnel)",
13 | "show_name": "Afficher le nom ?",
14 | "show_state": "Afficher l'état ?",
15 | "compact": "Compact ?"
16 | },
17 | "icon": {
18 | "title": "Icône",
19 | "icon": "Icône (Optionnel)",
20 | "show_icon": "Afficher l'icône ?",
21 | "use_state_color": "Afficher la couleur d'état?",
22 | "tap_action": "Action"
23 | },
24 | "slider": {
25 | "title": "Curseur",
26 | "direction": "Direction",
27 | "background": "Fond",
28 | "use_brightness": "Utiliser la luminosité ?",
29 | "show_track": "Afficher le chemin ?",
30 | "toggle_on_click": "Agir comme un bouton (désactive le curseur)",
31 | "force_square": "Forcer carré ?"
32 | },
33 | "action_button": {
34 | "title": "Bouton d'action",
35 | "mode": "Mode",
36 | "icon": "Icône",
37 | "show_button": "Afficher le bouton ?",
38 | "show_spinner": "Afficher spinner ?",
39 | "tap_action": "Action"
40 | }
41 | },
42 | "state": {
43 | "off": "Inactif",
44 | "on": "Actif"
45 | },
46 | "direction": {
47 | "left-right": "gauche à droite",
48 | "top-bottom": "haut à bas",
49 | "bottom-top": "Bas à haut"
50 | },
51 | "background": {
52 | "striped": "Rayures",
53 | "gradient": "Dégradé",
54 | "solid": "Uni",
55 | "triangle": "Triangle",
56 | "custom": "Personnalisé"
57 | },
58 | "mode": {
59 | "toggle": "Bascule",
60 | "custom": "Personnalisé"
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/localize/languages/he.json:
--------------------------------------------------------------------------------
1 | {
2 | "common": {
3 | "version": "v",
4 | "invalid_configuration": "תצורה לא חוקית",
5 | "show_warning": "הצג אזהרה",
6 | "show_error": "הצג שגיאה"
7 | },
8 | "tabs": {
9 | "general": {
10 | "title": "כללי",
11 | "entity": "ישיות (נדרש)",
12 | "name": "שם (אופציונלי)",
13 | "show_name": "להציג שם?",
14 | "show_state": "להציג מצב?",
15 | "compact": "קוֹמפָּקטִי?"
16 | },
17 | "icon": {
18 | "title": "סמליל",
19 | "icon": "סמליל (אופציונלי)",
20 | "show_icon": "להציג סמליל?",
21 | "use_state_color": "להשתמש בצבע מצב?",
22 | "tap_action": "פעולה בהקשה"
23 | },
24 | "slider": {
25 | "title": "גלילה",
26 | "direction": "כיוון",
27 | "background": "רקע",
28 | "use_brightness": "להשתמש בבהירות?",
29 | "show_track": "להציג מסלול?",
30 | "toggle_on_click": "פעל כמתג (השבת החלקה)",
31 | "force_square": "כוח מרובע?"
32 | },
33 | "action_button": {
34 | "title": "כפתור פעולה",
35 | "mode": "מצב",
36 | "icon": "סמליל",
37 | "show_button": "להציג כפתור?",
38 | "show_spinner": "להציג ספינר?",
39 | "tap_action": "פעולה בהקשה"
40 | }
41 | },
42 | "state": {
43 | "off": "כבוי",
44 | "on": "פועל"
45 | },
46 | "direction": {
47 | "left-right": "שמאל לימין",
48 | "top-bottom": "מלמעלה למטה",
49 | "bottom-top": "מלמטה למעלה"
50 | },
51 | "background": {
52 | "striped": "מפוספס",
53 | "gradient": "שיפוע",
54 | "solid": "מוצק",
55 | "triangle": "משולש",
56 | "custom": "מותאם אישית"
57 | },
58 | "mode": {
59 | "toggle": "החלפה",
60 | "custom": "מותאם אישית"
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/localize/languages/ko.json:
--------------------------------------------------------------------------------
1 | {
2 | "common": {
3 | "version": "v",
4 | "invalid_configuration": "유효하지 않은 설정입니다",
5 | "show_warning": "경고 표시",
6 | "show_error": "에러 표시"
7 | },
8 | "tabs": {
9 | "general": {
10 | "title": "일반",
11 | "entity": "구성 요소 (필수)",
12 | "name": "이름 (옵션)",
13 | "show_name": "이름 표시",
14 | "show_state": "상태 표시",
15 | "compact": "슬림 모드"
16 | },
17 | "icon": {
18 | "title": "아이콘",
19 | "icon": "아이콘 (옵션)",
20 | "show_icon": "아이콘 표시",
21 | "use_state_color": "상태 색상 사용",
22 | "tap_action": "탭 액션"
23 | },
24 | "slider": {
25 | "title": "슬라이더",
26 | "direction": "방향 지정",
27 | "background": "배경",
28 | "use_brightness": "밝기 사용",
29 | "show_track": "범위 표시",
30 | "toggle_on_click": "토글 버튼으로 동작(슬라이더 비활성화)",
31 | "force_square": "정사각형 모양으로 고정"
32 | },
33 | "action_button": {
34 | "title": "액션 버튼",
35 | "mode": "모드",
36 | "icon": "아이콘",
37 | "show_button": "버튼 표시",
38 | "show_spinner": "로딩 스피너 표시",
39 | "tap_action": "탭 액셥"
40 | }
41 | },
42 | "state": {
43 | "off": "꺼짐",
44 | "on": "켜짐"
45 | },
46 | "direction": {
47 | "left-right": "왼쪽에서 오른쪽",
48 | "top-bottom": "위에서 아래",
49 | "bottom-top": "아래에서 위"
50 | },
51 | "background": {
52 | "striped": "줄무늬",
53 | "gradient": "그레디언트",
54 | "solid": "단색",
55 | "triangle": "삼각형",
56 | "custom": "커스텀"
57 | },
58 | "mode": {
59 | "toggle": "토글 모드",
60 | "custom": "커스텀 모드"
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/localize/languages/nl.json:
--------------------------------------------------------------------------------
1 | {
2 | "common": {
3 | "version": "v",
4 | "invalid_configuration": "Ongeldige configuratie",
5 | "show_warning": "Toon waarschuwing",
6 | "show_error": "Toon fout"
7 | },
8 | "tabs": {
9 | "general": {
10 | "title": "Algemeen",
11 | "entity": "Entiteit (Verplicht)",
12 | "name": "Naam (Optioneel)",
13 | "show_name": "Toon naam?",
14 | "show_state": "Toon status?",
15 | "compact": "Compact?"
16 | },
17 | "icon": {
18 | "title": "Icoon",
19 | "icon": "Icoon (Optioneel)",
20 | "show_icon": "Toon icoon?",
21 | "use_state_color": "Gebruik status kleur?",
22 | "tap_action": "Tap actie"
23 | },
24 | "slider": {
25 | "title": "Schuifregelaar",
26 | "direction": "Richting",
27 | "background": "Actergrond",
28 | "use_brightness": "Gebruik helderheid?",
29 | "show_track": "Toon spoor?",
30 | "toggle_on_click": "Fungeren als een schakelaar (schuiven uitschakelen)",
31 | "force_square": "Forceer vierkant?"
32 | },
33 | "action_button": {
34 | "title": "Actie button",
35 | "mode": "Modus",
36 | "icon": "Icoon",
37 | "show_button": "Toon button?",
38 | "show_spinner": "Toon spinner?",
39 | "tap_action": "Tap actie"
40 | }
41 | },
42 | "state": {
43 | "off": "Uit",
44 | "on": "Aan"
45 | },
46 | "direction": {
47 | "left-right": "Links naar rechts",
48 | "top-bottom": "Boven naar onder",
49 | "bottom-top": "Onder naar boven"
50 | },
51 | "background": {
52 | "striped": "Gestreept",
53 | "gradient": "Verloop",
54 | "solid": "Vast",
55 | "triangle": "Driehoek",
56 | "custom": "Aangepast"
57 | },
58 | "mode": {
59 | "toggle": "Schakelaar",
60 | "custom": "Aangepast"
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/localize/languages/pl.json:
--------------------------------------------------------------------------------
1 | {
2 | "common": {
3 | "version": "v",
4 | "invalid_configuration": "Nieprawidłowa konfiguracja",
5 | "show_warning": "Pokaż ostrzeżenia",
6 | "show_error": "Pokaż błędy"
7 | },
8 | "tabs": {
9 | "general": {
10 | "title": "Ogólne",
11 | "entity": "Encja (Wymagana)",
12 | "name": "Nazwa (Opcjonalna)",
13 | "show_name": "Pokazać nazwę?",
14 | "show_state": "Pokazać stan?",
15 | "compact": "Kompaktowy?"
16 | },
17 | "icon": {
18 | "title": "Ikona",
19 | "icon": "Ikona (Opcjonalna)",
20 | "show_icon": "Pokazać ikonę?",
21 | "use_state_color": "Uzyć kolor stanu?",
22 | "tap_action": "Akcja kliknięcia"
23 | },
24 | "slider": {
25 | "title": "Suwak",
26 | "direction": "Kierunek",
27 | "background": "Tło",
28 | "use_brightness": "Użyć jasności?",
29 | "show_track": "Pokazać ślad?",
30 | "toggle_on_click": "Działaj jako przełącznik (wyłącz przesuwanie)",
31 | "force_square": "Wymusić kwadrat?"
32 | },
33 | "action_button": {
34 | "title": "Przycisk akcji",
35 | "mode": "Tryb",
36 | "icon": "Ikona",
37 | "show_button": "Pokazać przycisk?",
38 | "show_spinner": "Pokazać spinner?",
39 | "tap_action": "Akcja kliknięcia"
40 | }
41 | },
42 | "state": {
43 | "off": "Wyłączony",
44 | "on": "Włączony"
45 | },
46 | "direction": {
47 | "left-right": "Z lewej do prawej",
48 | "top-bottom": "Z góry na dół",
49 | "bottom-top": "Z dołu do góry"
50 | },
51 | "background": {
52 | "striped": "W paski",
53 | "gradient": "Gradient",
54 | "solid": "Pełne tło",
55 | "triangle": "Trójkąt",
56 | "custom": "Ustawienia własne"
57 | },
58 | "mode": {
59 | "toggle": "Przełącznik",
60 | "custom": "Ustawienia własne"
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/localize/languages/pt.json:
--------------------------------------------------------------------------------
1 | {
2 | "common": {
3 | "version": "v",
4 | "invalid_configuration": "Configuração Inválida",
5 | "show_warning": "Mostrar Aviso",
6 | "show_error": "Mostrar Erro"
7 | },
8 | "tabs": {
9 | "general": {
10 | "title": "Geral",
11 | "entity": "Entidade (Obrigatório)",
12 | "name": "Nome (Opcional)",
13 | "show_name": "Mostrar Nome?",
14 | "show_state": "Mostrar Estado?",
15 | "compact": "Compactar?"
16 | },
17 | "icon": {
18 | "title": "Ícone",
19 | "icon": "Ícone (Opcional)",
20 | "show_icon": "Mostrar Ícone?",
21 | "use_state_color": "Usar Cor de Estado?",
22 | "tap_action": "Ação de Toque"
23 | },
24 | "slider": {
25 | "title": "Slider",
26 | "direction": "Direção",
27 | "background": "Fundo",
28 | "use_brightness": "Usar Brilho?",
29 | "show_track": "Mostrar Acompanhamento?",
30 | "toggle_on_click": "Atua como um alternador (desative o deslizamento)",
31 | "force_square": "Forçar Quadrado?"
32 | },
33 | "action_button": {
34 | "title": "Botão de Ação",
35 | "mode": "Modo",
36 | "icon": "Ícone",
37 | "show_button": "Mostrar Botão?",
38 | "show_spinner": "Mostrar Spinner?",
39 | "tap_action": "Ação de Toque"
40 | }
41 | },
42 | "state": {
43 | "off": "Desligar",
44 | "on": "Ligar"
45 | },
46 | "direction": {
47 | "left-right": "Esquerda para a Direita",
48 | "top-bottom": "De Cima para Baixo",
49 | "bottom-top": "De Baixo para Cima"
50 | },
51 | "background": {
52 | "striped": "Listrado",
53 | "gradient": "Gradiente",
54 | "solid": "Sólido",
55 | "triangle": "Triângulo",
56 | "custom": "Personalizado"
57 | },
58 | "mode": {
59 | "toggle": "Alternancia",
60 | "custom": "Personalizado"
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/localize/languages/ru.json:
--------------------------------------------------------------------------------
1 | {
2 | "common": {
3 | "version": "v",
4 | "invalid_configuration": "Неверная конфигурация",
5 | "show_warning": "Показать предупреждения",
6 | "show_error": "Показать ошибки"
7 | },
8 | "tabs": {
9 | "general": {
10 | "title": "Общие",
11 | "entity": "Объект (обязательно)",
12 | "name": "Имя (Опционально)",
13 | "show_name": "Отображать имя?",
14 | "show_state": "Отображать статус?",
15 | "compact": "Компактный?"
16 | },
17 | "icon": {
18 | "title": "Иконка",
19 | "icon": "Иконка (Опционально)",
20 | "show_icon": "Показать иконку?",
21 | "use_state_color": "Использовать цвет статуса?",
22 | "tap_action": "Действие по нажатию"
23 | },
24 | "slider": {
25 | "title": "Слайдер",
26 | "direction": "Направление",
27 | "background": "Фон",
28 | "use_brightness": "Использовать яркость?",
29 | "show_track": "Показать трек?",
30 | "toggle_on_click": "Действовать как переключатель (отключить скольжение)",
31 | "force_square": "Отображать квадратным?"
32 | },
33 | "action_button": {
34 | "title": "Кнопка действия",
35 | "mode": "Режим",
36 | "icon": "Иконка",
37 | "show_button": "Отобразить кнопку?",
38 | "show_spinner": "Отобразить спиннер?",
39 | "tap_action": "Действие по нажатию"
40 | }
41 | },
42 | "state": {
43 | "off": "Выкл",
44 | "on": "Вкл"
45 | },
46 | "direction": {
47 | "left-right": "Слева направо",
48 | "top-bottom": "Сверху вниз",
49 | "bottom-top": "Снизу вверх"
50 | },
51 | "background": {
52 | "striped": "Полосатый",
53 | "gradient": "Градиент",
54 | "solid": "Сплошной цвет",
55 | "triangle": "Треугольник",
56 | "custom": "Свои настройки"
57 | },
58 | "mode": {
59 | "toggle": "Переключатель",
60 | "custom": "Свои настройки"
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/localize/localize.ts:
--------------------------------------------------------------------------------
1 | import * as en from './languages/en.json';
2 | import * as de from './languages/de.json';
3 | import * as fr from './languages/fr.json';
4 | import * as he from './languages/he.json';
5 | import * as nl from './languages/nl.json';
6 | import * as pl from './languages/pl.json';
7 | import * as pt from './languages/pt.json';
8 | import * as ru from './languages/ru.json';
9 | import * as ko from './languages/ko.json';
10 |
11 |
12 |
13 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
14 | const languages: any = {
15 | en: en,
16 | de: de,
17 | fr: fr,
18 | he: he,
19 | nl: nl,
20 | pl: pl,
21 | pt: pt,
22 | ru: ru,
23 | ko: ko,
24 | };
25 |
26 | export function localize(string: string, search = '', replace = ''): string {
27 | const lang = (localStorage.getItem('selectedLanguage') || 'en').replace(/['"]+/g, '').replace('-', '_');
28 |
29 | let translated: string;
30 |
31 | try {
32 | translated = string.split('.').reduce((o, i) => o[i], languages[lang]);
33 | } catch (e) {
34 | translated = string.split('.').reduce((o, i) => o[i], languages['en']);
35 | }
36 |
37 | if (translated === undefined) translated = string.split('.').reduce((o, i) => o[i], languages['en']);
38 |
39 | if (search !== '' && replace !== '') {
40 | translated = translated.replace(search, replace);
41 | }
42 | return translated;
43 | }
44 |
--------------------------------------------------------------------------------
/src/slider-button-card.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | import { ActionHandlerEvent, applyThemesOnElement, computeStateDomain, handleAction, hasConfigOrEntityChanged, HomeAssistant, LovelaceCard, LovelaceCardEditor, STATES_OFF, toggleEntity } from 'custom-card-helpers';
3 | import copy from 'fast-copy';
4 | import { css, CSSResult, customElement, html, LitElement, property, PropertyValues, query, state, TemplateResult } from 'lit-element';
5 | import { classMap } from 'lit-html/directives/class-map';
6 | import { ifDefined } from 'lit-html/directives/if-defined';
7 | import { styleMap } from 'lit-html/directives/style-map';
8 | import { actionHandler } from './action-handler-directive';
9 | import { CARD_VERSION } from './const';
10 | import { Controller } from './controllers/controller';
11 | import { ControllerFactory } from './controllers/get-controller';
12 | import './editor';
13 | import { localize } from './localize/localize';
14 |
15 | import type { SliderButtonCardConfig } from './types';
16 | import { ActionButtonConfigDefault, ActionButtonMode, IconConfigDefault } from './types';
17 | import { getSliderDefaultForEntity } from './utils';
18 |
19 | /* eslint no-console: 0 */
20 | console.info(
21 | `%c SLIDER-BUTTON-CARD %c ${localize('common.version')}${CARD_VERSION} %c`,
22 | 'background-color: #555;color: #fff;padding: 3px 2px 3px 3px;border: 1px solid #555;border-radius: 3px 0 0 3px;font-family: Roboto,Verdana,Geneva,sans-serif;text-shadow: 0 1px 0 rgba(1, 1, 1, 0.3)',
23 | 'background-color: transparent;color: #555;padding: 3px 3px 3px 2px;border: 1px solid #555; border-radius: 0 3px 3px 0;font-family: Roboto,Verdana,Geneva,sans-serif',
24 | 'background-color: transparent'
25 |
26 | );
27 |
28 | // This puts your card into the UI card picker dialog
29 | (window as any).customCards = (window as any).customCards || [];
30 | (window as any).customCards.push({
31 | type: 'slider-button-card',
32 | name: 'Slider button Card',
33 | description: 'A button card with slider',
34 | preview: true,
35 | });
36 |
37 | @customElement('slider-button-card')
38 | export class SliderButtonCard extends LitElement implements LovelaceCard {
39 | @property({attribute: false}) public hass!: HomeAssistant;
40 | @state() private config!: SliderButtonCardConfig;
41 | @query('.state') stateText;
42 | @query('.button') button;
43 | @query('.action') action;
44 | @query('.slider') slider;
45 | private changing = false;
46 | private changed = false;
47 | private ctrl!: Controller;
48 | private actionTimeout;
49 |
50 | public static async getConfigElement(): Promise {
51 | return document.createElement('slider-button-card-editor');
52 | }
53 |
54 | public static getStubConfig(hass: HomeAssistant, entities: string[]): object {
55 | const entity = entities.find(item => item.startsWith('light')) || '';
56 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
57 | const dummy = hass;
58 | return {
59 | entity: entity,
60 | slider: getSliderDefaultForEntity(entity),
61 | // eslint-disable-next-line @typescript-eslint/camelcase
62 | show_name: true,
63 | // eslint-disable-next-line @typescript-eslint/camelcase
64 | show_state: true,
65 | compact: false,
66 | icon: copy(IconConfigDefault),
67 | // eslint-disable-next-line @typescript-eslint/camelcase
68 | action_button: copy(ActionButtonConfigDefault),
69 | };
70 | }
71 | public getCardSize(): number {
72 | return 0;
73 | }
74 |
75 | public setConfig(config: SliderButtonCardConfig): void {
76 | if (!config) {
77 | throw new Error(localize('common.invalid_configuration'));
78 | }
79 |
80 | if (!config.entity) {
81 | throw new Error(localize('common.invalid_configuration'));
82 | }
83 |
84 | this.config = {
85 | slider: getSliderDefaultForEntity(config.entity),
86 | icon: copy(IconConfigDefault),
87 | // eslint-disable-next-line @typescript-eslint/camelcase
88 | show_name: true,
89 | // eslint-disable-next-line @typescript-eslint/camelcase
90 | show_state: true,
91 | compact: false,
92 | // eslint-disable-next-line @typescript-eslint/camelcase
93 | action_button: copy(ActionButtonConfigDefault),
94 | debug: false,
95 | ...config
96 | };
97 | this.ctrl = ControllerFactory.getInstance(this.config);
98 | }
99 |
100 | protected shouldUpdate(changedProps: PropertyValues): boolean {
101 | if (!this.config) {
102 | return false;
103 | }
104 | const oldHass = changedProps.get('hass') as HomeAssistant | undefined;
105 | if (
106 | !oldHass ||
107 | oldHass.themes !== this.hass.themes ||
108 | oldHass.language !== this.hass.language
109 | ) {
110 | this.ctrl.log('shouldUpdate', 'forced true');
111 | return true;
112 | }
113 | return hasConfigOrEntityChanged(this, changedProps, false);
114 | }
115 |
116 | protected updated(changedProps: PropertyValues): void {
117 | this.updateValue(this.ctrl.value, false);
118 | this.animateActionEnd();
119 | const oldHass = changedProps.get('hass') as HomeAssistant | undefined;
120 | const oldConfig = changedProps.get('config') as
121 | | SliderButtonCardConfig
122 | | undefined;
123 | if (
124 | oldHass?.themes !== this.hass.themes ||
125 | oldConfig?.theme !== this.config.theme
126 | ) {
127 | this.ctrl.log('Theme','updated');
128 | applyThemesOnElement(this, this.hass.themes, this.config.theme);
129 | }
130 | this.ctrl.log('Updated', this.ctrl.value);
131 | }
132 |
133 | protected firstUpdated(_changedProperties: PropertyValues): void {
134 | super.firstUpdated(_changedProperties);
135 | }
136 |
137 | protected render(): TemplateResult | void {
138 | this.ctrl.hass = this.hass;
139 | if (!this.ctrl.stateObj) {
140 | return this._showError(localize('common.show_error'));
141 | }
142 | return html`
143 |
148 |
178 |
179 | `;
180 | }
181 |
182 | private renderText(): TemplateResult {
183 | if (!this.config.show_name && !this.config.show_state) {
184 | return html``;
185 | }
186 | return html`
187 |
188 | ${this.config.show_name
189 | ? html`
190 |
${this.ctrl.name}
191 | `
192 | : ''}
193 | ${this.config.show_state
194 | ? html`
195 |
196 | ${this.ctrl.isUnavailable
197 | ? html`
198 | ${this.hass.localize('state.default.unavailable')}
199 | ` : html`
200 | ${this.ctrl.label}
201 | `}
202 |
203 | `
204 | : ''}
205 |
206 | `;
207 | }
208 |
209 | private renderIcon(): TemplateResult {
210 | if (this.config.icon?.show === false) {
211 | return html``;
212 | }
213 | let hasPicture = false;
214 | let backgroundImage = '';
215 | if (this.ctrl.stateObj.attributes.entity_picture) {
216 | backgroundImage = `url(${this.ctrl.stateObj.attributes.entity_picture})`;
217 | hasPicture = true;
218 | }
219 | return html`
220 | this._handleAction(e, this.config.icon)}
222 | .actionHandler=${actionHandler({
223 | hasHold: false,
224 | hasDoubleClick: false,
225 | })}
226 | style=${styleMap({
227 | 'background-image': `${backgroundImage}`,
228 | })}
229 | >
230 |
238 |
239 | `;
240 | }
241 |
242 | private renderAction(): TemplateResult {
243 | if (this.config.action_button?.show === false) {
244 | return html``;
245 | }
246 | if (this.config.action_button?.mode === ActionButtonMode.TOGGLE) {
247 | return html`
248 |
249 |
254 |
255 | `;
256 | }
257 | return html`
258 | this._handleAction(e, this.config.action_button)}
260 | .actionHandler=${actionHandler({
261 | hasHold: false,
262 | hasDoubleClick: false,
263 | })}
264 | >
265 |
269 | ${typeof this.config.action_button?.show_spinner === 'undefined' || this.config.action_button?.show_spinner
270 | ? html`
271 |
272 |
273 |
274 | `
275 | : ''}
276 |
277 | `;
278 | }
279 |
280 | private _handleAction(ev: ActionHandlerEvent, config): void {
281 | if (this.hass && this.config && ev.detail.action) {
282 | if (config.tap_action?.action === 'toggle' && !this.ctrl.isUnavailable) {
283 | this.animateActionStart();
284 | }
285 | handleAction(this, this.hass, {...config, entity: this.config.entity}, ev.detail.action);
286 | }
287 | }
288 |
289 | private async handleClick(ev: Event): Promise {
290 | if (this.ctrl.hasToggle && !this.ctrl.isUnavailable) {
291 | ev.preventDefault();
292 | this.animateActionStart();
293 | this.ctrl.log('Toggle');
294 | await toggleEntity(this.hass, this.config.entity);
295 | // this.setStateValue(this.ctrl.toggleValue);
296 | }
297 | }
298 |
299 | private _toggle(): void {
300 | if (this.hass && this.config) {
301 | // eslint-disable-next-line @typescript-eslint/camelcase
302 | handleAction(this, this.hass, {tap_action: {action: 'toggle'}, entity: this.config.entity}, 'tap');
303 | }
304 | }
305 |
306 | private setStateValue(value: number): void {
307 | this.ctrl.log('setStateValue', value);
308 | this.updateValue(value, false);
309 | this.ctrl.value = value;
310 | this.animateActionStart();
311 | }
312 |
313 | private animateActionStart(): void {
314 | this.animateActionEnd();
315 | if (this.action) {
316 | this.action.classList.add('loading');
317 | }
318 | }
319 |
320 | private animateActionEnd(): void {
321 | if (this.action) {
322 | clearTimeout(this.actionTimeout);
323 | this.actionTimeout = setTimeout(()=> {
324 | this.action.classList.remove('loading');
325 | }, 750)
326 | }
327 | }
328 |
329 | private updateValue(value: number, changing = true): void {
330 | this.changing = changing;
331 | this.changed = !changing;
332 | this.ctrl.log('updateValue', value);
333 | this.ctrl.targetValue = value;
334 | if (!this.button) {
335 | return
336 | }
337 | this.button.classList.remove('off');
338 | if (changing) {
339 | this.button.classList.add('changing');
340 | } else {
341 | this.button.classList.remove('changing');
342 | if (this.ctrl.isOff) {
343 | this.button.classList.add('off');
344 | }
345 | }
346 | if (this.stateText) {
347 | this.stateText.innerHTML = this.ctrl.isUnavailable ? `${this.hass.localize('state.default.unavailable')}` : this.ctrl.label;
348 | }
349 | this.button.style.setProperty('--slider-value', `${this.ctrl.percentage}%`);
350 | this.button.style.setProperty('--slider-bg-filter', this.ctrl.style.slider.filter);
351 | this.button.style.setProperty('--slider-color', this.ctrl.style.slider.color);
352 | this.button.style.setProperty('--icon-filter', this.ctrl.style.icon.filter);
353 | this.button.style.setProperty('--icon-color', this.ctrl.style.icon.color);
354 | this.button.style.setProperty('--icon-rotate-speed', this.ctrl.style.icon.rotateSpeed || '0s');
355 | }
356 |
357 | private _showError(error: string): TemplateResult {
358 | const errorCard = document.createElement('hui-error-card');
359 | errorCard.setConfig({
360 | type: 'error',
361 | error,
362 | origConfig: this.config
363 | });
364 |
365 | return html`
366 | ${errorCard}
367 | `;
368 | }
369 |
370 | private getColorFromVariable(color: string): string {
371 | if (typeof color !== 'undefined' && color.substring(0, 3) === 'var') {
372 | let varColor = window.getComputedStyle(this).getPropertyValue(color.substring(4).slice(0, -1)).trim();
373 | if (!varColor.length) {
374 | varColor = window.getComputedStyle(document.documentElement).getPropertyValue(color.substring(4).slice(0, -1)).trim();
375 | }
376 | return varColor
377 | }
378 | return color;
379 | }
380 |
381 | private onPointerDown(event: PointerEvent): void {
382 | event.preventDefault();
383 | event.stopPropagation();
384 | if (this.ctrl.isSliderDisabled) {
385 | return;
386 | }
387 | this.slider.setPointerCapture(event.pointerId);
388 | }
389 |
390 | private onPointerUp(event: PointerEvent): void {
391 | if (this.ctrl.isSliderDisabled) {
392 | return;
393 | }
394 | this.setStateValue(this.ctrl.targetValue);
395 | this.slider.releasePointerCapture(event.pointerId);
396 | }
397 |
398 | private onPointerMove(event: any): void {
399 | if (this.ctrl.isSliderDisabled) {
400 | return;
401 | }
402 | if (!this.slider.hasPointerCapture(event.pointerId)) return;
403 | const {left, top, width, height} = this.slider.getBoundingClientRect();
404 | const percentage = this.ctrl.moveSlider(event, {left, top, width, height});
405 | this.ctrl.log('onPointerMove', percentage);
406 | this.updateValue(percentage);
407 | }
408 |
409 | connectedCallback(): void {
410 | super.connectedCallback();
411 | }
412 |
413 | disconnectedCallback(): void {
414 | super.disconnectedCallback();
415 | }
416 |
417 | static get styles(): CSSResult {
418 | return css`
419 | ha-card {
420 | box-sizing: border-box;
421 | height: 100%;
422 | width: 100%;
423 | min-height: 7rem;
424 | display: flex;
425 | flex-direction: column;
426 | justify-content: space-between;
427 | touch-action: none;
428 | overflow: hidden;
429 | --mdc-icon-size: 2.2em;
430 | }
431 | ha-card.square {
432 | aspect-ratio: 1 / 1;
433 | }
434 | ha-card.compact {
435 | min-height: 3rem !important;
436 | }
437 | :host {
438 | --slider-bg-default-color: var(--primary-color, rgb(95, 124, 171));
439 | --slider-bg: var(--slider-color);
440 | --slider-bg-filter: brightness(100%);
441 | --slider-bg-direction: to right;
442 | --slider-track-color: #2b374e;
443 | --slider-tracker-color: transparent;
444 | --slider-value: 0%;
445 | --slider-transition-duration: 0.2s;
446 | /*--label-text-shadow: rgb(255 255 255 / 10%) -1px -1px 1px, rgb(0 0 0 / 50%) 1px 1px 1px;*/
447 | /*--label-color-on: var(--primary-text-color, white);*/
448 | /*--label-color-off: var(--primary-text-color, white);*/
449 | --icon-filter: brightness(100%);
450 | --icon-color: var(--paper-item-icon-color);
451 | --icon-rotate-speed: 0s;
452 | /*--state-color-on: #BAC0C6; */
453 | /*--state-color-off: var(--disabled-text-color);*/
454 | /*--state-text-shadow: rgb(255 255 255 / 10%) -1px -1px 1px, rgb(0 0 0 / 50%) 1px 1px 1px;*/
455 | --btn-bg-color-off: rgba(43,55,78,1);
456 | --btn-bg-color-on: #20293c;
457 | /*--action-icon-color-on: var(--paper-item-icon-color, black);*/
458 | /*--action-icon-color-off: var(--paper-item-icon-color, black);*/
459 | /*--action-spinner-color: var(--label-badge-text-color, white);*/
460 | }
461 | /* --- BUTTON --- */
462 |
463 | .button {
464 | position: relative;
465 | padding: 0.8rem;
466 | box-sizing: border-box;
467 | height: 100%;
468 | min-height: 7rem;
469 | width: 100%;
470 | display: block;
471 | overflow: hidden;
472 | transition: all 0.2s ease-in-out;
473 | touch-action: none;
474 | }
475 | ha-card.compact .button {
476 | min-height: 3rem !important;
477 | }
478 | .button.off {
479 | background-color: var(--btn-bg-color-off);
480 | }
481 |
482 | /* --- ICON --- */
483 |
484 | .icon {
485 | position: relative;
486 | cursor: pointer;
487 | width: var(--mdc-icon-size, 24px);
488 | height: var(--mdc-icon-size, 24px);
489 | box-sizing: border-box;
490 | padding: 0;
491 | outline: none;
492 | animation: var(--icon-rotate-speed, 0s) linear 0s infinite normal both running rotate;
493 | -webkit-tap-highlight-color: transparent;
494 | }
495 | .icon ha-icon {
496 | filter: var(--icon-filter, brightness(100%));
497 | color: var(--icon-color);
498 | transition: color 0.4s ease-in-out 0s, filter 0.2s linear 0s;
499 | }
500 | .icon.has-picture {
501 | background-size: cover;
502 | border-radius: 50%;
503 | }
504 | .icon.has-picture ha-icon{
505 | display: none;
506 | }
507 | .unavailable .icon ha-icon {
508 | color: var(--disabled-text-color);
509 | }
510 | .compact .icon {
511 | float: left;
512 | }
513 |
514 | /* --- TEXT --- */
515 |
516 | .text {
517 | position: absolute;
518 | bottom: 0;
519 | left: 0;
520 | padding: 0.8rem;
521 | pointer-events: none;
522 | user-select: none;
523 | font-size: 1.1rem;
524 | line-height: 1.3rem;
525 | max-width: calc(100% - 2em);
526 | /*text-shadow: rgb(255 255 255 / 10%) -1px -1px 1px, rgb(0 0 0 / 50%) 1px 1px 1px;*/
527 | }
528 | .compact .text {
529 | position: relative;
530 | top: 0.5rem;
531 | left: 0.5rem;
532 | display: inline-block;
533 | padding: 0;
534 | height: 1.3rem;
535 | width: 100%;
536 | overflow: hidden;
537 | max-width: calc(100% - 4em);
538 | }
539 | .compact.hide-action .text {
540 | max-width: calc(100% - 2em);
541 | }
542 |
543 | /* --- LABEL --- */
544 |
545 | .name {
546 | color: var(--label-color-on, var(--primary-text-color, white));
547 | text-overflow: ellipsis;
548 | overflow: hidden;
549 | white-space: nowrap;
550 | text-shadow: var(--label-text-shadow, none);
551 | }
552 | .off .name {
553 | color: var(--label-color-off, var(--primary-text-color, white));
554 | }
555 | .unavailable.off .name,
556 | .unavailable .name {
557 | color: var(--disabled-text-color);
558 | }
559 | .compact .name {
560 | display: inline-block;
561 | max-width: calc(100% - 3.5em);
562 | }
563 |
564 | /* --- STATE --- */
565 |
566 | .state {
567 | color: var(--state-color-on, var(--label-badge-text-color, white));
568 | text-overflow: ellipsis;
569 | white-space: nowrap;
570 | text-shadow: var(--state-text-shadow);
571 | transition: font-size 0.1s ease-in-out;
572 | }
573 | .changing .state {
574 | font-size: 150%;
575 | }
576 | .off .state {
577 | color: var(--state-color-off, var(--disabled-text-color));
578 | }
579 | .unavailable .state {
580 | color: var(--disabled-text-color);
581 | }
582 | .compact .state {
583 | display: inline-block;
584 | max-width: calc(100% - 0em);
585 | overflow: hidden;
586 | }
587 |
588 |
589 | /* --- SLIDER --- */
590 |
591 | .slider {
592 | position: absolute;
593 | top: 0px;
594 | left: 0px;
595 | height: 100%;
596 | width: 100%;
597 | background-color: var( --ha-card-background, var(--card-background-color, var(--btn-bg-color-on, black)) );
598 | cursor: ew-resize;
599 | z-index: 0;
600 | }
601 | .slider[data-mode="bottom-top"] {
602 | cursor: ns-resize;
603 | }
604 | .slider[data-mode="top-bottom"] {
605 | cursor: ns-resize;
606 | }
607 | .slider:active {
608 | cursor: grabbing;
609 | }
610 |
611 | /* --- SLIDER OVERLAY --- */
612 |
613 | .slider .toggle-overlay {
614 | position: absolute;
615 | top: 0px;
616 | left: 0px;
617 | height: 100%;
618 | width: 100%;
619 | cursor: pointer;
620 | opacity: 0;
621 | z-index: 999;
622 | }
623 |
624 | /* --- SLIDER BACKGROUND --- */
625 |
626 | .slider-bg {
627 | position: absolute;
628 | top: 0;
629 | left: 0px;
630 | height: 100%;
631 | width: 100%;
632 | background: var(--slider-bg);
633 | background-size: var(--slider-bg-size, 100% 100%);
634 | background-color: var(--slider-bg-color, transparent);
635 | background-position: var(--slider-bg-position, 0 0);
636 | filter: var(--slider-bg-filter, brightness(100%));
637 | }
638 | .off .slider .slider-bg {
639 | background-color: var( --ha-card-background, var(--card-background-color, var(--btn-bg-color-off, black)) );
640 | }
641 | .slider[data-background="solid"] .slider-bg {
642 | --slider-bg-color: var(--slider-color);
643 | }
644 | .slider[data-background="triangle"] .slider-bg {
645 | --slider-bg-direction: to bottom right;
646 | --slider-bg: linear-gradient(var(--slider-bg-direction), transparent 0%, transparent 50%, var(--slider-color) 50%, var(--slider-color) 100%);
647 | border-right: 0px solid;
648 | }
649 | .slider[data-background="triangle"][data-mode="bottom-top"] .slider-bg {
650 | --slider-bg-direction: to top left;
651 | }
652 | .slider[data-background="triangle"][data-mode="top-bottom"] .slider-bg {
653 | --slider-bg-direction: to bottom left;
654 | }
655 | .slider[data-background="custom"] .slider-bg {
656 | --slider-bg: repeating-linear-gradient(-45deg, var(--slider-color) 0, var(--slider-color) 1px, var(--slider-color) 0, transparent 10%);
657 | --slider-bg-size: 30px 30px;
658 | }
659 | .slider[data-background="gradient"] .slider-bg {
660 | --slider-bg: linear-gradient(var(--slider-bg-direction), rgba(0, 0, 0, 0) -10%, var(--slider-color) 100%);
661 | }
662 | .slider[data-background="striped"] .slider-bg {
663 | --slider-bg: linear-gradient(var(--slider-bg-direction), var(--slider-color), var(--slider-color) 50%, transparent 50%, transparent);
664 | --slider-bg-size: 4px 100%;
665 | }
666 | .slider[data-background="striped"][data-mode="bottom-top"] .slider-bg,
667 | .slider[data-background="striped"][data-mode="top-bottom"] .slider-bg {
668 | --slider-bg-size: 100% 4px;
669 | }
670 | .slider[data-mode="bottom-top"] .slider-bg {
671 | --slider-bg-direction: to top;
672 | }
673 | .slider[data-mode="top-bottom"] .slider-bg {
674 | --slider-bg-direction: to bottom;
675 | }
676 |
677 | /* --- SLIDER THUMB --- */
678 |
679 | .slider-thumb {
680 | position: relative;
681 | width: 100%;
682 | height: 100%;
683 | transform: translateX(var(--slider-value));
684 | background: transparent;
685 | transition: transform var(--slider-transition-duration) ease-in;
686 | }
687 | .changing .slider .slider-thumb {
688 | transition: none;
689 | }
690 | .slider[data-mode="top-bottom"] .slider-thumb {
691 | transform: translateY(var(--slider-value)) !important;
692 | }
693 | .slider[data-mode="bottom-top"] .slider-thumb {
694 | transform: translateY(calc(var(--slider-value) * -1)) !important;
695 | }
696 |
697 | .slider-thumb:before {
698 | content: '';
699 | position: absolute;
700 | top: 0;
701 | left: -2px;
702 | height: 100%;
703 | width: 2px;
704 | background: var(--slider-color);
705 | opacity: 0;
706 | transition: opacity 0.2s ease-in-out 0s;
707 | box-shadow: var(--slider-color) 0px 1px 5px 1px;
708 | z-index: 999;
709 | }
710 | .slider[data-mode="top-bottom"] .slider-thumb:before {
711 | top: -2px;
712 | left: 0px;
713 | height: 2px;
714 | width: 100%;
715 | }
716 | .changing .slider-thumb:before {
717 | opacity: 0.5;
718 | }
719 | .off.changing .slider-thumb:before {
720 | opacity: 0;
721 | }
722 |
723 | .slider-thumb:after {
724 | content: '';
725 | position: absolute;
726 | top: 0;
727 | left: 0px;
728 | height: 100%;
729 | width: 100%;
730 | background: var( --ha-card-background, var(--card-background-color, var(--btn-bg-color-on, black)) );
731 | opacity: 1;
732 | }
733 | .slider[data-show-track="true"] .slider-thumb:after {
734 | opacity: 0.9;
735 | }
736 | .off .slider[data-show-track="true"] .slider-thumb:after {
737 | opacity: 1;
738 | }
739 |
740 | /* --- ACTION BUTTON --- */
741 |
742 | .action {
743 | position: relative;
744 | float: right;
745 | width: var(--mdc-icon-size, 24px);
746 | height: var(--mdc-icon-size, 24px);
747 | color: var(--action-icon-color-on, var(--paper-item-icon-color, black));
748 | cursor: pointer;
749 | outline: none;
750 | -webkit-tap-highlight-color: transparent;
751 | }
752 | .action ha-switch {
753 | position: absolute;
754 | right: 0;
755 | top: 5px;
756 | }
757 | .off .action {
758 | color: var(--action-icon-color-off, var(--paper-item-icon-color, black));
759 | }
760 | .unavailable .action {
761 | color: var(--disabled-text-color);
762 | }
763 |
764 |
765 | .circular-loader {
766 | position: absolute;
767 | left: -8px;
768 | top: -8px;
769 | width: calc(var(--mdc-icon-size, 24px) + 16px);
770 | height: calc(var(--mdc-icon-size, 24px) + 16px);
771 | opacity: 0;
772 | transition: opacity 0.2s ease-in-out;
773 | animation: rotate 2s linear infinite;
774 | }
775 | .action.loading .circular-loader {
776 | opacity: 1;
777 | }
778 |
779 | .loader-path {
780 | fill: none;
781 | stroke-width: 2px;
782 | stroke: var(--action-spinner-color, var(--label-badge-text-color, white));
783 | animation: animate-stroke 1.5s ease-in-out infinite both;
784 | stroke-linecap: round;
785 | }
786 |
787 | /* --- MISC --- */
788 |
789 | .unavailable .slider .toggle-overlay,
790 | .unavailable .action,
791 | .unavailable .action ha-switch,
792 | .unavailable .slider {
793 | cursor: not-allowed !important;
794 | }
795 |
796 |
797 | @keyframes rotate {
798 | 100% {
799 | transform: rotate(360deg);
800 | }
801 | }
802 |
803 | @keyframes animate-stroke {
804 | 0% {
805 | stroke-dasharray: 1, 200;
806 | stroke-dashoffset: 0;
807 | }
808 | 50% {
809 | stroke-dasharray: 89, 200;
810 | stroke-dashoffset: -35;
811 | }
812 | 100% {
813 | stroke-dasharray: 89, 200;
814 | stroke-dashoffset: -124;
815 | }
816 | }
817 | `;
818 | }
819 | }
820 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/camelcase */
2 | import { ActionConfig, LovelaceCard, LovelaceCardConfig, LovelaceCardEditor } from 'custom-card-helpers';
3 |
4 | declare global {
5 | interface HTMLElementTagNameMap {
6 | 'slider-button-card-editor': LovelaceCardEditor;
7 | 'hui-error-card': LovelaceCard;
8 | }
9 | }
10 |
11 | export interface SliderButtonCardConfig extends LovelaceCardConfig {
12 | type: string;
13 | entity: string;
14 | name?: string;
15 | show_name?: boolean;
16 | show_state?: boolean;
17 | icon?: IconConfig;
18 | action_button?: ActionButtonConfig;
19 | slider?: SliderConfig;
20 | theme?: string;
21 | debug?: boolean;
22 | compact?: boolean;
23 | }
24 |
25 | export interface ActionButtonConfig {
26 | mode?: ActionButtonMode;
27 | icon?: string;
28 | show?: boolean;
29 | show_spinner?: boolean;
30 | tap_action?: ActionConfig;
31 | }
32 |
33 | export interface IconConfig {
34 | icon?: string;
35 | show?: boolean;
36 | tap_action?: ActionConfig;
37 | use_state_color?: boolean;
38 | }
39 |
40 | export interface SliderConfig {
41 | min?: number;
42 | max?: number;
43 | step?: number;
44 | attribute?: string;
45 | direction?: SliderDirections;
46 | background: SliderBackground;
47 | use_percentage_bg_opacity?: boolean;
48 | use_state_color?: boolean;
49 | show_track?: boolean;
50 | toggle_on_click?: boolean;
51 | invert?: boolean;
52 | force_square: boolean;
53 | }
54 |
55 | export enum ActionButtonMode {
56 | TOGGLE = 'toggle',
57 | CUSTOM = 'custom',
58 | }
59 |
60 | export enum SliderDirections {
61 | LEFT_RIGHT = 'left-right',
62 | TOP_BOTTOM = 'top-bottom',
63 | BOTTOM_TOP = 'bottom-top',
64 | }
65 |
66 | export enum SliderBackground {
67 | SOLID = 'solid',
68 | GRADIENT = 'gradient',
69 | TRIANGLE = 'triangle',
70 | STRIPED = 'striped',
71 | CUSTOM = 'custom',
72 | }
73 |
74 | export enum Domain {
75 | LIGHT = 'light',
76 | SWITCH = 'switch',
77 | FAN = 'fan',
78 | COVER = 'cover',
79 | INPUT_BOOLEAN = 'input_boolean',
80 | MEDIA_PLAYER = 'media_player',
81 | CLIMATE = 'climate',
82 | LOCK = 'lock',
83 | }
84 |
85 | export const ActionButtonConfigDefault: ActionButtonConfig = {
86 | mode: ActionButtonMode.TOGGLE,
87 | icon: 'mdi:power',
88 | show: true,
89 | show_spinner: true,
90 | tap_action: {
91 | action: 'toggle'
92 | },
93 | };
94 |
95 | export const IconConfigDefault: IconConfig = {
96 | show: true,
97 | use_state_color: true,
98 | tap_action: {
99 | action: 'more-info'
100 | },
101 | };
102 |
103 | export const SliderConfigDefault: SliderConfig = {
104 | direction: SliderDirections.LEFT_RIGHT,
105 | background: SliderBackground.SOLID,
106 | use_percentage_bg_opacity: false,
107 | use_state_color: false,
108 | show_track: false,
109 | toggle_on_click: false,
110 | force_square: false,
111 | };
112 |
113 | export const SliderConfigDefaultDomain: Map = new Map([
114 | [Domain.LIGHT, {
115 | direction: SliderDirections.LEFT_RIGHT,
116 | background: SliderBackground.GRADIENT,
117 | use_state_color: true,
118 | use_percentage_bg_opacity: false,
119 | show_track: false,
120 | toggle_on_click: false,
121 | force_square: false,
122 | }],
123 | [Domain.FAN, {
124 | direction: SliderDirections.LEFT_RIGHT,
125 | background: SliderBackground.SOLID,
126 | use_state_color: false,
127 | use_percentage_bg_opacity: false,
128 | show_track: false,
129 | toggle_on_click: false,
130 | force_square: false,
131 | }],
132 | [Domain.SWITCH, {
133 | direction: SliderDirections.LEFT_RIGHT,
134 | background: SliderBackground.SOLID,
135 | use_state_color: false,
136 | use_percentage_bg_opacity: false,
137 | show_track: false,
138 | toggle_on_click: true,
139 | force_square: false,
140 | }],
141 | [Domain.COVER, {
142 | direction: SliderDirections.TOP_BOTTOM,
143 | background: SliderBackground.STRIPED,
144 | use_state_color: false,
145 | use_percentage_bg_opacity: false,
146 | toggle_on_click: false,
147 | show_track: false,
148 | force_square: false,
149 | invert: true,
150 | }],
151 | [Domain.INPUT_BOOLEAN, {
152 | direction: SliderDirections.LEFT_RIGHT,
153 | background: SliderBackground.SOLID,
154 | use_state_color: false,
155 | use_percentage_bg_opacity: false,
156 | show_track: false,
157 | toggle_on_click: true,
158 | force_square: false,
159 | }],
160 | [Domain.MEDIA_PLAYER, {
161 | direction: SliderDirections.LEFT_RIGHT,
162 | background: SliderBackground.TRIANGLE,
163 | use_state_color: false,
164 | use_percentage_bg_opacity: false,
165 | show_track: true,
166 | toggle_on_click: false,
167 | force_square: false,
168 | }],
169 | [Domain.LOCK, {
170 | direction: SliderDirections.LEFT_RIGHT,
171 | background: SliderBackground.SOLID,
172 | use_state_color: false,
173 | use_percentage_bg_opacity: false,
174 | show_track: false,
175 | toggle_on_click: true,
176 | force_square: false,
177 | }],
178 | [Domain.CLIMATE, {
179 | direction: SliderDirections.LEFT_RIGHT,
180 | background: SliderBackground.TRIANGLE,
181 | use_state_color: false,
182 | use_percentage_bg_opacity: false,
183 | show_track: true,
184 | toggle_on_click: false,
185 | force_square: false,
186 | }],
187 | ]);
188 |
189 | export enum LightAttributes {
190 | COLOR_TEMP = 'color_temp',
191 | BRIGHTNESS = 'brightness',
192 | BRIGHTNESS_PCT = 'brightness_pct',
193 | HUE = 'hue',
194 | SATURATION = 'saturation',
195 | ON_OFF = 'onoff',
196 | }
197 |
198 | export enum LightColorModes {
199 | COLOR_TEMP = 'color_temp',
200 | BRIGHTNESS = 'brightness',
201 | HS = 'hs',
202 | ON_OFF = 'onoff',
203 | }
204 |
205 | export enum CoverAttributes {
206 | POSITION = 'position',
207 | TILT = 'tilt',
208 | }
209 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { tinycolor, TinyColor } from '@ctrl/tinycolor';
2 | import { computeDomain } from 'custom-card-helpers';
3 | import copy from 'fast-copy';
4 | import { Domain, SliderConfig, SliderConfigDefault, SliderConfigDefaultDomain } from './types';
5 |
6 | export function getEnumValues(enumeration): string[] {
7 | return Object.keys(enumeration).map(key => enumeration[key]).filter(value => typeof value === 'string');
8 | }
9 |
10 | export const applyPatch = (data, path, value): void => {
11 | if (path.length === 1) {
12 | data[path[0]] = value;
13 | return;
14 | }
15 | if (!data[path[0]]) {
16 | data[path[0]] = {};
17 | }
18 | // eslint-disable-next-line consistent-return
19 | return applyPatch(data[path[0]], path.slice(1), value);
20 | };
21 |
22 | export function getSliderDefaultForEntity(entity: string): SliderConfig {
23 | const domain = computeDomain(entity) || Domain.LIGHT;
24 | const cfg = SliderConfigDefaultDomain.get(domain) || SliderConfigDefault;
25 | return copy(cfg);
26 | }
27 |
28 | export function getLightColorBasedOnTemperature(current: number, min: number, max: number): string {
29 | const high = new TinyColor('rgb(255, 160, 0)'); // orange-ish
30 | const low = new TinyColor('rgb(166, 209, 255)'); // blue-ish
31 | const middle = new TinyColor('white');
32 | const mixAmount = ((current - min) / (max - min)) * 100;
33 | if (mixAmount < 50) {
34 | return tinycolor(low)
35 | .mix(middle, mixAmount * 2)
36 | .toRgbString();
37 | } else {
38 | return tinycolor(middle)
39 | .mix(high, (mixAmount - 50) * 2)
40 | .toRgbString();
41 | }
42 | }
43 | export function toPercentage(value: number, min: number, max: number): number {
44 | return (((value - min) / max) * 100); //.toFixed(2);
45 | }
46 |
47 | export function percentageToValue(percent: number, min: number, max: number): number {
48 | return Math.floor(
49 | (percent * (max - min) / 100 + min)
50 | )
51 | }
52 |
53 | export const normalize = (value: number, min: number, max: number): number => {
54 | if (isNaN(value) || isNaN(min) || isNaN(max)) {
55 | // Not a number, return 0
56 | return 0;
57 | }
58 | if (value > max) return max;
59 | if (value < min) return min;
60 | return value;
61 | };
62 |
63 | export const capitalizeFirst = (s): string => (s && s[0].toUpperCase() + s.slice(1)) || "";
64 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2017",
4 | "module": "esnext",
5 | "moduleResolution": "node",
6 | "lib": [
7 | "es2017",
8 | "dom",
9 | "dom.iterable"
10 | ],
11 | "noEmit": true,
12 | "noUnusedParameters": true,
13 | "noImplicitReturns": true,
14 | "noFallthroughCasesInSwitch": true,
15 | "strict": true,
16 | "noImplicitAny": false,
17 | "skipLibCheck": true,
18 | "resolveJsonModule": true,
19 | "experimentalDecorators": true
20 | }
21 | }
--------------------------------------------------------------------------------