├── .github └── css-swipe-card.png ├── hacs.json ├── README.md └── dist └── css-swipe-card.js /.github/css-swipe-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nemuritor01/css-swipe-card/HEAD/.github/css-swipe-card.png -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "CSS-Swipe-Card", 3 | "content_in_root": false, 4 | "render_readme": true, 5 | "filename": "css-swipe-card.js", 6 | "homeassistant": "2023.9.0" 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CSS-Swipe-Card 2 | 3 | ![readme-images-css-swipe-card](https://github.com/Nemuritor01/css-swipe-card/blob/main/.github/css-swipe-card.png) 4 | 5 | CSS-Swipe-Card is a minimalist and customizable card, that lets you flick through a slider-carousel of cards. 6 | This card is written in CSS and Java Script and not using any third party library. 7 | 8 | Please note, that I´ve created this card for my personal use in the first place. 9 | I´m not a full time developer, but an IT guy. 10 | The code might not follow best practise methods. Contributors are welcome. 11 | 12 |
13 | 14 | ## Table of contents 15 | 16 | **[`Installation`](#installation)** **[`Configuration`](#configuration)** **[`Styling`](#styling)** **[`Automations`](#automations)** **[`Credits`](#credits)** 17 | 18 |
19 | 20 | ## Installation 21 | 22 | **Home Assistant lowest supported version:** 2023.9.0 23 | 24 |
25 | 26 | With HACS 27 | 28 |
29 | 30 | 1. Open HACS (installation instructions are [here](https://hacs.xyz/docs/setup/prerequisites/). 31 | 2. Open the menu in the upper-right and select `Custom repositories`. 32 | 3. Enter the repository: `https://github.com/Nemuritor01/css-swipe-card` 33 | 4. Select the category `Lovelace`. 34 | 5. Select `ADD`. 35 | 6. Confirm the repository now appears in your HACS custom repositories list. Select `CANCEL` to close the custom repository window. 36 | 7. In the HACS search, type `CSS-Swipe-Card`. 37 | 8. Select the `CSS-Swipe-Card` Respository from the list. 38 | 9. Install the Repository. 39 | 10. Make sure to add to resources via one of the following: 40 | - If using the GUI Resource option, this should have been added automatically. 41 | - If using the `configuration.yaml`, open your `configuration.yaml` via File editor or other means and add: 42 | ``` 43 | lovelace: 44 | mode: yaml 45 | resources: 46 | - url: /hacsfiles/css-swipe-card/css-swipe-card.js 47 | type: module 48 | ``` 49 | 11. Reload your browser. If the card does not show, try to clear your browser cache. 50 | 51 |
52 | 53 |
54 | 55 | Without HACS 56 | 57 |
58 | 59 | 1. Download these files: [css-swipe-card.js](https://github.com/Nemuritor01/css-swipe-card/blob/main/src/css-swipe-card.js) 60 | 2. Add these files to your `/www` folder 61 | 3. On your dashboard click on the icon at the right top corner then on `Edit dashboard` 62 | 4. Click again on that icon and then click on `Manage resources` 63 | 5. Click on `Add resource` 64 | 6. Copy and paste this: `/local/css-swipe-card.js?v=1` 65 | 7. Click on `JavaScript Module` then `Create` 66 | 8. Go back and refresh your page 67 | 9. After any update of the file you will have to edit `/local/css-swipe-card.js?v=1` and change the version to any higher number 68 | 69 | If it's not working, just try to clear your browser cache.` 70 | 71 |
72 | 73 | ## Configuration: 74 | 75 | Add a card with type `custom:css-swipe-card`: 76 | 77 | ```yaml 78 | - type: custom:css-swipe-card 79 | cards: [] 80 | ``` 81 | ## Parameters 82 | 83 | | Name | Type | Default | Supported options | Description | 84 | | ---- | ---- | ------- | ----------------- | ----------- | 85 | | `cardId` | string | automatic calculation | a unique card ID you can use to trigger the card in automations | 86 | | `template` | string | slider-horizontal | slider-horizontal, slider-vertical | 87 | | `height` | string | | Any css option that fits in the `height` css value | Will force the height of the swiper container | 88 | | `auto_height` | boolean | false | true, false | force the same heigth, based on the tallest card | 89 | | `card_gap` | string | 0px | Any css option that fits in the `width` css value | | 90 | | `timer` | number | 0 | Any number | Will reset the swiper to the first card after `timer` seconds | 91 | | `pagination` | boolean | false | true, false | enable pagination bullets | 92 | | `navigation` | boolean | false | true, false | enable navigation buttons | 93 | | `navigation_next` | icon | none | any icon in home assistant (mdi:xxx; fas:xxx) | set icon in navigation button next | 94 | | `navigation_prev` | icon | none | any icon in home assistant (mdi:xxx; fas:xxx) | set icon in navigation button previous | 95 | | `custom_css` | | none | see [`Styling`](#styling) | customize design of the swipe card based on various shortcuts | 96 | 97 | ## Styles 98 | 99 | Option `custom_css:`gives the ability to customize lots of css variables 100 | 101 | | Variable | Default | 102 | | -------- | ------- | 103 | | `--slides-align-items` | center | 104 | | `--pagination-bullet-active-background-color` | var(--primary-text-color) | 105 | | `--pagination-bullet-background-color` | var(--primary-background-color) | 106 | | `--pagination-bullet-border` | 1px solid #999 | 107 | | `--pagination-bullet-distance` | 10px | 108 | | `--navigation-button-next-color` | var(--primary-text-color) | 109 | | `--navigation-button-next-background-color` | var(--primary-background-color) | 110 | | `--navigation-button-next-width` | 40px | 111 | | `--navigation-button-next-height` | 40px | 112 | | `--navigation-button-next-border-radius` | 100% | 113 | | `--navigation-button-next-border` | none | 114 | | `--navigation-button-prev-color` | var(--primary-text-color) | 115 | | `--navigation-button-prev-background-color` | var(--primary-background-color) | 116 | | `--navigation-button-prev-width` | 40px | 117 | | `--navigation-button-prev-height` | 40px | 118 | | `--navigation-button-prev-border-radius` | 100% | 119 | | `--navigation-button-prev-border` | none | 120 | | `--navigation-button-distance` | 10px | 121 | 122 | 123 | Example code 124 | 125 | ```yaml 126 | type: custom:css-swipe-card 127 | cardId: YourUniqueCardName 128 | template: slider-horizontal 129 | auto_height: true 130 | pagination: true 131 | navigation: true 132 | card_gap: 2rem 133 | timer: 3 134 | navigation_next: mdi:chevron-right 135 | navigation_prev: mdi:chevron-left 136 | height: 10rem 137 | cards: 138 | - type: entity 139 | entity: sensor.your_sensor 140 | - type: entity 141 | entity: sensor.your_sensor 142 | - type: entity 143 | entity: sensor.your_sensor 144 | custom_css: 145 | '--navigation-button-next-color': white 146 | '--navigation-button-next-background-color': cornflowerblue 147 | '--navigation-button-next-width': 50px 148 | '--navigation-button-next-height': 50px 149 | '--navigation-button-prev-color': white 150 | '--navigation-button-prev-background-color': orchid 151 | '--navigation-button-prev-width': 50px 152 | '--navigation-button-prev-height': 50px 153 | '--pagination-bullet-active-background-color': cornflowerblue 154 | '--pagination-bullet-distance': 5px 155 | ``` 156 | ## Automations: 157 | Interactions with Home Assistant automations via input_number helper. 158 | CSS-Swipe-Card is able to monitor and interact with a user created input_number helper. The card monitors, if an input_number.YourCardId helper is availble. If an input number is set, the card will scroll to this card and reset the input_number helper. 159 | 160 | Instruction: 161 | 1. Define a unique cardId in your CSS-Swipe-Card config 162 | 163 | 2. Create an input_number helper. 164 | [create a number helper](https://www.home-assistant.io/integrations/input_number/) 165 | 166 | - name: cardId of your css-swipe-card 167 | - min: 0 (must be 0!) 168 | - max: amount of cards (if 4 cards enter 4) 169 | - step: 1 170 | 171 | 3. Use it in automations 172 | Define a trigger and use action: input_number.set_value. The value should be the card you want to scroll to 173 | - 1 = first card 174 | - 2 = second card 175 | etc. 176 | 177 | Example: 178 | 179 | ```yaml 180 | alias: your-automation-name 181 | description: "" 182 | trigger: 183 | - platform: state 184 | entity_id: 185 | - input_boolean.your_switch 186 | from: "off" 187 | to: "on" 188 | for: 189 | hours: 0 190 | minutes: 0 191 | seconds: 0 192 | condition: [] 193 | - action: input_number.set_value 194 | metadata: {} 195 | data: 196 | value: 1 197 | target: 198 | entity_id: input_number.YourCardId 199 | mode: single 200 | ``` 201 | 202 | ## Credits 203 | 204 | Credits to Bram Kragten. Some functions are based on his card code. 205 | https://github.com/bramkragten/swipe-card/commits?author=bramkragten 206 | -------------------------------------------------------------------------------- /dist/css-swipe-card.js: -------------------------------------------------------------------------------- 1 | class CssSwipeCard extends HTMLElement { 2 | static get version() { 3 | return 'v0.8.0'; 4 | } 5 | 6 | constructor() { 7 | super(); 8 | this.attachShadow({ mode: 'open' }); 9 | this.currentIndex = 0; 10 | this.resizeObserver = null; 11 | } 12 | 13 | // Core setup and rendering methods 14 | setConfig(config) { 15 | if (!config || !config.cards || !Array.isArray(config.cards)) { 16 | throw new Error('You need to define cards'); 17 | } 18 | 19 | this.cardId = config.cardId || `css-swipe-card-${Math.random().toString(36).substr(2, 9)}`; 20 | 21 | this.config = { 22 | width: '100%', 23 | template: 'slider-horizontal', 24 | auto_height: false, 25 | card_gap: '0px', 26 | timer: 0, 27 | pagination: false, 28 | navigation: false, 29 | navigation_next: '', 30 | navigation_prev: '', 31 | custom_css: {}, 32 | cardId: this.cardId, 33 | ...config 34 | }; 35 | 36 | this.render(); 37 | } 38 | 39 | async render() { 40 | const styles = this.getStyles(); 41 | const html = this.getHtml(); 42 | 43 | this.shadowRoot.innerHTML = `${html}`; 44 | 45 | const cardContainer = this.shadowRoot.querySelector(`.${this.config.template}`); 46 | this._cards = []; 47 | 48 | for (const [index, cardConfig] of this.config.cards.entries()) { 49 | const card = await this.createCardElement(cardConfig); 50 | const slide = document.createElement('div'); 51 | slide.classList.add('slide'); 52 | slide.style.width = '100%'; 53 | slide.dataset.index = index; 54 | card.classList.add('card-element'); 55 | slide.appendChild(card); 56 | cardContainer.appendChild(slide); 57 | this._cards.push(card); 58 | } 59 | 60 | if (this.config.auto_height) { 61 | this.setupResizeObserver(); 62 | } else { 63 | await this.setManualHeight(); 64 | } 65 | 66 | this.applyCustomStyles(); 67 | 68 | if (this.config.pagination) { 69 | this.setupPagination(); 70 | } 71 | 72 | if (this.config.navigation) { 73 | this.setupNavigation(); 74 | } 75 | 76 | this.setupTimer(); 77 | 78 | const slider = this.shadowRoot.querySelector(`.${this.config.template}`); 79 | slider.addEventListener('scroll', () => { 80 | this.updateCurrentIndex(); 81 | this.updatePagination(); 82 | }); 83 | 84 | if (this._hass) { 85 | this.checkInputNumberState(); 86 | } 87 | } 88 | 89 | // HTML and CSS generation methods 90 | getStyles() { 91 | return ` 92 | :host { 93 | --slides-gap: ${this.config.card_gap}; 94 | --slides-align-items: center; 95 | --pagination-bullet-active-background-color: var(--primary-text-color); 96 | --pagination-bullet-background-color: var(--primary-background-color); 97 | --pagination-bullet-border: 1px solid #999; 98 | --pagination-bullet-distance: 10px; 99 | --navigation-button-next-color: var(--primary-text-color); 100 | --navigation-button-next-background-color: var(--primary-background-color); 101 | --navigation-button-next-width: 40px; 102 | --navigation-button-next-height: 40px; 103 | --navigation-button-next-border-radius: 100%; 104 | --navigation-button-next-border: none; 105 | --navigation-button-prev-color: var(--primary-text-color); 106 | --navigation-button-prev-background-color: var(--primary-background-color); 107 | --navigation-button-prev-width: 40px; 108 | --navigation-button-prev-height: 40px; 109 | --navigation-button-prev-border-radius: 100%; 110 | --navigation-button-prev-border: none; 111 | --navigation-button-distance: 10px; 112 | } 113 | #${this.cardId} { 114 | position: relative; 115 | overflow: hidden; 116 | 117 | /* Force hardware acceleration with 3D transform */ 118 | transform: translateZ(0); 119 | -webkit-transform: translateZ(0); 120 | -moz-transform: translateZ(0); 121 | -ms-transform: translateZ(0); 122 | -o-transform: translateZ(0); 123 | 124 | /* Existing properties */ 125 | backface-visibility: hidden; 126 | perspective: 1000; 127 | -webkit-backface-visibility: hidden; 128 | -webkit-perspective: 1000; 129 | -moz-backface-visibility: hidden; 130 | -moz-perspective: 1000; 131 | -ms-backface-visibility: hidden; 132 | -ms-perspective: 1000; 133 | 134 | will-change: transform; 135 | -webkit-overflow-scrolling: touch; 136 | } 137 | #${this.cardId} .slider-horizontal { 138 | display: flex; 139 | overflow-x: auto; 140 | overflow-y: hidden; 141 | scroll-snap-type: x mandatory; 142 | scroll-behavior: smooth; 143 | position: relative; 144 | gap: var(--slides-gap); 145 | padding-inline: var(--slides-gap); 146 | } 147 | #${this.cardId} .slider-vertical { 148 | display: flex; 149 | flex-direction: column; 150 | overflow-y: auto; 151 | overflow-x: hidden; 152 | scroll-snap-type: y mandatory; 153 | scroll-behavior: smooth; 154 | position: relative; 155 | gap: var(--slides-gap); 156 | padding-block: var(--slides-gap); 157 | } 158 | #${this.cardId} .slider-horizontal, 159 | #${this.cardId} .slider-vertical { 160 | &::-webkit-scrollbar { 161 | display: none; 162 | } 163 | scrollbar-width: none; 164 | -ms-overflow-style: none; 165 | 166 | } 167 | #${this.cardId} .slide { 168 | display: flex; 169 | min-width: 100%; 170 | align-items: var(--slides-align-items); 171 | justify-content: center; 172 | scroll-snap-align: start; 173 | } 174 | #${this.cardId} .card-element { 175 | width: 100% !important; 176 | scroll-snap-align: start; 177 | scroll-snap-stop: always; 178 | } 179 | #${this.cardId} .pagination-control.horizontal { 180 | position: absolute; 181 | bottom: var(--pagination-bullet-distance); 182 | left: 50%; 183 | align-items: center; 184 | transform: translateX(-50%); 185 | display: flex; 186 | gap: 10px; 187 | } 188 | #${this.cardId} .pagination-control.vertical { 189 | position: absolute; 190 | top: 50%; 191 | right: var(--pagination-bullet-distance); 192 | align-items: center; 193 | transform: translateY(-50%); 194 | display: flex; 195 | flex-direction: column; 196 | gap: 10px; 197 | } 198 | #${this.cardId} .pagination-bullet { 199 | width: 10px; 200 | height: 10px; 201 | border-radius: 50%; 202 | background-color: var(--pagination-bullet-background-color, var(--primary-background-color)); 203 | border: var(--pagination-bullet-border, 1px solid #999); 204 | cursor: pointer; 205 | padding: 0; 206 | transition: all 0.3s ease; 207 | } 208 | #${this.cardId} .pagination-bullet.active { 209 | background-color: var(--pagination-bullet-active-background-color, var(--primary-text-color)); 210 | width: 12px; 211 | height: 12px; 212 | } 213 | #${this.cardId} .navigation-button { 214 | position: absolute; 215 | border: none; 216 | cursor: pointer; 217 | font-size: 24px; 218 | padding: 0; 219 | display: flex; 220 | align-items: center; 221 | justify-content: center; 222 | z-index: 1; 223 | transition: transform 0.1s; 224 | } 225 | #${this.cardId} .navigation-button:active { 226 | animation: buttonPress 0.2s ease-out; 227 | } 228 | #${this.cardId} .navigation-button.prev-horizontal { 229 | width: var(--navigation-button-prev-width); 230 | height: var(--navigation-button-prev-height); 231 | left: var(--navigation-button-distance); 232 | top: 50%; 233 | margin-top: calc(-1 * var(--navigation-button-prev-height) / 2); 234 | color: var(--navigation-button-prev-color); 235 | background: var(--navigation-button-prev-background-color); 236 | border-radius: var(--navigation-button-prev-border-radius); 237 | border: var(--navigation-button-prev-border); 238 | transition: transform 0.1s; 239 | } 240 | #${this.cardId} .navigation-button.next-horizontal { 241 | width: var(--navigation-button-next-width); 242 | height: var(--navigation-button-next-height); 243 | right: var(--navigation-button-distance); 244 | top: 50%; 245 | margin-top: calc(-1 * var(--navigation-button-next-height) / 2); 246 | color: var(--navigation-button-next-color); 247 | background: var(--navigation-button-next-background-color); 248 | border-radius: var(--navigation-button-next-border-radius); 249 | border: var(--navigation-button-next-border); 250 | transition: transform 0.1s; 251 | } 252 | #${this.cardId} .navigation-button.prev-vertical { 253 | width: var(--navigation-button-prev-width); 254 | height: var(--navigation-button-prev-height); 255 | top: var(--navigation-button-distance); 256 | left: 50%; 257 | margin-left: calc(-1 * var(--navigation-button-prev-width) / 2); 258 | color: var(--navigation-button-prev-color); 259 | background: var(--navigation-button-prev-background-color); 260 | border-radius: var(--navigation-button-prev-border-radius); 261 | border: var(--navigation-button-prev-border); 262 | transition: transform 0.1s; 263 | } 264 | #${this.cardId} .navigation-button.next-vertical { 265 | width: var(--navigation-button-next-width); 266 | height: var(--navigation-button-next-height); 267 | bottom: var(--navigation-button-distance); 268 | left: 50%; 269 | margin-left: calc(-1 * var(--navigation-button-next-width) / 2); 270 | color: var(--navigation-button-next-color); 271 | background: var(--navigation-button-next-background-color); 272 | border-radius: var(--navigation-button-next-border-radius); 273 | border: var(--navigation-button-next-border); 274 | transition: transform 0.1s; 275 | } 276 | #${this.cardId} .navigation-button ha-icon { 277 | width: 80%; 278 | height: 80%; 279 | display: flex; 280 | align-items: center; 281 | justify-content: center; 282 | } 283 | #${this.cardId} .navigation-button, #${this.cardId} .pagination-control label { 284 | -webkit-tap-highlight-color: transparent; 285 | outline: none; 286 | } 287 | #${this.cardId} .navigation-button ha-icon, 288 | #${this.cardId} .pagination-control label { 289 | pointer-events: none; 290 | } 291 | @keyframes buttonPress { 292 | 0% { 293 | transform: scale(1); 294 | } 295 | 50% { 296 | transform: scale(0.9); 297 | } 298 | 100% { 299 | transform: scale(1); 300 | } 301 | } 302 | `; 303 | } 304 | 305 | getHtml() { 306 | return ` 307 |
308 |
309 | ${this.config.pagination ? `
` : ''} 310 | ${this.config.navigation ? ` 311 | 314 | 317 | ` : ''} 318 |
319 | `; 320 | } 321 | 322 | // Card creation and sizing methods 323 | async createCardElement(cardConfig) { 324 | const createCard = (await loadCardHelpers()).createCardElement; 325 | const element = createCard(cardConfig); 326 | element.hass = this._hass; 327 | return element; 328 | } 329 | 330 | async getCardSize() { 331 | if (!this._cards) { 332 | return 0; 333 | } 334 | 335 | let maxHeight = 0; 336 | 337 | for (const card of this._cards) { 338 | if (card.getCardSize) { 339 | const size = await card.getCardSize(); 340 | maxHeight = Math.max(maxHeight, size * 50); 341 | } else { 342 | await card.updateComplete; 343 | const rect = card.getBoundingClientRect(); 344 | maxHeight = Math.max(maxHeight, rect.height); 345 | } 346 | } 347 | 348 | return maxHeight || 140; // fallback to 140 if maxHeight is 0 349 | } 350 | 351 | async getMaxCardHeight() { 352 | let maxHeight = 0; 353 | for (const card of this._cards) { 354 | await card.updateComplete; // Ensure card is fully rendered 355 | const rect = card.getBoundingClientRect(); 356 | maxHeight = Math.max(maxHeight, rect.height); 357 | } 358 | return maxHeight || 140; // Fallback height 359 | } 360 | 361 | // Card container height adjustment methods 362 | async adjustCardContainerHeight() { 363 | const cardContainer = this.shadowRoot.querySelector(`.${this.config.template}`); 364 | const slideContainer = this.shadowRoot.querySelector(`.slide`); 365 | const maxHeight = await this.getMaxCardHeight(); 366 | 367 | if (this.config.auto_height) { 368 | this._cards.forEach(card => { 369 | card.style.height = `${maxHeight}px`; 370 | }); 371 | cardContainer.style.height = `${maxHeight}px`; 372 | slideContainer.style.height = `${maxHeight}px`; 373 | } else { 374 | cardContainer.style.height = `${maxHeight}px`; 375 | slideContainer.style.height = `${maxHeight}px`; 376 | this._cards.forEach(card => { 377 | card.style.height = 'auto'; // Keeps native height for cards 378 | }); 379 | } 380 | 381 | if (this.config.height && !this.config.auto_height) { 382 | cardContainer.style.height = this.config.height; 383 | slideContainer.style.height = this.config.height; 384 | this._cards.forEach(card => { 385 | card.style.height = this.config.height; 386 | }); 387 | } 388 | } 389 | 390 | async setManualHeight() { 391 | const cardContainer = this.shadowRoot.querySelector(`.${this.config.template}`); 392 | const isHorizontal = this.config.template === 'slider-horizontal'; 393 | 394 | if (isHorizontal) { 395 | cardContainer.style.height = this.config.height; 396 | cardContainer.style.overflowY = 'hidden'; 397 | } else { 398 | // For vertical mode 399 | const maxHeight = await this.getMaxCardHeight(); 400 | cardContainer.style.height = this.config.height || `${maxHeight}px`; 401 | cardContainer.style.overflowY = 'auto'; 402 | } 403 | 404 | this._cards.forEach(card => { 405 | if (isHorizontal) { 406 | card.style.height = this.config.height; 407 | } else { 408 | card.style.height = 'auto'; // Keep native height for cards in vertical mode 409 | } 410 | }); 411 | } 412 | 413 | // Resize observer setup 414 | setupResizeObserver() { 415 | if (this.resizeObserver) { 416 | this.resizeObserver.disconnect(); 417 | } 418 | 419 | this.resizeObserver = new ResizeObserver(() => { 420 | this.adjustCardContainerHeight(); 421 | this.updateCurrentIndex(); 422 | this.updatePagination(); 423 | }); 424 | 425 | this._cards.forEach(card => { 426 | this.resizeObserver.observe(card); 427 | }); 428 | } 429 | 430 | // Current index update method 431 | updateCurrentIndex() { 432 | const slider = this.shadowRoot.querySelector(`.${this.config.template}`); 433 | const isHorizontal = this.config.template === 'slider-horizontal'; 434 | const scrollPosition = isHorizontal ? slider.scrollLeft : slider.scrollTop; 435 | const viewportSize = isHorizontal ? slider.clientWidth : slider.clientHeight; 436 | 437 | let accumulatedSize = 0; 438 | for (let i = 0; i < this._cards.length; i++) { 439 | const cardSize = isHorizontal ? this._cards[i].clientWidth : this._cards[i].clientHeight; 440 | if (scrollPosition < accumulatedSize + cardSize / 2) { 441 | this.currentIndex = i; 442 | break; 443 | } 444 | accumulatedSize += cardSize; 445 | } 446 | } 447 | 448 | // Home Assistant integration methods 449 | set hass(hass) { 450 | const oldHass = this._hass; 451 | this._hass = hass; 452 | 453 | if (!oldHass) { 454 | this.setupInputNumberListener(); 455 | this.checkInputNumberState(); 456 | } 457 | 458 | const cardContainer = this.shadowRoot.querySelector(`.${this.config.template}`); 459 | if (cardContainer) { 460 | cardContainer.childNodes.forEach((child) => { 461 | if (child.firstChild) { 462 | child.firstChild.hass = hass; 463 | } 464 | }); 465 | } 466 | 467 | const inputNumberEntity = `input_number.${this.config.cardId}`; 468 | if (oldHass && hass.states[inputNumberEntity] !== oldHass.states[inputNumberEntity]) { 469 | this.checkInputNumberState(); 470 | } 471 | } 472 | 473 | setupInputNumberListener() { 474 | const inputNumberEntity = `input_number.${this.config.cardId}`; 475 | this._hass.connection.subscribeEvents( 476 | (event) => this.handleInputNumberChange(event), 477 | 'state_changed', 478 | { entity_id: inputNumberEntity } 479 | ); 480 | } 481 | 482 | checkInputNumberState() { 483 | const inputNumberEntity = `input_number.${this.config.cardId}`; 484 | const state = this._hass.states[inputNumberEntity]; 485 | if (state) { 486 | const inputNumber = parseFloat(state.state); 487 | if (inputNumber !== 0) { 488 | const calcIndex = this.calcIndex(inputNumber); 489 | if (calcIndex >= 0) { 490 | requestAnimationFrame(() => { 491 | this.scrollToCardByIndex(calcIndex); 492 | setTimeout(() => { 493 | this.resetInputNumber(); 494 | }, 500); 495 | }); 496 | } 497 | } 498 | } 499 | } 500 | 501 | handleInputNumberChange(event) { 502 | if (event.data.entity_id === `input_number.${this.config.cardId}`) { 503 | const newState = event.data.new_state; 504 | if (newState && newState.state) { 505 | const inputNumber = parseFloat(newState.state); 506 | if (inputNumber !== 0) { 507 | const calcIndex = this.calcIndex(inputNumber); 508 | if (calcIndex >= 0) { 509 | this.scrollToCardByIndex(calcIndex).then(() => { 510 | this.resetInputNumber(); 511 | }); 512 | } 513 | } 514 | } 515 | } 516 | } 517 | 518 | resetInputNumber() { 519 | if (!this._hass) { 520 | console.error("HASS not available"); 521 | return; 522 | } 523 | 524 | const inputNumberEntity = `input_number.${this.config.cardId}`; 525 | this._hass.callService("input_number", "set_value", { 526 | entity_id: inputNumberEntity, 527 | value: 0 528 | }).catch((error) => { 529 | console.error("Failed to reset input_number:", error); 530 | }); 531 | } 532 | 533 | calcIndex(inputNumber) { 534 | return inputNumber - 1; 535 | } 536 | 537 | // Pagination setup and update methods 538 | setupPagination() { 539 | const paginationControl = this.shadowRoot.querySelector('.pagination-control'); 540 | if (!paginationControl) return; 541 | 542 | // Clear existing pagination bullets 543 | paginationControl.innerHTML = ''; 544 | 545 | this._cards.forEach((_, index) => { 546 | const bullet = document.createElement('button'); 547 | bullet.classList.add('pagination-bullet'); 548 | bullet.setAttribute('aria-label', `Go to slide ${index + 1}`); 549 | bullet.addEventListener('click', () => this.scrollToCard(index)); 550 | paginationControl.appendChild(bullet); 551 | }); 552 | 553 | this.updatePagination(); 554 | } 555 | 556 | updatePagination() { 557 | const paginationControl = this.shadowRoot.querySelector('.pagination-control'); 558 | if (!paginationControl) return; 559 | 560 | const bullets = paginationControl.querySelectorAll('.pagination-bullet'); 561 | bullets.forEach((bullet, index) => { 562 | if (index === this.currentIndex) { 563 | bullet.classList.add('active'); 564 | bullet.setAttribute('aria-current', 'true'); 565 | } else { 566 | bullet.classList.remove('active'); 567 | bullet.removeAttribute('aria-current'); 568 | } 569 | }); 570 | } 571 | 572 | // Navigation setup and methods 573 | setupNavigation() { 574 | const prevButton = this.shadowRoot.querySelector('.navigation-button.prev-horizontal, .navigation-button.prev-vertical'); 575 | const nextButton = this.shadowRoot.querySelector('.navigation-button.next-horizontal, .navigation-button.next-vertical'); 576 | if (prevButton) prevButton.addEventListener('click', () => this.navigate(-1)); 577 | if (nextButton) nextButton.addEventListener('click', () => this.navigate(1)); 578 | } 579 | 580 | navigate(direction) { 581 | const newIndex = Math.max(0, Math.min(this.currentIndex + direction, this._cards.length - 1)); 582 | this.scrollToCard(newIndex); 583 | } 584 | 585 | // Card scrolling methods 586 | scrollToCard(index) { 587 | const slider = this.shadowRoot.querySelector(`.${this.config.template}`); 588 | if (!slider) return; 589 | 590 | const isHorizontal = this.config.template === 'slider-horizontal'; 591 | let scrollPosition = 0; 592 | 593 | for (let i = 0; i < index; i++) { 594 | scrollPosition += isHorizontal ? this._cards[i].clientWidth : this._cards[i].clientHeight; 595 | } 596 | 597 | slider.scrollTo({ 598 | [isHorizontal ? 'left' : 'top']: scrollPosition, 599 | behavior: 'smooth' 600 | }); 601 | 602 | this.updateCurrentIndex(); 603 | this.updatePagination(); 604 | 605 | if (this.config.timer > 0) { 606 | this.resetTimer(); 607 | } 608 | } 609 | 610 | scrollToCardByIndex(index) { 611 | return new Promise((resolve) => { 612 | const slider = this.shadowRoot.querySelector(`.${this.config.template}`); 613 | if (!slider) { 614 | resolve(); 615 | return; 616 | } 617 | const isHorizontal = this.config.template === 'slider-horizontal'; 618 | const maxIndex = this._cards.length - 1; 619 | const safeIndex = Math.max(0, Math.min(Math.round(index), maxIndex)); 620 | const scrollPosition = safeIndex * (isHorizontal ? slider.clientWidth : slider.clientHeight); 621 | 622 | const scrollEndHandler = () => { 623 | slider.removeEventListener('scrollend', scrollEndHandler); 624 | this.updatePagination(); 625 | resolve(); 626 | }; 627 | 628 | slider.addEventListener('scrollend', scrollEndHandler); 629 | 630 | slider.scrollTo({ 631 | [isHorizontal ? 'left' : 'top']: scrollPosition, 632 | behavior: 'smooth' 633 | }); 634 | }); 635 | } 636 | 637 | // Timer setup and reset methods 638 | setupTimer() { 639 | if (this.config.timer > 0) { 640 | this.resetTimer(); 641 | const slider = this.shadowRoot.querySelector(`.${this.config.template}`); 642 | slider.addEventListener('scroll', () => this.resetTimer()); 643 | slider.addEventListener('click', () => this.resetTimer()); 644 | slider.addEventListener('touchend', () => this.resetTimer()); 645 | } 646 | } 647 | 648 | resetTimer() { 649 | if (this.timerInterval) { 650 | clearInterval(this.timerInterval); 651 | } 652 | this.timerInterval = setTimeout(() => { 653 | const slider = this.shadowRoot.querySelector(`.${this.config.template}`); 654 | this.scrollToCard(0); 655 | }, this.config.timer * 1000); 656 | } 657 | 658 | // Cleanup method 659 | disconnectedCallback() { 660 | if (this.resizeObserver) { 661 | this.resizeObserver.disconnect(); 662 | } 663 | if (this.timerInterval) { 664 | clearInterval(this.timerInterval); 665 | } 666 | } 667 | 668 | // Custom styles application method 669 | applyCustomStyles() { 670 | const style = document.createElement('style'); 671 | style.textContent = Object.entries(this.config.custom_css) 672 | .map(([property, value]) => `#${this.cardId} { ${property}: ${value}; }`) 673 | .join('\n'); 674 | this.shadowRoot.appendChild(style); 675 | } 676 | } 677 | 678 | customElements.define('css-swipe-card', CssSwipeCard); 679 | 680 | window.customCards = window.customCards || []; 681 | window.customCards.push({ 682 | type: "css-swipe-card", 683 | name: "CSS Swipe Card", 684 | description: "A custom swipe card and carousel" 685 | }); 686 | --------------------------------------------------------------------------------