├── .eslintrc.js ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── build.yml │ ├── release.yml │ └── validate.yaml ├── .gitignore ├── .nvmrc ├── .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 ├── elements ├── formfield.js ├── ignore │ ├── switch.js │ └── textfield.js ├── switch.js └── textfield.js ├── hacs.json ├── package-lock.json ├── package.json ├── rollup-plugins ├── ignore.js └── ignoreWrapper.js ├── rollup.config.dev.js ├── rollup.config.js ├── src ├── action-handler-directive.ts ├── const.ts ├── controllers │ ├── automation-controller.ts │ ├── climate-controller.ts │ ├── controller.ts │ ├── cover-controller.ts │ ├── fan-controller.ts │ ├── get-controller.ts │ ├── input-boolean-controller.ts │ ├── input-number-controller.ts │ ├── light-controller.ts │ ├── lock-controller.ts │ ├── media-controller.ts │ ├── number-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 │ │ └── sk.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@v2-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 | /dist/ 2 | /node_modules/ 3 | /.rpt2_cache/ 4 | /.idea/ 5 | /.vscode/ 6 | /.devcontainer/ 7 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.17.1 2 | -------------------------------------------------------------------------------- /.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 | [![hacs_badge](https://img.shields.io/badge/HACS-default-orange.svg?style=for-the-badge)](https://github.com/custom-components/hacs) 4 | 5 | A button card with integrated slider for `automation, light, switch, fan, cover, input_boolean, input_number, media_player, number, 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 `automation, light, switch, fan, cover, input_boolean, input_number, media_player, number climate, lock` | | 75 | | name | string | **Optional** | Name | `entity.friendly_name` | 76 | | show_attribute | boolean | **Optional** | Show attribute | `false` (except for `media_player` entities) | 77 | | show_name | boolean | **Optional** | Show name | `true` | 78 | | show_state | boolean | **Optional** | Show state | `true` | 79 | | compact | boolean | **Optional** | Compact mode, display name and state inline with icon. Useful for full width cards. | `false` | 80 | | attribute | string | **Optional** | Name of the attribute to display if `show_attribute` is `true`. 81 | | icon | object | **Optional** | [Icon options](#icon-options) | | 82 | | slider | object | **Optional** | [Slider options](#slider-options) | | 83 | | action_button | object | **Optional** | [Action button options](#action-button-options) | | 84 | 85 | ### Icon Options 86 | 87 | | Name | Type | Requirement | Description | Default | 88 | | ----------------- | ------- | ------------ | ------------------------------------------- | ------------------- | 89 | | icon | string | **Optional** | Icon | `default entity icon` | 90 | | show | boolean | **Optional** | Show icon | `true` | 91 | | use_state_color | boolean | **Optional** | Use state color | `true` | 92 | | tap_action | object | **Optional** | [Action](#action-options) to take on tap | `action: more-info` | 93 | 94 | ### Slider Options 95 | 96 | | Name | Type | Requirement | Description | Default | 97 | | ----------------- | ------- | ------------ | ------------------------------------------- | ------------------- | 98 | | direction | string | **Optional** | Direction `left-right, right-left, top-bottom, bottom-top` | `left-right` | 99 | | background | string | **Optional** | Background `solid, gradient, triangle, striped, custom` | `gradient` | 100 | | use_state_color | boolean | **Optional** | Use state color | `true` | 101 | | use_percentage_bg_opacity | boolean | **Optional** | Apply opacity to background based on percentage | `true` | 102 | | show_track | boolean | **Optional** | Show track when state is on | `false` | 103 | | force_square | boolean | **Optional** | Force the button as a square | `false` | 104 | | toggle_on_click | boolean | **Optional** | Force the slider to act as a toggle, if `true` sliding is disabled | `false` | 105 | | attribute | string | **Optional** | Control an [attribute](#attributes) for `light` or `cover` entities | | 106 | | invert | boolean | **Optional** | Invert calculation of state and percentage, useful for `cover` entities | `false`
`true` for `cover` | 107 | 108 | ### Attributes 109 | Light: 110 | - `brightness_pct` **default** 111 | - `brightness` 112 | - `color_temp` 113 | - `hue` 114 | - `saturation` 115 | 116 | _Warning options other than `brightness_pct` and `brightness` may give strange results_ 117 | 118 | 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`. 119 | 120 | Cover: 121 | - `position` **default** 122 | - `tilt` 123 | ### Action button Options 124 | 125 | | Name | Type | Requirement | Description | Default | 126 | | ----------------- | ------- | ------------ | ------------------------------------------- | ------------------- | 127 | | mode | string | **Optional** | Mode `toggle, custom` | `toggle` | 128 | | show | boolean | **Optional** | Show the action button | `true` | 129 | | icon | string | **Optional** | Icon when mode is `custom` | `mdi:power` | 130 | | show_spinner | boolean | **Optional** | Show spinner when mode is `custom` | `true` | 131 | | tap_action | object | **Optional** | [Action](#action-options) to take on tap | `action: toggle` | 132 | 133 | ### Action Options 134 | 135 | | Name | Type | Requirement | Description | Default | 136 | | --------------- | ------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------- | ----------- | 137 | | action | string | **Required** | Action to perform (more-info, toggle, call-service, navigate url, none) | `more-info` | 138 | | navigation_path | string | **Optional** | Path to navigate to (e.g. /lovelace/0/) when action defined as navigate | `none` | 139 | | url | string | **Optional** | URL to open on click when action is url. The URL will open in a new tab | `none` | 140 | | service | string | **Optional** | Service to call (e.g. media_player.media_play_pause) when action defined as call-service | `none` | 141 | | service_data | object | **Optional** | Service data to include (e.g. entity_id: media_player.bedroom) when action defined as call-service | `none` | 142 | | haptic | string | **Optional** | Haptic feedback for the [Beta IOS App](http://home-assistant.io/ios/beta) _success, warning, failure, light, medium, heavy, selection_ | `none` | 143 | | repeat | number | **Optional** | How often to repeat the `hold_action` in milliseconds. | `non` | 144 | ### Styles 145 | Custom styles can be set by using [Card mod](https://github.com/thomasloven/lovelace-card-mod) 146 | ```yaml 147 | style: | 148 | :host { 149 | --VARIABLE: VALUE; 150 | } 151 | ``` 152 | 153 | | Variable | Description | Default | 154 | | ----------------------- | ------------------------------------------- | ------------------- | 155 | | `--icon-color` | Color of the icon when `icon.use_state_color === false` | `var(--paper-item-icon-color)` | 156 | | `--label-color-on` | Color of the label when state is on | `var(--primary-text-color, white)` | 157 | | `--label-color-off` | Color of the label when state is off | `var(--primary-text-color, white)` | 158 | | `--state-color-on` | Color of the state value when state is on | `var(--label-badge-text-color, white)` | 159 | | `--state-color-off` | Color of the state value when state is off | `var(--disabled-text-color)` | 160 | | `--action-icon-color-on` | Color of the action button icon when state is on | `var(--paper-item-icon-color, black)` | 161 | | `--action-icon-color-off` | Color of the action button icon when state is off | `var(--paper-item-icon-color, black)` | 162 | | `--action-spinner-color` | Color of the spinner action button | `var(--label-badge-text-color, white)` | 163 | 164 | ## Examples 165 | 166 | ### Minimal working config 167 | 168 | 169 | 170 | 172 | 173 | 174 | 176 | 191 | 192 |
Minimal working config 171 |
175 | 177 | 178 | ```yaml 179 | type: custom:slider-button-card 180 | entity: light.couch 181 | slider: 182 | direction: left-right 183 | background: gradient 184 | icon: 185 | tap_action: 186 | action: more-info 187 | action_button: 188 | mode: toggle 189 | ``` 190 |
193 | 194 | ### Per feature 195 | 196 | #### General 197 | 198 | 199 | 200 | 201 | 203 | 204 | 205 | 207 | 213 | 214 |
Compact, best used in full width (not in grid) 202 |
206 | 208 | 209 | ```yaml 210 | compact: true 211 | ``` 212 |
215 | 216 | #### Icon 217 | 218 | 219 | 220 | 221 | 223 | 224 | 225 | 227 | 235 | 236 |
Minimal config 222 |
226 | 228 | 229 | ```yaml 230 | icon: 231 | tap_action: 232 | action: more-info 233 | ``` 234 |
237 | 238 | 239 | 240 | 241 | 243 | 244 | 245 | 247 | 256 | 257 |
Icon override 242 |
246 | 248 | 249 | ```yaml 250 | icon: 251 | icon: mdi:lightbulb 252 | tap_action: 253 | action: more-info 254 | ``` 255 |
258 | 259 | 260 | #### Action button 261 | 262 | 263 | 264 | 265 | 267 | 268 | 269 | 271 | 279 | 280 |
Minimal config 266 |
270 | 272 | 273 | ```yaml 274 | action_button: 275 | mode: toggle 276 | show: true 277 | ``` 278 |
281 | 282 | 283 | 284 | 285 | 287 | 288 | 289 | 291 | 301 | 302 |
Custom 286 |
290 | 292 | 293 | ```yaml 294 | action_button: 295 | mode: custom 296 | show: true 297 | tap_action: 298 | action: toggle 299 | ``` 300 |
303 | 304 | 305 | 306 | 307 | 309 | 310 | 311 | 313 | 327 | 328 |
Custom icon and tap action 308 |
312 | 314 | 315 | ```yaml 316 | action_button: 317 | mode: custom 318 | show: true 319 | icon: mdi:palette 320 | tap_action: 321 | action: call-service 322 | service: scene.turn_on 323 | service_data: 324 | entity_id: scene.test 325 | ``` 326 |
329 | 330 | #### Slider 331 | 332 | 333 | 334 | 335 | 337 | 338 | 339 | 341 | 349 | 350 |
Minimal config 336 |
340 | 342 | 343 | ```yaml 344 | slider: 345 | direction: left-right 346 | background: gradient 347 | ``` 348 |
351 | 352 | 353 | 354 | 355 | 357 | 358 | 359 | 361 | 370 | 371 |
Background uses color or color_temp if available 356 |
360 | 362 | 363 | ```yaml 364 | slider: 365 | direction: left-right 366 | background: gradient 367 | use_state_color: true 368 | ``` 369 |
372 | 373 | 374 | 375 | 376 | 378 | 379 | 380 | 382 | 392 | 393 |
Show track, best used in full width or triangle 377 |
381 | 383 | 384 | ```yaml 385 | slider: 386 | direction: left-right 387 | background: triangle 388 | use_state_color: true 389 | show_track: true 390 | ``` 391 |
394 | 395 | 396 | 397 | 398 | 400 | 401 | 402 | 404 | 415 | 416 |
Force square 399 |
403 | 405 | 406 | ```yaml 407 | slider: 408 | direction: left-right 409 | background: triangle 410 | use_state_color: true 411 | show_track: true 412 | force_square: true 413 | ``` 414 |
417 | 418 | 419 | 420 | ### Full examples 421 | #### Fan 422 | For fan entities the icon auto rotates based on the speed of the fan. 423 | 424 | 425 | 426 | 428 | 429 | 430 | 432 | 451 | 452 |
Icon rotate animation 427 |
431 | 433 | 434 | ```yaml 435 | type: custom:slider-button-card 436 | entity: fan.living_fan 437 | slider: 438 | direction: left-right 439 | background: triangle 440 | show_track: true 441 | icon: 442 | tap_action: 443 | action: more-info 444 | action_button: 445 | tap_action: 446 | action: toggle 447 | mode: custom 448 | name: Fan 449 | ``` 450 |
453 | 454 | #### Switch 455 | Use `slider.toggle_on_click: true` so the slider acts as a toggle (sliding is disabled). 456 | 457 | 458 | 459 | 461 | 462 | 463 | 465 | 485 | 486 |
Toggle on click 460 |
464 | 466 | 467 | ```yaml 468 | type: custom:slider-button-card 469 | entity: switch.socket 470 | slider: 471 | direction: left-right 472 | background: custom 473 | toggle_on_click: true 474 | icon: 475 | use_state_color: true 476 | tap_action: 477 | action: more-info 478 | action_button: 479 | tap_action: 480 | action: toggle 481 | mode: custom 482 | name: Switch 483 | ``` 484 |
487 | 488 | #### Cover 489 | For most use cases: set `slider.direction: top-bottom` and `slider.background: striped`; 490 | 491 | 492 | 493 | 495 | 496 | 497 | 499 | 519 | 520 |
Direction top to bottom, custom action icon 494 |
498 | 500 | 501 | ```yaml 502 | type: custom:slider-button-card 503 | entity: cover.living_cover 504 | slider: 505 | direction: top-bottom 506 | background: striped 507 | icon: 508 | show: true 509 | tap_action: 510 | action: more-info 511 | action_button: 512 | tap_action: 513 | action: toggle 514 | mode: custom 515 | icon: mdi:swap-vertical 516 | name: Cover 517 | ``` 518 |
521 | 522 | #### Media player 523 | Default behavior: slider is used for volume control, when there is an entity picture it will be used instead of the icon. 524 | In this example the action button is used to toggle play/pause. 525 | 526 | 527 | 528 | 530 | 531 | 532 | 534 | 558 | 559 |
Action button to toggle play/pause 529 |
533 | 535 | 536 | ```yaml 537 | type: custom:slider-button-card 538 | entity: media_player.spotify_mha 539 | slider: 540 | direction: left-right 541 | background: triangle 542 | show_track: true 543 | icon: 544 | tap_action: 545 | action: more-info 546 | action_button: 547 | mode: custom 548 | icon: mdi:play-pause 549 | tap_action: 550 | action: call-service 551 | service: media_player.media_play_pause 552 | service_data: 553 | entity_id: media_player.spotify_mha 554 | name: Media 555 | 556 | ``` 557 |
560 | 561 | #### Climate 562 | Default behavior: slider is used to set target temperature, it doesn't alter state. 563 | 564 | 565 | 566 | 568 | 569 | 570 | 572 | 592 | 593 |
Target temperature and state disabled in card 567 |
571 | 573 | 574 | ```yaml 575 | type: custom:slider-button-card 576 | entity: climate.harmony_climate_controller 577 | slider: 578 | direction: left-right 579 | background: triangle 580 | show_track: true 581 | icon: 582 | tap_action: 583 | action: more-info 584 | action_button: 585 | mode: custom 586 | tap_action: 587 | action: toggle 588 | name: Airco 589 | 590 | ``` 591 |
594 | 595 | #### Lock 596 | Default behavior: `slider.toggle_on_click: true` 597 | 598 | 599 | 600 | 602 | 603 | 604 | 606 | 624 | 625 |
Action button hidden 601 |
605 | 607 | 608 | ```yaml 609 | type: custom:slider-button-card 610 | entity: lock.virtual_lock 611 | slider: 612 | direction: left-right 613 | background: solid 614 | toggle_on_click: true 615 | icon: 616 | use_state_color: true 617 | tap_action: 618 | action: more-info 619 | action_button: 620 | show: false 621 | name: Lock 622 | ``` 623 |
626 | 627 | #### Grid 628 | 629 | 630 | 631 | 632 | 634 | 635 | 636 | 638 | 704 | 705 |
4 columns, square: false 633 |
637 | 639 | 640 | ```yaml 641 | type: grid 642 | cards: 643 | - type: custom:slider-button-card 644 | entity: light.couch 645 | slider: 646 | direction: left-right 647 | background: gradient 648 | use_state_color: true 649 | icon: 650 | tap_action: 651 | action: more-info 652 | use_state_color: true 653 | action_button: 654 | mode: toggle 655 | - type: custom:slider-button-card 656 | entity: switch.socket 657 | slider: 658 | direction: left-right 659 | background: custom 660 | toggle_on_click: true 661 | icon: 662 | use_state_color: true 663 | tap_action: 664 | action: more-info 665 | action_button: 666 | tap_action: 667 | action: toggle 668 | mode: toggle 669 | name: Switch 670 | - type: custom:slider-button-card 671 | entity: fan.living_fan 672 | slider: 673 | direction: left-right 674 | background: triangle 675 | show_track: true 676 | icon: 677 | tap_action: 678 | action: more-info 679 | action_button: 680 | tap_action: 681 | action: toggle 682 | mode: custom 683 | name: Fan 684 | - type: custom:slider-button-card 685 | entity: cover.living_cover 686 | slider: 687 | direction: top-bottom 688 | background: striped 689 | icon: 690 | show: true 691 | tap_action: 692 | action: more-info 693 | action_button: 694 | tap_action: 695 | action: toggle 696 | mode: custom 697 | icon: mdi:swap-vertical 698 | name: Cover 699 | square: false 700 | columns: 4 701 | 702 | ``` 703 |
706 | 707 | ## Groups 708 | Mixed `group` entities are not supported, if you want to control multiple 709 | - lights use [Light group](https://www.home-assistant.io/integrations/light.group/) 710 | - covers use [Cover group](https://www.home-assistant.io/integrations/cover.group/) 711 | - media players use [Media player group](https://www.home-assistant.io/integrations/media_player.group/) 712 | 713 | ## Known issues 714 | When you discover any bugs please open an [issue](https://github.com/custom-cards/slider-button-card/issues). 715 | 716 | ### Input Number & Number entities 717 | - If the `input_number.entity.min value` is not cleanly divisible by the `input_number.entity.step value`, then the slider card is off by an amount. If your `input_number` has `min = 5`, `max = 25`, `step = 5` then it will work just fine. But if the `step` is 2, then it will be off. This also has the side effect of changing the `input_number` to an "out of bounds" value when modified via this card. Using `step = 1` avoids this problem. 718 | - The same limitation applies to `number` entities. 719 | 720 | ## Languages 721 | 722 | This card supports translations. Please, help to add more translations and improve existing ones. Here's a list of supported languages: 723 | 724 | - English 725 | - French 726 | - German 727 | - Hebrew 728 | - Korean 729 | - Nederlands (Dutch) 730 | - Polish (polski) 731 | - Portuguese 732 | - Russian 733 | - Slovak 734 | - [_Your language?_][add-translation] 735 | 736 | ## Credits 737 | - Originally inspired by [Slider entity row](https://github.com/thomasloven/lovelace-slider-entity-row) 738 | - Forked from [Slider button card](https://github.com/mattieha/slider-button-card/) by [@mattieha](https://www.github.com/mattieha) 739 | 740 | --- 741 | [![beer](https://www.buymeacoffee.com/assets/img/custom_images/black_img.png)](https://www.buymeacoffee.com/mattijsha) 742 | 743 | 744 | [hacs]: https://hacs.xyz 745 | [add-translation]: https://github.com/custom-cards/slider-button-card/blob/main/CONTRIBUTE.md#adding-a-new-translation 746 | [visual-editor]: https://raw.githubusercontent.com/custom-cards/slider-button-card/main/assets/card-editor.png 747 | [preview]: https://raw.githubusercontent.com/custom-cards/slider-button-card/main/assets/preview.gif 748 | [preview-2]: https://raw.githubusercontent.com/custom-cards/slider-button-card/main/assets/preview-2.gif 749 | [grid]: https://raw.githubusercontent.com/custom-cards/slider-button-card/main/assets/grid-not-square.png 750 | [full-width]: https://raw.githubusercontent.com/custom-cards/slider-button-card/main/assets/grid-full-width.png 751 | [latest-release]: https://github.com/custom-cards/slider-button-card/releases/latest 752 | [releases-shield]: https://img.shields.io/github/release/custom-cards/slider-button-card.svg?style=for-the-badge 753 | [releases]: https://github.com/custom-cards/slider-button-card/releases 754 | [icon-minimal]: https://raw.githubusercontent.com/custom-cards/slider-button-card/main/assets/grid-full-width.png 755 | -------------------------------------------------------------------------------- /assets/card-editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-cards/slider-button-card/f63628332e1febc28642fdbba872715bf8096bde/assets/card-editor.png -------------------------------------------------------------------------------- /assets/examples/action-custom-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-cards/slider-button-card/f63628332e1febc28642fdbba872715bf8096bde/assets/examples/action-custom-icon.png -------------------------------------------------------------------------------- /assets/examples/action-custom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-cards/slider-button-card/f63628332e1febc28642fdbba872715bf8096bde/assets/examples/action-custom.png -------------------------------------------------------------------------------- /assets/examples/action-minimal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-cards/slider-button-card/f63628332e1febc28642fdbba872715bf8096bde/assets/examples/action-minimal.png -------------------------------------------------------------------------------- /assets/examples/climate.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-cards/slider-button-card/f63628332e1febc28642fdbba872715bf8096bde/assets/examples/climate.gif -------------------------------------------------------------------------------- /assets/examples/cover.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-cards/slider-button-card/f63628332e1febc28642fdbba872715bf8096bde/assets/examples/cover.gif -------------------------------------------------------------------------------- /assets/examples/fan.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-cards/slider-button-card/f63628332e1febc28642fdbba872715bf8096bde/assets/examples/fan.gif -------------------------------------------------------------------------------- /assets/examples/general-compact.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-cards/slider-button-card/f63628332e1febc28642fdbba872715bf8096bde/assets/examples/general-compact.png -------------------------------------------------------------------------------- /assets/examples/general-minimal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-cards/slider-button-card/f63628332e1febc28642fdbba872715bf8096bde/assets/examples/general-minimal.png -------------------------------------------------------------------------------- /assets/examples/icon-icon-override.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-cards/slider-button-card/f63628332e1febc28642fdbba872715bf8096bde/assets/examples/icon-icon-override.png -------------------------------------------------------------------------------- /assets/examples/icon-minimal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-cards/slider-button-card/f63628332e1febc28642fdbba872715bf8096bde/assets/examples/icon-minimal.png -------------------------------------------------------------------------------- /assets/examples/lock.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-cards/slider-button-card/f63628332e1febc28642fdbba872715bf8096bde/assets/examples/lock.gif -------------------------------------------------------------------------------- /assets/examples/media.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-cards/slider-button-card/f63628332e1febc28642fdbba872715bf8096bde/assets/examples/media.gif -------------------------------------------------------------------------------- /assets/examples/slider-force-square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-cards/slider-button-card/f63628332e1febc28642fdbba872715bf8096bde/assets/examples/slider-force-square.png -------------------------------------------------------------------------------- /assets/examples/slider-minimal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-cards/slider-button-card/f63628332e1febc28642fdbba872715bf8096bde/assets/examples/slider-minimal.png -------------------------------------------------------------------------------- /assets/examples/slider-show-track.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-cards/slider-button-card/f63628332e1febc28642fdbba872715bf8096bde/assets/examples/slider-show-track.png -------------------------------------------------------------------------------- /assets/examples/slider-state-color.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-cards/slider-button-card/f63628332e1febc28642fdbba872715bf8096bde/assets/examples/slider-state-color.png -------------------------------------------------------------------------------- /assets/examples/switch.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-cards/slider-button-card/f63628332e1febc28642fdbba872715bf8096bde/assets/examples/switch.gif -------------------------------------------------------------------------------- /assets/grid-full-width.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-cards/slider-button-card/f63628332e1febc28642fdbba872715bf8096bde/assets/grid-full-width.png -------------------------------------------------------------------------------- /assets/grid-not-square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-cards/slider-button-card/f63628332e1febc28642fdbba872715bf8096bde/assets/grid-not-square.png -------------------------------------------------------------------------------- /assets/preview-2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-cards/slider-button-card/f63628332e1febc28642fdbba872715bf8096bde/assets/preview-2.gif -------------------------------------------------------------------------------- /assets/preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-cards/slider-button-card/f63628332e1febc28642fdbba872715bf8096bde/assets/preview.gif -------------------------------------------------------------------------------- /elements/formfield.js: -------------------------------------------------------------------------------- 1 | import { FormfieldBase } from '@material/mwc-formfield/mwc-formfield-base.js'; 2 | import { styles as formfieldStyles } from '@material/mwc-formfield/mwc-formfield.css.js'; 3 | 4 | export const formfieldDefinition = { 5 | 'mwc-formfield': class extends FormfieldBase { 6 | static get styles() { 7 | return formfieldStyles; 8 | } 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /elements/ignore/switch.js: -------------------------------------------------------------------------------- 1 | export const ignoreSwitchFiles = ['@material/mwc-ripple/mwc-ripple.js']; 2 | -------------------------------------------------------------------------------- /elements/ignore/textfield.js: -------------------------------------------------------------------------------- 1 | export const ignoreTextfieldFiles = ['@material/mwc-notched-outline/mwc-notched-outline.js']; 2 | -------------------------------------------------------------------------------- /elements/switch.js: -------------------------------------------------------------------------------- 1 | import { SwitchBase } from '@material/mwc-switch/deprecated/mwc-switch-base.js'; 2 | import { RippleBase } from '@material/mwc-ripple/mwc-ripple-base.js'; 3 | import { styles as switchStyles } from '@material/mwc-switch/deprecated/mwc-switch.css'; 4 | import { styles as rippleStyles } from '@material/mwc-ripple/mwc-ripple.css'; 5 | 6 | export const switchDefinition = { 7 | 'mwc-switch': class extends SwitchBase { 8 | static get styles() { 9 | return switchStyles; 10 | } 11 | }, 12 | 'mwc-ripple': class extends RippleBase { 13 | static get styles() { 14 | return rippleStyles; 15 | } 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /elements/textfield.js: -------------------------------------------------------------------------------- 1 | import { TextFieldBase } from '@material/mwc-textfield/mwc-textfield-base.js'; 2 | import { NotchedOutlineBase } from '@material/mwc-notched-outline/mwc-notched-outline-base.js'; 3 | 4 | import { styles as textfieldStyles } from '@material/mwc-textfield/mwc-textfield.css'; 5 | import { styles as notchedOutlineStyles } from '@material/mwc-notched-outline/mwc-notched-outline.css'; 6 | 7 | export const textfieldDefinition = { 8 | 'mwc-textfield': class extends TextFieldBase { 9 | static get styles() { 10 | return textfieldStyles; 11 | } 12 | }, 13 | 'mwc-notched-outline': class extends NotchedOutlineBase { 14 | static get styles() { 15 | return notchedOutlineStyles; 16 | } 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Slider Button Card", 3 | "render_readme": true, 4 | "filename": "slider-button-card.js" 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slider-button-card", 3 | "version": "1.13.0", 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/custom-cards/slider-button-card" 16 | }, 17 | "author": "M Hoog Antink", 18 | "license": "MIT", 19 | "dependencies": { 20 | "@ctrl/tinycolor": "^3.4.0", 21 | "@lit-labs/scoped-registry-mixin": "^1.0.1", 22 | "@material/mwc-formfield": "^0.27.0", 23 | "@material/mwc-list": "^0.27.0", 24 | "@material/mwc-notched-outline": "^0.27.0", 25 | "@material/mwc-switch": "^0.27.0", 26 | "@material/mwc-textarea": "^0.27.0", 27 | "@material/mwc-textfield": "^0.27.0", 28 | "custom-card-helpers": "^1.6.6", 29 | "fast-copy": "^2.1.1", 30 | "home-assistant-js-websocket": "^4.5.0", 31 | "lit": "^2.8.0" 32 | }, 33 | "devDependencies": { 34 | "@babel/core": "^7.12.3", 35 | "@babel/plugin-proposal-class-properties": "^7.12.1", 36 | "@babel/plugin-proposal-decorators": "^7.12.1", 37 | "@rollup/plugin-json": "^4.1.0", 38 | "@typescript-eslint/eslint-plugin": "^2.34.0", 39 | "@typescript-eslint/parser": "^2.34.0", 40 | "eslint": "^6.8.0", 41 | "eslint-config-airbnb-base": "^14.2.1", 42 | "eslint-config-prettier": "^6.15.0", 43 | "eslint-plugin-import": "^2.22.1", 44 | "eslint-plugin-prettier": "^3.1.4", 45 | "prettier": "^1.19.1", 46 | "rollup": "^1.32.1", 47 | "rollup-plugin-babel": "^4.4.0", 48 | "rollup-plugin-commonjs": "^10.1.0", 49 | "rollup-plugin-node-resolve": "^5.2.0", 50 | "rollup-plugin-serve": "^1.1.0", 51 | "rollup-plugin-terser": "^5.3.1", 52 | "rollup-plugin-typescript2": "^0.24.3", 53 | "typescript": "^3.9.7" 54 | }, 55 | "scripts": { 56 | "start": "rollup -c rollup.config.dev.js --watch", 57 | "build": "npm run lint && npm run rollup", 58 | "lint": "eslint src/*.ts", 59 | "rollup": "rollup -c" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /rollup-plugins/ignore.js: -------------------------------------------------------------------------------- 1 | export default function (userOptions = {}) { 2 | // Files need to be absolute paths. 3 | // This only works if the file has no exports 4 | // and only is imported for its side effects 5 | const files = userOptions.files || []; 6 | 7 | if (files.length === 0) { 8 | return { 9 | name: 'ignore', 10 | }; 11 | } 12 | 13 | return { 14 | name: 'ignore', 15 | 16 | load(id) { 17 | return files.some((toIgnorePath) => id.startsWith(toIgnorePath)) 18 | ? { 19 | code: '', 20 | } 21 | : null; 22 | }, 23 | }; 24 | } -------------------------------------------------------------------------------- /rollup-plugins/ignoreWrapper.js: -------------------------------------------------------------------------------- 1 | import ignore from "./ignore"; 2 | import { ignoreSwitchFiles } from '../elements/ignore/switch'; 3 | import { ignoreTextfieldFiles } from '../elements/ignore/textfield' 4 | 5 | export default function ignoreWrapper() { 6 | return ignore({ 7 | files: [ 8 | ...ignoreSwitchFiles, 9 | ...ignoreTextfieldFiles, 10 | ].map((file) => require.resolve(file)), 11 | }) 12 | } -------------------------------------------------------------------------------- /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 ignoreWrapper from './rollup-plugins/ignoreWrapper'; 7 | import json from '@rollup/plugin-json'; 8 | 9 | export default { 10 | input: ["src/slider-button-card.ts"], 11 | output: { 12 | dir: "./dist", 13 | format: "es", 14 | }, 15 | plugins: [ 16 | resolve(), 17 | typescript(), 18 | json(), 19 | babel({ 20 | exclude: "node_modules/**", 21 | }), 22 | terser(), 23 | serve({ 24 | contentBase: "./dist", 25 | host: "0.0.0.0", 26 | port: 5000, 27 | allowCrossOrigin: true, 28 | headers: { 29 | "Access-Control-Allow-Origin": "*", 30 | }, 31 | }), 32 | ignoreWrapper() 33 | ], 34 | }; 35 | -------------------------------------------------------------------------------- /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 ignoreWrapper from './rollup-plugins/ignoreWrapper'; 7 | import serve from 'rollup-plugin-serve'; 8 | import json from '@rollup/plugin-json'; 9 | 10 | const dev = process.env.ROLLUP_WATCH; 11 | 12 | const serveopts = { 13 | contentBase: ['./dist'], 14 | host: '0.0.0.0', 15 | port: 5000, 16 | allowCrossOrigin: true, 17 | headers: { 18 | 'Access-Control-Allow-Origin': '*', 19 | }, 20 | }; 21 | 22 | const plugins = [ 23 | nodeResolve({}), 24 | commonjs(), 25 | typescript(), 26 | json(), 27 | babel({ 28 | exclude: 'node_modules/**', 29 | }), 30 | dev && serve(serveopts), 31 | !dev && terser(), 32 | ignoreWrapper() 33 | ]; 34 | 35 | export default [ 36 | { 37 | input: 'src/slider-button-card.ts', 38 | output: { 39 | dir: 'dist', 40 | format: 'es', 41 | }, 42 | plugins: [...plugins], 43 | }, 44 | ]; 45 | -------------------------------------------------------------------------------- /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/automation-controller.ts: -------------------------------------------------------------------------------- 1 | import { STATES_OFF } from 'custom-card-helpers'; 2 | import { Controller } from './controller'; 3 | 4 | export class AutomationController 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('automation', 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.automation.state._.on'); 31 | } 32 | return this._hass.localize('component.automation.state._.off'); 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /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 attributeLabel(): string { 95 | if (this._config.attribute) { 96 | return this.stateObj.attributes[this._config.attribute]; 97 | } 98 | return ''; 99 | } 100 | 101 | get hidden(): boolean { 102 | return false; 103 | } 104 | 105 | get hasSlider(): boolean { 106 | return true; 107 | } 108 | 109 | get hasToggle(): boolean { 110 | return this._config.slider?.toggle_on_click ?? false; 111 | } 112 | 113 | get toggleValue(): number { 114 | return this.value === this.min ? this.max : this.min; 115 | } 116 | 117 | get state(): string { 118 | return this.stateObj?.state; 119 | } 120 | 121 | get isOff(): boolean { 122 | return this.percentage === 0; 123 | } 124 | 125 | get isUnavailable(): boolean { 126 | return this.state ? this.state === 'unavailable' : true; 127 | } 128 | 129 | get isSliderDisabled(): boolean { 130 | return this.isUnavailable ? this.isUnavailable : this.hasToggle; 131 | } 132 | 133 | get min(): number { 134 | return this._config.slider?.min ?? this._min ?? 0; 135 | } 136 | 137 | get max(): number { 138 | return this._config.slider?.max ?? this._max ?? 100; 139 | } 140 | 141 | get step(): number { 142 | return this._config.slider?.step ?? this._step ?? 5; 143 | } 144 | 145 | get invert(): boolean { 146 | return this._config.slider?.invert ?? this._invert ?? false; 147 | } 148 | 149 | get isValuePercentage(): boolean { 150 | return true; 151 | } 152 | 153 | get percentage(): number { 154 | return Math.round( 155 | ((this.targetValue - (this.invert ? this.max : this.min)) * 100) / (this.max - this.min) * (this.invert ? -1 : 1) 156 | ); 157 | } 158 | 159 | get valueFromPercentage(): number { 160 | return percentageToValue(this.percentage, this.min, this.max); 161 | } 162 | 163 | get allowedAttributes(): string[] { 164 | return []; 165 | } 166 | 167 | get style(): Style { 168 | return { 169 | icon: { 170 | filter: this.iconFilter, 171 | color: this.iconColor, 172 | rotateSpeed: this.iconRotateSpeed, 173 | }, 174 | slider: { 175 | filter: this.sliderFilter, 176 | color: this.sliderColor, 177 | }, 178 | }; 179 | } 180 | 181 | get iconFilter(): string { 182 | if (!this._config.icon?.use_state_color || this.percentage === 0) { 183 | return 'brightness(100%)'; 184 | } 185 | return `brightness(${(this.percentage + 100) / 2}%)`; 186 | } 187 | 188 | get iconColor(): string { 189 | if (this._config.icon?.use_state_color) { 190 | if (this.stateObj.attributes.hs_color) { 191 | const [hue, sat] = this.stateObj.attributes.hs_color; 192 | if (sat > 10) { 193 | return `hsl(${hue}, 100%, ${100 - sat / 2}%)`; 194 | } 195 | } else if (this.percentage > 0) { 196 | return 'var(--paper-item-icon-active-color, #fdd835)' 197 | } else { 198 | return 'var(--paper-item-icon-color, #44739e)' 199 | } 200 | } 201 | return ''; 202 | } 203 | 204 | get iconRotateSpeed(): string { 205 | return '0s'; 206 | } 207 | 208 | get sliderFilter(): string { 209 | if (!this._config.slider?.use_percentage_bg_opacity || this.percentage === 0 || this._config.slider.background === SliderBackground.GRADIENT) { 210 | return 'brightness(100%)'; 211 | } 212 | return `brightness(${(this.percentage + 100) / 2}%)`; 213 | } 214 | 215 | get sliderColor(): string { 216 | if (this._config.slider?.use_state_color) { 217 | if (this.stateObj.attributes.hs_color) { 218 | const [hue, sat] = this.stateObj.attributes.hs_color; 219 | if (sat > 10) { 220 | const color = `hsl(${hue}, 100%, ${100 - sat / 2}%)`; 221 | this._sliderPrevColor = color; 222 | return color; 223 | } 224 | } else if ( 225 | this.stateObj.attributes.color_temp && 226 | this.stateObj.attributes.min_mireds && 227 | this.stateObj.attributes.max_mireds 228 | ) { 229 | const color = getLightColorBasedOnTemperature( 230 | this.stateObj.attributes.color_temp, 231 | this.stateObj.attributes.min_mireds, 232 | this.stateObj.attributes.max_mireds, 233 | ); 234 | this._sliderPrevColor = color; 235 | return color; 236 | } else if (this._sliderPrevColor.startsWith('hsl') || this._sliderPrevColor.startsWith('rgb')) { 237 | return this._sliderPrevColor; 238 | } 239 | } 240 | return 'inherit'; 241 | } 242 | 243 | moveSlider(event: any, {left, top, width, height}): number { 244 | let percentage = this.calcMovementPercentage(event, {left, top, width, height}); 245 | percentage = this.applyStep(percentage); 246 | percentage = normalize(percentage, 0, 100); 247 | if (!this.isValuePercentage) { 248 | percentage = percentageToValue(percentage, this.min, this.max); 249 | } 250 | return percentage; 251 | } 252 | 253 | calcMovementPercentage(event: any, {left, top, width, height}): number { 254 | let percentage; 255 | switch(this._config.slider?.direction) { 256 | case SliderDirections.LEFT_RIGHT: 257 | percentage = toPercentage( 258 | event.clientX, 259 | left, 260 | width 261 | ); 262 | if (this.invert) { 263 | percentage = 100 - percentage; 264 | } 265 | break 266 | case SliderDirections.RIGHT_LEFT: 267 | percentage = toPercentage( 268 | event.clientX, 269 | left, 270 | width 271 | ); 272 | if (!this.invert) { 273 | percentage = 100 - percentage; 274 | } 275 | break 276 | case SliderDirections.TOP_BOTTOM: 277 | percentage = toPercentage( 278 | event.clientY, 279 | top, 280 | height 281 | ); 282 | if (this.invert) { 283 | percentage = 100 - percentage; 284 | } 285 | break 286 | case SliderDirections.BOTTOM_TOP: 287 | percentage = toPercentage( 288 | event.clientY, 289 | top, 290 | height 291 | ); 292 | if (!this.invert) { 293 | percentage = 100 - percentage; 294 | } 295 | break 296 | 297 | } 298 | return percentage; 299 | } 300 | 301 | applyStep(value: number): number { 302 | return Math.round(value / this.step) * this.step; 303 | } 304 | 305 | log(name = '', value: string | number | object = ''): void { 306 | if (this._config.debug) { 307 | console.log(`${this._config.entity}: ${name}`, value) 308 | } 309 | } 310 | } 311 | -------------------------------------------------------------------------------- /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.entity_component._.state.${this.state}`); 76 | const closedLabel = this._hass.localize('component.cover.entity_component._.state.closed'); 77 | const openLabel = this._hass.localize('component.cover.entity_component._.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.hasSlider ? this.stateObj.attributes.percentage_step : 1; 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.entity_component._.state.on'); 41 | } 42 | } 43 | return this._hass.localize('component.fan.entity_component._.state.off'); 44 | } 45 | 46 | get hasSlider(): boolean { 47 | return 'percentage' 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.hasSlider) { 57 | if (this.percentage > 0) { 58 | speed = 3 - ((this.percentage / 100) * 2); 59 | } 60 | } else { 61 | speed = this._value 62 | } 63 | 64 | return `${speed}s` 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /src/controllers/get-controller.ts: -------------------------------------------------------------------------------- 1 | import { computeDomain } from 'custom-card-helpers'; 2 | import { Domain, SliderButtonCardConfig } from '../types'; 3 | import { AutomationController } from './automation-controller'; 4 | import { ClimateController } from './climate-controller'; 5 | import { Controller } from './controller'; 6 | import { CoverController } from './cover-controller'; 7 | import { FanController } from './fan-controller'; 8 | import { InputBooleanController } from './input-boolean-controller'; 9 | import { InputNumberController } from './input-number-controller'; 10 | import { LightController } from './light-controller'; 11 | import { LockController } from './lock-controller'; 12 | import { MediaController } from './media-controller'; 13 | import { SwitchController } from './switch-controller'; 14 | import { NumberController } from './number-controller'; 15 | 16 | export class ControllerFactory { 17 | static getInstance(config: SliderButtonCardConfig): Controller { 18 | const domain = computeDomain(config.entity); 19 | const mapping = { 20 | [Domain.LIGHT]: LightController, 21 | [Domain.FAN]: FanController, 22 | [Domain.SWITCH]: SwitchController, 23 | [Domain.AUTOMATION]: AutomationController, 24 | [Domain.COVER]: CoverController, 25 | [Domain.INPUT_BOOLEAN]: InputBooleanController, 26 | [Domain.INPUT_NUMBER]: InputNumberController, 27 | [Domain.MEDIA_PLAYER]: MediaController, 28 | [Domain.NUMBER]: NumberController, 29 | [Domain.CLIMATE]: ClimateController, 30 | [Domain.LOCK]: LockController, 31 | }; 32 | if (typeof mapping[domain] === 'undefined') { 33 | throw new Error(`Unsupported entity type: ${domain}`) 34 | } 35 | return new mapping[domain](config); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /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.entity_component._.state.on'); 31 | } 32 | return this._hass.localize('component.input_boolean.entity_component._.state.off'); 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/controllers/input-number-controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from './controller'; 2 | 3 | export class InputNumberController extends Controller { 4 | _targetValue; 5 | _invert = false; 6 | 7 | get _value(): number { 8 | return this.stateObj.state; 9 | } 10 | 11 | set _value(value) { 12 | this._hass.callService('input_number', 'set_value', { 13 | // eslint-disable-next-line @typescript-eslint/camelcase 14 | entity_id: this.stateObj.entity_id, 15 | value: value, 16 | }); 17 | } 18 | 19 | get _min(): number { 20 | return this.stateObj.attributes.min; 21 | } 22 | 23 | get _max(): number { 24 | return this.stateObj.attributes.max; 25 | } 26 | 27 | get isValuePercentage(): boolean { 28 | return false; 29 | } 30 | 31 | get _step(): number { 32 | return this.stateObj.attributes.step; 33 | } 34 | 35 | get label(): string { 36 | return this.stateObj.attributes.unit_of_measurement ? `${this.targetValue} ${this.stateObj.attributes.unit_of_measurement}` : `${this.targetValue}`; 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /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.entity_component._.state.off'); 185 | } 186 | if (this.colorMode === LightColorModes.ON_OFF) { 187 | return this._hass.localize('component.light.entity_component._.state.on'); 188 | } 189 | switch(this.attribute) { 190 | case LightAttributes.ON_OFF: 191 | return this._hass.localize('component.light.entity_component._.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.entity_component._.state.unlocked'); 31 | } 32 | return this._hass.localize('component.lock.entity_component._.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/number-controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from './controller'; 2 | 3 | export class NumberController extends Controller { 4 | _targetValue; 5 | _invert = false; 6 | 7 | get _value(): number { 8 | return this.stateObj.state; 9 | } 10 | 11 | set _value(value) { 12 | this._hass.callService('number', 'set_value', { 13 | // eslint-disable-next-line @typescript-eslint/camelcase 14 | entity_id: this.stateObj.entity_id, 15 | value: value, 16 | }); 17 | } 18 | 19 | get _min(): number { 20 | return this.stateObj.attributes.min; 21 | } 22 | 23 | get _max(): number { 24 | return this.stateObj.attributes.max; 25 | } 26 | 27 | get isValuePercentage(): boolean { 28 | return false; 29 | } 30 | 31 | get _step(): number { 32 | return this.stateObj.attributes.step; 33 | } 34 | 35 | get label(): string { 36 | return this.stateObj.attributes.unit_of_measurement ? `${this.targetValue} ${this.stateObj.attributes.unit_of_measurement}` : `${this.targetValue}`; 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /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.entity_component._.state.on'); 31 | } 32 | return this._hass.localize('component.switch.entity_component._.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 | CSSResult, 6 | LitElement, 7 | TemplateResult, 8 | css, 9 | html 10 | } from 'lit'; 11 | import { customElement, property, state } from 'lit/decorators.js'; 12 | 13 | import { formfieldDefinition } from '../elements/formfield'; 14 | import { switchDefinition } from '../elements/switch'; 15 | import { textfieldDefinition } from '../elements/textfield'; 16 | 17 | import { ScopedRegistryHost } from '@lit-labs/scoped-registry-mixin'; 18 | import { ActionConfig, HomeAssistant, LovelaceCardEditor, computeDomain, fireEvent } from 'custom-card-helpers'; 19 | import { localize } from './localize/localize'; 20 | import { ActionButtonConfig, ActionButtonConfigDefault, ActionButtonMode, Domain, IconConfig, IconConfigDefault, SliderBackground, SliderButtonCardConfig, SliderConfig, SliderConfigDefault, SliderDirections } from './types'; 21 | import { applyPatch, getEnumValues, getSliderDefaultForEntity } from './utils'; 22 | 23 | @customElement('slider-button-card-editor') 24 | export class SliderButtonCardEditor extends ScopedRegistryHost(LitElement) implements LovelaceCardEditor { 25 | @property({ attribute: false }) public hass?: HomeAssistant; 26 | 27 | @state() private _config?: SliderButtonCardConfig; 28 | @state() private _helpers?: any; 29 | 30 | private _initialized = false; 31 | private directions = getEnumValues(SliderDirections); 32 | private backgrounds = getEnumValues(SliderBackground); 33 | private actionModes = getEnumValues(ActionButtonMode); 34 | 35 | static elementDefinitions = { 36 | ...formfieldDefinition, 37 | ...switchDefinition, 38 | ...textfieldDefinition, 39 | } 40 | 41 | firstUpdated(): void { 42 | // Use HA elements when using ScopedRegistry. Reference: https://gist.github.com/thomasloven/5f965bd26e5f69876890886c09dd9ba8 43 | this._loadHomeAssistantComponent("ha-entity-picker", { type: "entities", entities: [] }); 44 | this._loadHomeAssistantComponent("ha-icon-picker", { type: "entities", entities: [] }); 45 | this._loadHomeAssistantComponent("ha-selector", { type: "entities", entities: [] }); 46 | } 47 | 48 | async _loadHomeAssistantComponent(component: string, card: {}): Promise { 49 | const registry = (this.shadowRoot as any)?.customElements; 50 | if (!registry || registry.get(component)) { 51 | return; 52 | } 53 | 54 | const ch = await (window as any).loadCardHelpers(); 55 | const c = await ch.createCardElement(card); 56 | await c.constructor.getConfigElement(); 57 | 58 | registry.define(component, window.customElements.get(component)); 59 | } 60 | 61 | public async setConfig(config: SliderButtonCardConfig): Promise { 62 | this._config = config; 63 | } 64 | 65 | protected shouldUpdate(): boolean { 66 | if (!this._initialized) { 67 | this._initialize(); 68 | } 69 | 70 | return true; 71 | } 72 | 73 | get _name(): string { 74 | return this._config?.name || ''; 75 | } 76 | 77 | get _show_name(): boolean { 78 | return typeof this._config?.show_name === 'undefined' ? true : this._config?.show_name; 79 | } 80 | 81 | get _show_state(): boolean { 82 | return typeof this._config?.show_state === 'undefined' ? true : this._config?.show_state; 83 | } 84 | 85 | get _show_attribute(): boolean { 86 | return typeof this._config?.show_attribute === 'undefined' ? true : this._config?.show_attribute; 87 | } 88 | 89 | get _compact(): boolean { 90 | return typeof this._config?.compact !== 'boolean' ? false : this._config?.compact; 91 | } 92 | 93 | get _entity(): string { 94 | return this._config?.entity || ''; 95 | } 96 | 97 | get _attribute(): string { 98 | return this._config?.attribute || ''; 99 | } 100 | 101 | get _icon(): IconConfig { 102 | return this._config?.icon || IconConfigDefault; 103 | } 104 | 105 | get _slider(): SliderConfig { 106 | return this._config?.slider || SliderConfigDefault; 107 | } 108 | 109 | get _action_button(): ActionButtonConfig { 110 | return this._config?.action_button || ActionButtonConfigDefault; 111 | } 112 | 113 | get _entityAttributes(): string[] { 114 | if (!this.hass || !this._entity) { 115 | return []; 116 | } 117 | return Object.keys(this.hass.states[this._entity].attributes).sort(); 118 | } 119 | 120 | protected _renderOptionSelector( 121 | configValue: string, 122 | options: string[] | { value: string; label: string }[] = [], 123 | label: string, 124 | value: string | ActionConfig | undefined, 125 | 126 | ): TemplateResult | void { 127 | if (!this._config) { 128 | return; 129 | } 130 | 131 | return html` 132 | 146 | 147 | `; 148 | } 149 | 150 | protected render(): TemplateResult | void { 151 | if (!this.hass) { 152 | return html``; 153 | } 154 | 155 | return html` 156 |
157 |
158 |
159 | 160 | 161 |
162 | 169 | 170 | 177 | ${this._renderOptionSelector(`attribute`, this._entityAttributes, localize('tabs.general.attribute'), this._attribute)} 178 |
179 | 180 | 185 | 186 | 187 | 192 | 193 | 194 | 199 | 200 | 201 | 206 | 207 |
208 |
209 |
210 | 211 |
212 | 213 | 214 |
215 | 224 |
225 | 226 | 231 | 232 | ${this.renderStateColor('icon')} 233 |
234 | 245 |
246 |
247 | 248 |
249 | 250 | 251 |
252 |
253 | ${this._renderOptionSelector( 254 | `slider.direction`, 255 | this.directions.map(direction => { 256 | return {'value': direction, 'label': localize(`direction.${direction}`)} 257 | }), localize('tabs.slider.direction'), 258 | this._slider.direction || '' 259 | )} 260 | ${this._renderOptionSelector( 261 | `slider.background`, 262 | this.backgrounds.map(background => { 263 | return {'value': background, 'label': localize(`background.${background}`)} 264 | }), localize('tabs.slider.background'), 265 | this._slider.background || '' 266 | )} 267 |
268 |
269 | ${this.renderBrightness('slider')} 270 | ${this.renderStateColor('slider')} 271 | 272 | 277 | 278 | 279 | 284 | 285 | 286 | 291 | 292 |
293 |
294 |
295 | 296 |
297 | 298 | 299 |
300 | ${this._renderOptionSelector( 301 | `action_button.mode`, 302 | this.actionModes.map(mode => { 303 | return {'value': mode, 'label': localize(`mode.${mode}`)} 304 | }), localize('tabs.action_button.mode'), 305 | this._action_button.mode || '' 306 | )} 307 | ${this._action_button.mode === ActionButtonMode.CUSTOM 308 | ? html` 309 | 317 | 318 | ` 319 | : 320 | ''} 321 |
322 | 323 | 328 | 329 | ${this._action_button.mode === ActionButtonMode.CUSTOM 330 | ? html` 331 | 332 | 337 | 338 | ` 339 | : 340 | ''} 341 |
342 | ${this._action_button.mode === ActionButtonMode.CUSTOM 343 | ? html` 344 | 355 | ` 356 | : 357 | ''} 358 |
359 |
360 |
361 |
362 | `; 363 | } 364 | 365 | protected renderBrightness(path: string): TemplateResult | void { 366 | const item = this[`_${path}`]; 367 | return html` 368 | 369 | 374 | 375 | `; 376 | } 377 | 378 | protected renderStateColor(path: string): TemplateResult | void { 379 | const item = this[`_${path}`]; 380 | return html` 381 | 382 | 387 | 388 | `; 389 | } 390 | 391 | private _initialize(): void { 392 | if (this.hass === undefined) return; 393 | if (this._config === undefined) return; 394 | this._initialized = true; 395 | } 396 | 397 | private _valueChangedSelect(ev): void { 398 | const target = ev.target; 399 | const value = ev.detail.value; 400 | if (!value) { 401 | return; 402 | } 403 | this._changeValue(target.configValue, value); 404 | } 405 | 406 | private _valueChangedEntity(ev): void { 407 | const target = ev.target; 408 | const value = ev.target?.value; 409 | if (!value) { 410 | return; 411 | } 412 | const updateDefaults = computeDomain(value) !== computeDomain(this._config?.entity || 'light.dummy'); 413 | this._changeValue(target.configValue, value); 414 | this._changeValue('name', ''); 415 | this._changeValue('attribute', ''); 416 | this._changeValue('icon.icon', ''); 417 | if (updateDefaults) { 418 | const cfg = copy(this._config); 419 | applyPatch(cfg, ['slider'], getSliderDefaultForEntity(value)); 420 | this._config = cfg; 421 | fireEvent(this, 'config-changed', { config: this._config }); 422 | } 423 | } 424 | 425 | private _valueChanged(ev): void { 426 | const target = ev.target; 427 | const value = ev.target?.value; 428 | this._changeValue(target.configValue, target.checked !== undefined ? target.checked : value); 429 | } 430 | 431 | private _changeValue(configValue: string, value: string | boolean | number): void { 432 | if (!this._config || !this.hass) { 433 | return; 434 | } 435 | if (this[`_${configValue}`] !== undefined && this[`_${configValue}`] === value) { 436 | return; 437 | } 438 | if (configValue) { 439 | const cfg = copy(this._config); 440 | applyPatch(cfg, [...configValue.split('.')], value); 441 | this._config = cfg; 442 | if (value === '') { 443 | delete this._config[configValue]; 444 | } 445 | } 446 | fireEvent(this, 'config-changed', { config: this._config }); 447 | } 448 | 449 | static get styles(): CSSResult { 450 | return css` 451 | mwc-textfield { 452 | width: 100%; 453 | } 454 | mwc-switch { 455 | padding: 16px 6px; 456 | } 457 | .side-by-side { 458 | display: flex; 459 | flex-flow: row wrap; 460 | } 461 | .side-by-side > * { 462 | padding-right: 8px; 463 | width: 50%; 464 | flex-flow: column wrap; 465 | box-sizing: border-box; 466 | } 467 | .side-by-side > *:last-child { 468 | flex: 1; 469 | padding-right: 0; 470 | } 471 | .suffix { 472 | margin: 0 8px; 473 | } 474 | .group { 475 | padding: 15px; 476 | border: 1px solid var(--primary-text-color) 477 | } 478 | .tabs { 479 | overflow: hidden; 480 | } 481 | .tab { 482 | width: 100%; 483 | color: var(--primary-text-color); 484 | overflow: hidden; 485 | } 486 | .tab-label { 487 | display: flex; 488 | justify-content: space-between; 489 | padding: 1em 1em 1em 0em; 490 | border-bottom: 1px solid var(--secondary-text-color); 491 | font-weight: bold; 492 | cursor: pointer; 493 | } 494 | .tab-label:hover { 495 | /*background: #1a252f;*/ 496 | } 497 | .tab-label::after { 498 | content: "❯"; 499 | width: 1em; 500 | height: 1em; 501 | text-align: center; 502 | transition: all 0.35s; 503 | } 504 | .tab-content { 505 | max-height: 0; 506 | padding: 0 1em; 507 | background: var(--secondary-background-color); 508 | transition: all 0.35s; 509 | } 510 | input.tab-checkbox { 511 | position: absolute; 512 | opacity: 0; 513 | z-index: -1; 514 | } 515 | input.tab-checkbox:checked + .tab-label { 516 | border-color: var(--accent-color); 517 | } 518 | input.tab-checkbox:checked + .tab-label::after { 519 | transform: rotate(90deg); 520 | } 521 | input.tab-checkbox:checked ~ .tab-content { 522 | max-height: 100vh; 523 | padding: 1em; 524 | } 525 | `; 526 | } 527 | } 528 | -------------------------------------------------------------------------------- /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 | "attribute": "Attribute (Optional)", 13 | "name": "Name (Optional)", 14 | "show_name": "Show name?", 15 | "show_state": "Show state?", 16 | "show_attribute": "Show attribute?", 17 | "compact": "Compact?" 18 | }, 19 | "icon": { 20 | "title": "Icon", 21 | "icon": "Icon (Optional)", 22 | "show_icon": "Show icon?", 23 | "use_state_color": "Use state color?", 24 | "tap_action": "Tap action" 25 | }, 26 | "slider": { 27 | "title": "Slider", 28 | "direction": "Direction", 29 | "background": "Background", 30 | "use_brightness": "Use brightness?", 31 | "show_track": "Show track?", 32 | "toggle_on_click": "Act as a toggle (disable sliding)", 33 | "force_square": "Force square?" 34 | }, 35 | "action_button": { 36 | "title": "Action button", 37 | "mode": "Mode", 38 | "icon": "Icon", 39 | "show_button": "Show button?", 40 | "show_spinner": "Show spinner?", 41 | "tap_action": "Tap action" 42 | } 43 | }, 44 | "state": { 45 | "off": "Off", 46 | "on": "On" 47 | }, 48 | "direction": { 49 | "left-right": "Left to right", 50 | "right-left": "Right to left", 51 | "top-bottom": "Top to bottom", 52 | "bottom-top": "Bottom to top" 53 | }, 54 | "background": { 55 | "striped": "Striped", 56 | "gradient": "Gradient", 57 | "solid": "Solid", 58 | "triangle": "Triangle", 59 | "custom": "Custom" 60 | }, 61 | "mode": { 62 | "toggle": "Toggle", 63 | "custom": "Custom" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /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/languages/sk.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "version": "v", 4 | "invalid_configuration": "Neplatná konfigurácia", 5 | "show_warning": "Zobraziť warning", 6 | "show_error": "Zobraziť error" 7 | }, 8 | "tabs": { 9 | "general": { 10 | "title": "Všeobecné", 11 | "entity": "Entita (požadovaná)", 12 | "attribute": "Atribút (Voliteľné)", 13 | "name": "Názov (voliteľný)", 14 | "show_name": "Zobraziť názov?", 15 | "show_state": "Zobraziť stav?", 16 | "show_attribute": "Zobraziť atribútt?", 17 | "compact": "Kompaktné?" 18 | }, 19 | "icon": { 20 | "title": "Ikona", 21 | "icon": "Ikona (voliteľné)", 22 | "show_icon": "Zobraziť ikonu?", 23 | "use_state_color": "Use state color?", 24 | "tap_action": "Klepnite na akciu" 25 | }, 26 | "slider": { 27 | "title": "Posuvník", 28 | "direction": "Smer", 29 | "background": "Pozadie", 30 | "use_brightness": "Použiť jas?", 31 | "show_track": "Zobraziť skladbu?", 32 | "toggle_on_click": "Pôsobiť ako prepínač (zakázať posúvanie)", 33 | "force_square": "Silový štvorec?" 34 | }, 35 | "action_button": { 36 | "title": "Akčné tlačidlo", 37 | "mode": "Režim", 38 | "icon": "Ikona", 39 | "show_button": "Zobraziť tlačidlo?", 40 | "show_spinner": "Zobraziť číselník?", 41 | "tap_action": "Klepnite na akciu" 42 | } 43 | }, 44 | "state": { 45 | "off": "Vypnúť", 46 | "on": "Zapnúť" 47 | }, 48 | "direction": { 49 | "left-right": "Zľava doprava", 50 | "right-left": "Zprava do ľava", 51 | "top-bottom": "Zhora nadol", 52 | "bottom-top": "Zdola nahor" 53 | }, 54 | "background": { 55 | "striped": "Prúžkované", 56 | "gradient": "Gradient", 57 | "solid": "Pevné", 58 | "triangle": "Trojuholník", 59 | "custom": "Voliteľné" 60 | }, 61 | "mode": { 62 | "toggle": "Prepnúť", 63 | "custom": "Voliteľné" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /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 ko from './languages/ko.json'; 6 | import * as nl from './languages/nl.json'; 7 | import * as pl from './languages/pl.json'; 8 | import * as pt from './languages/pt.json'; 9 | import * as ru from './languages/ru.json'; 10 | import * as sk from './languages/sk.json'; 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 | ko: ko, 20 | nl: nl, 21 | pl: pl, 22 | pt: pt, 23 | ru: ru, 24 | sk: sk, 25 | }; 26 | 27 | export function localize(string: string, search = '', replace = ''): string { 28 | const lang = (localStorage.getItem('selectedLanguage') || 'en').replace(/['"]+/g, '').replace('-', '_'); 29 | 30 | let translated: string; 31 | 32 | try { 33 | translated = string.split('.').reduce((o, i) => o[i], languages[lang]); 34 | } catch (e) { 35 | translated = string.split('.').reduce((o, i) => o[i], languages['en']); 36 | } 37 | 38 | if (translated === undefined) translated = string.split('.').reduce((o, i) => o[i], languages['en']); 39 | 40 | if (search !== '' && replace !== '') { 41 | translated = translated.replace(search, replace); 42 | } 43 | return translated; 44 | } 45 | -------------------------------------------------------------------------------- /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, eventOptions, 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, SliderDirections } 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 | 149 |
160 |
170 | ${this.ctrl.hasToggle 171 | ? html` 172 |
173 | ` 174 | : ''} 175 |
176 |
177 |
178 | ${this.renderText()} 179 | ${this.renderAction()} 180 | ${this.renderIcon()} 181 |
182 |
183 | `; 184 | } 185 | 186 | private renderText(): TemplateResult { 187 | if (!this.config.show_name && !this.config.show_state && !this.config.show_attribute) { 188 | return html``; 189 | } 190 | return html` 191 |
192 | ${this.config.show_name 193 | ? html` 194 |
${this.ctrl.name}
195 | ` 196 | : ''} 197 | 198 | 199 | ${this.config.show_state 200 | ? html` 201 | 202 | ${this.ctrl.isUnavailable 203 | ? html` 204 | ${this.hass.localize('state.default.unavailable')} 205 | ` : html` 206 | ${this.ctrl.label} 207 | `} 208 | 209 | ` 210 | : ''} 211 | 212 | ${this.config.show_attribute 213 | ? html` 214 | 215 | ${this.config.show_state && this.ctrl.attributeLabel 216 | ? html ` · ` 217 | : ''} 218 | ${this.ctrl.attributeLabel} 219 | 220 | ` 221 | : ''} 222 | 223 |
224 | `; 225 | } 226 | 227 | private renderIcon(): TemplateResult { 228 | if (this.config.icon?.show === false) { 229 | return html``; 230 | } 231 | let hasPicture = false; 232 | let backgroundImage = ''; 233 | if (this.ctrl.stateObj.attributes.entity_picture) { 234 | backgroundImage = `url(${this.ctrl.stateObj.attributes.entity_picture})`; 235 | hasPicture = true; 236 | } 237 | return html` 238 |
this._handleAction(e, this.config.icon)} 240 | .actionHandler=${actionHandler({ 241 | hasHold: false, 242 | hasDoubleClick: false, 243 | })} 244 | style=${styleMap({ 245 | 'background-image': `${backgroundImage}`, 246 | })} 247 | > 248 | 256 |
257 | `; 258 | } 259 | 260 | private renderAction(): TemplateResult { 261 | if (this.config.action_button?.show === false) { 262 | return html``; 263 | } 264 | if (this.config.action_button?.mode === ActionButtonMode.TOGGLE) { 265 | return html` 266 |
267 | 272 |
273 | `; 274 | } 275 | return html` 276 |
this._handleAction(e, this.config.action_button)} 278 | .actionHandler=${actionHandler({ 279 | hasHold: false, 280 | hasDoubleClick: false, 281 | })} 282 | > 283 | 287 | ${typeof this.config.action_button?.show_spinner === 'undefined' || this.config.action_button?.show_spinner 288 | ? html` 289 | 290 | 291 | 292 | ` 293 | : ''} 294 |
295 | `; 296 | } 297 | 298 | private _handleAction(ev: ActionHandlerEvent, config): void { 299 | if (this.hass && this.config && ev.detail.action) { 300 | if (config.tap_action?.action === 'toggle' && !this.ctrl.isUnavailable) { 301 | this.animateActionStart(); 302 | } 303 | handleAction(this, this.hass, {...config, entity: this.config.entity}, ev.detail.action); 304 | } 305 | } 306 | 307 | private async handleClick(ev: Event): Promise { 308 | if (this.ctrl.hasToggle && !this.ctrl.isUnavailable) { 309 | ev.preventDefault(); 310 | this.animateActionStart(); 311 | this.ctrl.log('Toggle'); 312 | await toggleEntity(this.hass, this.config.entity); 313 | // this.setStateValue(this.ctrl.toggleValue); 314 | } 315 | } 316 | 317 | private _toggle(): void { 318 | if (this.hass && this.config) { 319 | // eslint-disable-next-line @typescript-eslint/camelcase 320 | handleAction(this, this.hass, {tap_action: {action: 'toggle'}, entity: this.config.entity}, 'tap'); 321 | } 322 | } 323 | 324 | private setStateValue(value: number): void { 325 | this.ctrl.log('setStateValue', value); 326 | this.updateValue(value, false); 327 | this.ctrl.value = value; 328 | this.animateActionStart(); 329 | } 330 | 331 | private animateActionStart(): void { 332 | this.animateActionEnd(); 333 | if (this.action) { 334 | this.action.classList.add('loading'); 335 | } 336 | } 337 | 338 | private animateActionEnd(): void { 339 | if (this.action) { 340 | clearTimeout(this.actionTimeout); 341 | this.actionTimeout = setTimeout(()=> { 342 | this.action.classList.remove('loading'); 343 | }, 750) 344 | } 345 | } 346 | 347 | private updateValue(value: number, changing = true): void { 348 | this.changing = changing; 349 | this.changed = !changing; 350 | this.ctrl.log('updateValue', value); 351 | this.ctrl.targetValue = value; 352 | if (!this.button) { 353 | return 354 | } 355 | this.button.classList.remove('off'); 356 | if (changing) { 357 | this.button.classList.add('changing'); 358 | } else { 359 | this.button.classList.remove('changing'); 360 | if (this.ctrl.isOff) { 361 | this.button.classList.add('off'); 362 | } 363 | } 364 | if (this.stateText) { 365 | this.stateText.innerHTML = this.ctrl.isUnavailable ? `${this.hass.localize('state.default.unavailable')}` : this.ctrl.label; 366 | } 367 | this.button.style.setProperty('--slider-value', `${this.ctrl.percentage}%`); 368 | this.button.style.setProperty('--slider-bg-filter', this.ctrl.style.slider.filter); 369 | this.button.style.setProperty('--slider-color', this.ctrl.style.slider.color); 370 | this.button.style.setProperty('--icon-filter', this.ctrl.style.icon.filter); 371 | this.button.style.setProperty('--icon-color', this.ctrl.style.icon.color); 372 | this.button.style.setProperty('--icon-rotate-speed', this.ctrl.style.icon.rotateSpeed || '0s'); 373 | } 374 | 375 | private _showError(error: string): TemplateResult { 376 | const errorCard = document.createElement('hui-error-card'); 377 | errorCard.setConfig({ 378 | type: 'error', 379 | error, 380 | origConfig: this.config 381 | }); 382 | 383 | return html` 384 | ${errorCard} 385 | `; 386 | } 387 | 388 | private getColorFromVariable(color: string): string { 389 | if (typeof color !== 'undefined' && color.substring(0, 3) === 'var') { 390 | let varColor = window.getComputedStyle(this).getPropertyValue(color.substring(4).slice(0, -1)).trim(); 391 | if (!varColor.length) { 392 | varColor = window.getComputedStyle(document.documentElement).getPropertyValue(color.substring(4).slice(0, -1)).trim(); 393 | } 394 | return varColor 395 | } 396 | return color; 397 | } 398 | 399 | @eventOptions({passive: true}) 400 | private onPointerDown(event: PointerEvent): void { 401 | if (this.config.slider?.direction === SliderDirections.TOP_BOTTOM 402 | || this.config.slider?.direction === SliderDirections.BOTTOM_TOP) { 403 | event.preventDefault(); 404 | } 405 | event.stopPropagation(); 406 | if (this.ctrl.isSliderDisabled) { 407 | return; 408 | } 409 | this.slider.setPointerCapture(event.pointerId); 410 | } 411 | 412 | @eventOptions({passive: true}) 413 | private onPointerUp(event: PointerEvent): void { 414 | if (this.ctrl.isSliderDisabled) { 415 | return; 416 | } 417 | 418 | if (this.config.slider?.direction === SliderDirections.TOP_BOTTOM 419 | || this.config.slider?.direction === SliderDirections.BOTTOM_TOP) { 420 | this.setStateValue(this.ctrl.targetValue); 421 | this.slider.releasePointerCapture(event.pointerId); 422 | } 423 | 424 | if (!this.slider.hasPointerCapture(event.pointerId)) { 425 | return; 426 | } 427 | 428 | this.setStateValue(this.ctrl.targetValue); 429 | this.slider.releasePointerCapture(event.pointerId); 430 | } 431 | 432 | private onPointerCancel(event: PointerEvent): void { 433 | if (this.config.slider?.direction === SliderDirections.TOP_BOTTOM 434 | || this.config.slider?.direction === SliderDirections.BOTTOM_TOP) { 435 | return; 436 | } 437 | this.updateValue(this.ctrl.value, false); 438 | this.slider.releasePointerCapture(event.pointerId); 439 | } 440 | 441 | @eventOptions({passive: true}) 442 | private onPointerMove(event: any): void { 443 | if (this.ctrl.isSliderDisabled) { 444 | return; 445 | } 446 | if (!this.slider.hasPointerCapture(event.pointerId)) return; 447 | const {left, top, width, height} = this.slider.getBoundingClientRect(); 448 | const percentage = this.ctrl.moveSlider(event, {left, top, width, height}); 449 | this.ctrl.log('onPointerMove', percentage); 450 | this.updateValue(percentage); 451 | } 452 | 453 | connectedCallback(): void { 454 | super.connectedCallback(); 455 | } 456 | 457 | disconnectedCallback(): void { 458 | super.disconnectedCallback(); 459 | } 460 | 461 | static get styles(): CSSResult { 462 | return css` 463 | ha-card { 464 | box-sizing: border-box; 465 | height: 100%; 466 | width: 100%; 467 | min-height: 7rem; 468 | display: flex; 469 | flex-direction: column; 470 | justify-content: space-between; 471 | touch-action: pan-y; 472 | overflow: hidden; 473 | --mdc-icon-size: 2.2em; 474 | } 475 | ha-card[data-mode="top-bottom"], 476 | ha-card[data-mode="bottom-top"] { 477 | touch-action: none; 478 | } 479 | ha-card.square { 480 | aspect-ratio: 1 / 1; 481 | } 482 | ha-card.compact { 483 | min-height: 3rem !important; 484 | } 485 | :host { 486 | --slider-bg-default-color: var(--primary-color, rgb(95, 124, 171)); 487 | --slider-bg: var(--slider-color); 488 | --slider-bg-filter: brightness(100%); 489 | --slider-bg-direction: to right; 490 | --slider-track-color: #2b374e; 491 | --slider-tracker-color: transparent; 492 | --slider-value: 0%; 493 | --slider-transition-duration: 0.2s; 494 | /*--label-text-shadow: rgb(255 255 255 / 10%) -1px -1px 1px, rgb(0 0 0 / 50%) 1px 1px 1px;*/ 495 | /*--label-color-on: var(--primary-text-color, white);*/ 496 | /*--label-color-off: var(--primary-text-color, white);*/ 497 | --icon-filter: brightness(100%); 498 | --icon-color: var(--paper-item-icon-color); 499 | --icon-rotate-speed: 0s; 500 | /*--state-color-on: #BAC0C6; */ 501 | /*--state-color-off: var(--disabled-text-color);*/ 502 | /*--state-text-shadow: rgb(255 255 255 / 10%) -1px -1px 1px, rgb(0 0 0 / 50%) 1px 1px 1px;*/ 503 | --btn-bg-color-off: rgba(43,55,78,1); 504 | --btn-bg-color-on: #20293c; 505 | /*--action-icon-color-on: var(--paper-item-icon-color, black);*/ 506 | /*--action-icon-color-off: var(--paper-item-icon-color, black);*/ 507 | /*--action-spinner-color: var(--label-badge-text-color, white);*/ 508 | } 509 | /* --- BUTTON --- */ 510 | 511 | .button { 512 | position: relative; 513 | padding: 0.8rem; 514 | box-sizing: border-box; 515 | height: 100%; 516 | min-height: 7rem; 517 | width: 100%; 518 | display: block; 519 | overflow: hidden; 520 | transition: all 0.2s ease-in-out; 521 | touch-action: pan-y; 522 | } 523 | .button[data-mode="top-bottom"], 524 | .button[data-mode="bottom-top"] { 525 | touch-action: none; 526 | } 527 | ha-card.compact .button { 528 | min-height: 3rem !important; 529 | } 530 | .button.off { 531 | background-color: var(--btn-bg-color-off); 532 | } 533 | 534 | /* --- ICON --- */ 535 | 536 | .icon { 537 | position: relative; 538 | cursor: pointer; 539 | width: var(--mdc-icon-size, 24px); 540 | height: var(--mdc-icon-size, 24px); 541 | box-sizing: border-box; 542 | padding: 0; 543 | outline: none; 544 | animation: var(--icon-rotate-speed, 0s) linear 0s infinite normal both running rotate; 545 | -webkit-tap-highlight-color: transparent; 546 | } 547 | .icon ha-icon { 548 | filter: var(--icon-filter, brightness(100%)); 549 | color: var(--icon-color); 550 | transition: color 0.4s ease-in-out 0s, filter 0.2s linear 0s; 551 | } 552 | .icon.has-picture { 553 | background-size: cover; 554 | border-radius: 50%; 555 | } 556 | .icon.has-picture ha-icon{ 557 | display: none; 558 | } 559 | .unavailable .icon ha-icon { 560 | color: var(--disabled-text-color); 561 | } 562 | .compact .icon { 563 | float: left; 564 | } 565 | 566 | /* --- TEXT --- */ 567 | 568 | .text { 569 | position: absolute; 570 | bottom: 0; 571 | left: 0; 572 | padding: 0.8rem; 573 | pointer-events: none; 574 | user-select: none; 575 | font-size: 1.1rem; 576 | line-height: 1.3rem; 577 | max-width: calc(100% - 2em); 578 | /*text-shadow: rgb(255 255 255 / 10%) -1px -1px 1px, rgb(0 0 0 / 50%) 1px 1px 1px;*/ 579 | } 580 | .compact .text { 581 | position: relative; 582 | top: 0.5rem; 583 | left: 0.5rem; 584 | display: inline-block; 585 | padding: 0; 586 | height: 1.3rem; 587 | width: 100%; 588 | overflow: hidden; 589 | max-width: calc(100% - 4em); 590 | } 591 | .compact.hide-action .text { 592 | max-width: calc(100% - 2em); 593 | } 594 | 595 | /* --- LABEL --- */ 596 | 597 | .name { 598 | color: var(--label-color-on, var(--primary-text-color, white)); 599 | text-overflow: ellipsis; 600 | overflow: hidden; 601 | white-space: nowrap; 602 | text-shadow: var(--label-text-shadow, none); 603 | } 604 | .off .name { 605 | color: var(--label-color-off, var(--primary-text-color, white)); 606 | } 607 | .unavailable.off .name, 608 | .unavailable .name { 609 | color: var(--disabled-text-color); 610 | } 611 | .compact .name { 612 | display: inline-block; 613 | max-width: calc(100% - 3.5em); 614 | } 615 | 616 | /* --- STATE --- */ 617 | 618 | .state { 619 | color: var(--state-color-on, var(--label-badge-text-color, white)); 620 | text-overflow: ellipsis; 621 | white-space: nowrap; 622 | text-shadow: var(--state-text-shadow); 623 | transition: font-size 0.1s ease-in-out; 624 | } 625 | .changing .state { 626 | font-size: 150%; 627 | } 628 | .off .state { 629 | color: var(--state-color-off, var(--disabled-text-color)); 630 | } 631 | .unavailable .state { 632 | color: var(--disabled-text-color); 633 | } 634 | .compact .state { 635 | display: inline-block; 636 | max-width: calc(100% - 0em); 637 | overflow: hidden; 638 | } 639 | 640 | /* --- ATTRIBUTE --- */ 641 | 642 | .attribute { 643 | /* 644 | color: var(--state-color-on, var(--label-badge-text-color, white)); 645 | text-overflow: ellipsis; 646 | overflow: hidden; 647 | white-space: nowrap; 648 | text-shadow: var(--state-text-shadow); 649 | max-width: calc(50% -2em); 650 | transition: font-size 0.1s ease-in-out; 651 | border: 1px solid red; 652 | */ 653 | } 654 | 655 | .compact .attribute { 656 | display: inline-block; 657 | max-width: calc(100% - 0em); 658 | overflow: hidden; 659 | } 660 | 661 | .oneliner { 662 | color: var(--state-color-on, var(--label-badge-text-color, white)); 663 | text-overflow: ellipsis; 664 | overflow: hidden; 665 | white-space: nowrap; 666 | max-width: 20px; 667 | width: 20px; 668 | text-shadow: var(--state-text-shadow); 669 | transition: font-size 0.1s ease-in-out; 670 | /*border: 1px solid blue;*/ 671 | } 672 | /* --- SLIDER --- */ 673 | 674 | .slider { 675 | position: absolute; 676 | top: 0px; 677 | left: 0px; 678 | height: 100%; 679 | width: 100%; 680 | background-color: var( --ha-card-background, var(--card-background-color, var(--btn-bg-color-on, black)) ); 681 | cursor: ew-resize; 682 | z-index: 0; 683 | } 684 | .slider[data-mode="bottom-top"] { 685 | cursor: ns-resize; 686 | } 687 | .slider[data-mode="top-bottom"] { 688 | cursor: ns-resize; 689 | } 690 | .slider:active { 691 | cursor: grabbing; 692 | } 693 | 694 | /* --- SLIDER OVERLAY --- */ 695 | 696 | .slider .toggle-overlay { 697 | position: absolute; 698 | top: 0px; 699 | left: 0px; 700 | height: 100%; 701 | width: 100%; 702 | cursor: pointer; 703 | opacity: 0; 704 | z-index: 999; 705 | } 706 | 707 | /* --- SLIDER BACKGROUND --- */ 708 | 709 | .slider-bg { 710 | position: absolute; 711 | top: 0; 712 | left: 0px; 713 | height: 100%; 714 | width: 100%; 715 | background: var(--slider-bg); 716 | background-size: var(--slider-bg-size, 100% 100%); 717 | background-color: var(--slider-bg-color, transparent); 718 | background-position: var(--slider-bg-position, 0 0); 719 | filter: var(--slider-bg-filter, brightness(100%)); 720 | } 721 | .off .slider .slider-bg { 722 | background-color: var( --ha-card-background, var(--card-background-color, var(--btn-bg-color-off, black)) ); 723 | } 724 | .slider[data-background="solid"] .slider-bg { 725 | --slider-bg-color: var(--slider-color); 726 | } 727 | .slider[data-background="triangle"] .slider-bg { 728 | --slider-bg-direction: to bottom right; 729 | --slider-bg: linear-gradient(var(--slider-bg-direction), transparent 0%, transparent 50%, var(--slider-color) 50%, var(--slider-color) 100%); 730 | border-right: 0px solid; 731 | } 732 | .slider[data-background="triangle"][data-mode="right-left"] .slider-bg { 733 | --slider-bg-direction: to bottom left; 734 | } 735 | .slider[data-background="triangle"][data-mode="bottom-top"] .slider-bg { 736 | --slider-bg-direction: to top left; 737 | } 738 | .slider[data-background="triangle"][data-mode="top-bottom"] .slider-bg { 739 | --slider-bg-direction: to bottom left; 740 | } 741 | .slider[data-background="custom"] .slider-bg { 742 | --slider-bg: repeating-linear-gradient(-45deg, var(--slider-color) 0, var(--slider-color) 1px, var(--slider-color) 0, transparent 10%); 743 | --slider-bg-size: 30px 30px; 744 | } 745 | .slider[data-background="gradient"] .slider-bg { 746 | --slider-bg: linear-gradient(var(--slider-bg-direction), rgba(0, 0, 0, 0) -10%, var(--slider-color) 100%); 747 | } 748 | .slider[data-background="striped"] .slider-bg { 749 | --slider-bg: linear-gradient(var(--slider-bg-direction), var(--slider-color), var(--slider-color) 50%, transparent 50%, transparent); 750 | --slider-bg-size: 4px 100%; 751 | } 752 | .slider[data-background="striped"][data-mode="bottom-top"] .slider-bg, 753 | .slider[data-background="striped"][data-mode="top-bottom"] .slider-bg { 754 | --slider-bg-size: 100% 4px; 755 | } 756 | .slider[data-mode="right-left"] .slider-bg { 757 | --slider-bg-direction: to left; 758 | } 759 | .slider[data-mode="bottom-top"] .slider-bg { 760 | --slider-bg-direction: to top; 761 | } 762 | .slider[data-mode="top-bottom"] .slider-bg { 763 | --slider-bg-direction: to bottom; 764 | } 765 | 766 | /* --- SLIDER THUMB --- */ 767 | 768 | .slider-thumb { 769 | position: relative; 770 | width: 100%; 771 | height: 100%; 772 | transform: translateX(var(--slider-value)); 773 | background: transparent; 774 | transition: transform var(--slider-transition-duration) ease-in; 775 | } 776 | .changing .slider .slider-thumb { 777 | transition: none; 778 | } 779 | .slider[data-mode="right-left"] .slider-thumb { 780 | transform: translateX(calc(var(--slider-value) * -1)) !important; 781 | } 782 | .slider[data-mode="top-bottom"] .slider-thumb { 783 | transform: translateY(var(--slider-value)) !important; 784 | } 785 | .slider[data-mode="bottom-top"] .slider-thumb { 786 | transform: translateY(calc(var(--slider-value) * -1)) !important; 787 | } 788 | 789 | .slider-thumb:before { 790 | content: ''; 791 | position: absolute; 792 | top: 0; 793 | left: -2px; 794 | height: 100%; 795 | width: 2px; 796 | background: var(--slider-color); 797 | opacity: 0; 798 | transition: opacity 0.2s ease-in-out 0s; 799 | box-shadow: var(--slider-color) 0px 1px 5px 1px; 800 | z-index: 999; 801 | } 802 | .slider[data-mode="top-bottom"] .slider-thumb:before { 803 | top: -2px; 804 | left: 0px; 805 | height: 2px; 806 | width: 100%; 807 | } 808 | .changing .slider-thumb:before { 809 | opacity: 0.5; 810 | } 811 | .off.changing .slider-thumb:before { 812 | opacity: 0; 813 | } 814 | 815 | .slider-thumb:after { 816 | content: ''; 817 | position: absolute; 818 | top: 0; 819 | left: 0px; 820 | height: 100%; 821 | width: 100%; 822 | background: var( --ha-card-background, var(--card-background-color, var(--btn-bg-color-on, black)) ); 823 | opacity: 1; 824 | } 825 | .slider[data-show-track="true"] .slider-thumb:after { 826 | opacity: 0.9; 827 | } 828 | .off .slider[data-show-track="true"] .slider-thumb:after { 829 | opacity: 1; 830 | } 831 | 832 | /* --- ACTION BUTTON --- */ 833 | 834 | .action { 835 | position: relative; 836 | float: right; 837 | width: var(--mdc-icon-size, 24px); 838 | height: var(--mdc-icon-size, 24px); 839 | color: var(--action-icon-color-on, var(--paper-item-icon-color, black)); 840 | cursor: pointer; 841 | outline: none; 842 | -webkit-tap-highlight-color: transparent; 843 | } 844 | .action ha-switch { 845 | position: absolute; 846 | right: 0; 847 | top: 5px; 848 | } 849 | .off .action { 850 | color: var(--action-icon-color-off, var(--paper-item-icon-color, black)); 851 | } 852 | .unavailable .action { 853 | color: var(--disabled-text-color); 854 | } 855 | 856 | 857 | .circular-loader { 858 | position: absolute; 859 | left: -8px; 860 | top: -8px; 861 | width: calc(var(--mdc-icon-size, 24px) + 16px); 862 | height: calc(var(--mdc-icon-size, 24px) + 16px); 863 | opacity: 0; 864 | transition: opacity 0.2s ease-in-out; 865 | animation: rotate 2s linear infinite; 866 | } 867 | .action.loading .circular-loader { 868 | opacity: 1; 869 | } 870 | 871 | .loader-path { 872 | fill: none; 873 | stroke-width: 2px; 874 | stroke: var(--action-spinner-color, var(--label-badge-text-color, white)); 875 | animation: animate-stroke 1.5s ease-in-out infinite both; 876 | stroke-linecap: round; 877 | } 878 | 879 | /* --- MISC --- */ 880 | 881 | .unavailable .slider .toggle-overlay, 882 | .unavailable .action, 883 | .unavailable .action ha-switch, 884 | .unavailable .slider { 885 | cursor: not-allowed !important; 886 | } 887 | 888 | 889 | @keyframes rotate { 890 | 100% { 891 | transform: rotate(360deg); 892 | } 893 | } 894 | 895 | @keyframes animate-stroke { 896 | 0% { 897 | stroke-dasharray: 1, 200; 898 | stroke-dashoffset: 0; 899 | } 900 | 50% { 901 | stroke-dasharray: 89, 200; 902 | stroke-dashoffset: -35; 903 | } 904 | 100% { 905 | stroke-dasharray: 89, 200; 906 | stroke-dashoffset: -124; 907 | } 908 | } 909 | `; 910 | } 911 | } 912 | -------------------------------------------------------------------------------- /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 | attribute?: string; 15 | name?: string; 16 | show_name?: boolean; 17 | show_state?: boolean; 18 | show_attribute?: boolean; 19 | icon?: IconConfig; 20 | action_button?: ActionButtonConfig; 21 | slider?: SliderConfig; 22 | theme?: string; 23 | debug?: boolean; 24 | compact?: boolean; 25 | } 26 | 27 | export interface ActionButtonConfig { 28 | mode?: ActionButtonMode; 29 | icon?: string; 30 | show?: boolean; 31 | show_spinner?: boolean; 32 | tap_action?: ActionConfig; 33 | } 34 | 35 | export interface IconConfig { 36 | icon?: string; 37 | show?: boolean; 38 | tap_action?: ActionConfig; 39 | use_state_color?: boolean; 40 | } 41 | 42 | export interface SliderConfig { 43 | min?: number; 44 | max?: number; 45 | step?: number; 46 | attribute?: string; 47 | direction?: SliderDirections; 48 | background: SliderBackground; 49 | use_percentage_bg_opacity?: boolean; 50 | use_state_color?: boolean; 51 | show_track?: boolean; 52 | toggle_on_click?: boolean; 53 | invert?: boolean; 54 | force_square: boolean; 55 | } 56 | 57 | export enum ActionButtonMode { 58 | TOGGLE = 'toggle', 59 | CUSTOM = 'custom', 60 | } 61 | 62 | export enum SliderDirections { 63 | LEFT_RIGHT = 'left-right', 64 | RIGHT_LEFT = 'right-left', 65 | TOP_BOTTOM = 'top-bottom', 66 | BOTTOM_TOP = 'bottom-top', 67 | } 68 | 69 | export enum SliderBackground { 70 | SOLID = 'solid', 71 | GRADIENT = 'gradient', 72 | TRIANGLE = 'triangle', 73 | STRIPED = 'striped', 74 | CUSTOM = 'custom', 75 | } 76 | 77 | export enum Domain { 78 | LIGHT = 'light', 79 | SWITCH = 'switch', 80 | FAN = 'fan', 81 | COVER = 'cover', 82 | INPUT_BOOLEAN = 'input_boolean', 83 | INPUT_NUMBER = 'input_number', 84 | MEDIA_PLAYER = 'media_player', 85 | NUMBER = 'number', 86 | CLIMATE = 'climate', 87 | LOCK = 'lock', 88 | AUTOMATION = 'automation', 89 | } 90 | 91 | export const ActionButtonConfigDefault: ActionButtonConfig = { 92 | mode: ActionButtonMode.TOGGLE, 93 | icon: 'mdi:power', 94 | show: true, 95 | show_spinner: true, 96 | tap_action: { 97 | action: 'toggle' 98 | }, 99 | }; 100 | 101 | export const IconConfigDefault: IconConfig = { 102 | show: true, 103 | use_state_color: true, 104 | tap_action: { 105 | action: 'more-info' 106 | }, 107 | }; 108 | 109 | export const SliderConfigDefault: SliderConfig = { 110 | direction: SliderDirections.LEFT_RIGHT, 111 | background: SliderBackground.SOLID, 112 | use_percentage_bg_opacity: false, 113 | use_state_color: false, 114 | show_track: false, 115 | toggle_on_click: false, 116 | force_square: false, 117 | }; 118 | 119 | export const SliderConfigDefaultDomain: Map = new Map([ 120 | [Domain.LIGHT, { 121 | direction: SliderDirections.LEFT_RIGHT, 122 | background: SliderBackground.GRADIENT, 123 | use_state_color: true, 124 | use_percentage_bg_opacity: false, 125 | show_track: false, 126 | toggle_on_click: false, 127 | force_square: false, 128 | show_attribute: false, 129 | }], 130 | [Domain.FAN, { 131 | direction: SliderDirections.LEFT_RIGHT, 132 | background: SliderBackground.SOLID, 133 | use_state_color: false, 134 | use_percentage_bg_opacity: false, 135 | show_track: false, 136 | toggle_on_click: false, 137 | force_square: false, 138 | show_attribute: false, 139 | }], 140 | [Domain.SWITCH, { 141 | direction: SliderDirections.LEFT_RIGHT, 142 | background: SliderBackground.SOLID, 143 | use_state_color: false, 144 | use_percentage_bg_opacity: false, 145 | show_track: false, 146 | toggle_on_click: true, 147 | force_square: false, 148 | show_attribute: false, 149 | }], 150 | [Domain.AUTOMATION, { 151 | direction: SliderDirections.LEFT_RIGHT, 152 | background: SliderBackground.SOLID, 153 | use_state_color: false, 154 | use_percentage_bg_opacity: false, 155 | show_track: false, 156 | toggle_on_click: true, 157 | force_square: false, 158 | }], 159 | [Domain.COVER, { 160 | direction: SliderDirections.TOP_BOTTOM, 161 | background: SliderBackground.STRIPED, 162 | use_state_color: false, 163 | use_percentage_bg_opacity: false, 164 | toggle_on_click: false, 165 | show_track: false, 166 | force_square: false, 167 | invert: true, 168 | show_attribute: false, 169 | }], 170 | [Domain.INPUT_BOOLEAN, { 171 | direction: SliderDirections.LEFT_RIGHT, 172 | background: SliderBackground.SOLID, 173 | use_state_color: false, 174 | use_percentage_bg_opacity: false, 175 | show_track: false, 176 | toggle_on_click: true, 177 | force_square: false, 178 | show_attribute: false, 179 | }], 180 | [Domain.INPUT_NUMBER, { 181 | direction: SliderDirections.LEFT_RIGHT, 182 | background: SliderBackground.SOLID, 183 | use_state_color: false, 184 | use_percentage_bg_opacity: false, 185 | show_track: false, 186 | toggle_on_click: false, 187 | force_square: false, 188 | }], 189 | [Domain.MEDIA_PLAYER, { 190 | direction: SliderDirections.LEFT_RIGHT, 191 | background: SliderBackground.TRIANGLE, 192 | use_state_color: false, 193 | use_percentage_bg_opacity: false, 194 | show_track: true, 195 | toggle_on_click: false, 196 | force_square: false, 197 | show_attribute: true, 198 | attribute: "media_title", 199 | }], 200 | [Domain.LOCK, { 201 | direction: SliderDirections.LEFT_RIGHT, 202 | background: SliderBackground.SOLID, 203 | use_state_color: false, 204 | use_percentage_bg_opacity: false, 205 | show_track: false, 206 | toggle_on_click: true, 207 | force_square: false, 208 | show_attribute: false, 209 | }], 210 | [Domain.CLIMATE, { 211 | direction: SliderDirections.LEFT_RIGHT, 212 | background: SliderBackground.TRIANGLE, 213 | use_state_color: false, 214 | use_percentage_bg_opacity: false, 215 | show_track: true, 216 | toggle_on_click: false, 217 | force_square: false, 218 | show_attribute: false, 219 | }], 220 | ]); 221 | 222 | export enum LightAttributes { 223 | COLOR_TEMP = 'color_temp', 224 | BRIGHTNESS = 'brightness', 225 | BRIGHTNESS_PCT = 'brightness_pct', 226 | HUE = 'hue', 227 | SATURATION = 'saturation', 228 | ON_OFF = 'onoff', 229 | } 230 | 231 | export enum LightColorModes { 232 | COLOR_TEMP = 'color_temp', 233 | BRIGHTNESS = 'brightness', 234 | HS = 'hs', 235 | ON_OFF = 'onoff', 236 | } 237 | 238 | export enum CoverAttributes { 239 | POSITION = 'position', 240 | TILT = 'tilt', 241 | } 242 | -------------------------------------------------------------------------------- /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 | } --------------------------------------------------------------------------------