├── README.md ├── collapsable-cards.js ├── examples └── collapsable-cards.mp4 └── hacs.json /README.md: -------------------------------------------------------------------------------- 1 | # Collapsable cards 2 | 3 | Hide a list of cards behind a dropdown. 4 | 5 | https://user-images.githubusercontent.com/3329319/117338763-db269b80-ae96-11eb-8b1a-36e96d3b3d67.mov 6 | 7 | Big thanks to [ofekashery, the author of vertical-stack-in-card](https://github.com/ofekashery/vertical-stack-in-card), whose code I copied to make this card. 8 | 9 | ## Options 10 | 11 | | Name | Type | Default | Description | 12 | | ---------- | ------- | ------------ | ----------------------------------------- | 13 | | type | string | | `custom:collapsable-cards` | 14 | | cards | list | | List of cards | 15 | | defaultOpen | string | false | Whether the cards should be visible by default. Can also be set to `desktop-only` to be open by default on desktop and collapsed by default on mobile. Or `contain-toggled` to open only if there are active entities | 16 | | title | string | "Toggle" | Dropdown title | 17 | | title_card | card | | Card to display in place of the dropdown title | 18 | | buttonStyle| string | "" | CSS overrides for the dropdown toggle button | 19 | 20 | ## Installation 21 | 22 | # HACS 23 | 24 | Add this repository via HACS Custom repositories 25 | 26 | https://github.com/RossMcMillan92/lovelace-collapsable-cards 27 | 28 | ([How to add Custom Repositories](https://hacs.xyz/docs/faq/custom_repositories/)) 29 | 30 | # Manually 31 | [In-depth tutorial here](https://github.com/thomasloven/hass-config/wiki/Lovelace-Plugins), otherwise follow these steps: 32 | 33 | 1. Install the `collapsable-cards` card by copying `collapsable-cards.js` to `/www/collapsable-cards.js` 34 | 35 | 2. On your lovelace dashboard 36 | 1. Click options 37 | 2. Edit dashboard 38 | 3. Click Options 39 | 4. Manage resources 40 | 5. Add resource 41 | - URL: /local/collapsable-cards.js 42 | - Resource type: JavaScript module 43 | 44 | 3. Add a custom card to your dashboard 45 | 46 | 47 | ```yaml 48 | type: 'custom:collapsable-cards' 49 | title: Office 50 | cards: 51 | - type: entities 52 | entities: 53 | - entity: light.office_desk_led 54 | - entity: light.office_led_strips 55 | - entity: sensor.ross_work_laptop_is_on 56 | show_header_toggle: false 57 | ``` 58 | -------------------------------------------------------------------------------- /collapsable-cards.js: -------------------------------------------------------------------------------- 1 | class CollapsableCards extends HTMLElement { 2 | 3 | constructor() { 4 | super(); 5 | this.containers = ["grid", "custom:collapsable-cards", "vertical-stack", "horizontal-stack"]; 6 | this.differentConfig = ["HUI-THERMOSTAT-CARD"]; 7 | } 8 | 9 | setConfig(config) { 10 | this.id = Math.round(Math.random() * 10000) 11 | this._cardSize = {}; 12 | this._cardSize.promise = new Promise((resolve) => (this._cardSize.resolve = resolve)); 13 | 14 | if (!config || !config.cards || !Array.isArray(config.cards)) { 15 | throw new Error('Supply the `cards` property'); 16 | } 17 | 18 | let isMobile = window.matchMedia("only screen and (max-width: 760px)").matches; 19 | if (config.defaultOpen == true) { 20 | this.isToggled = true; 21 | } else if (config.defaultOpen == 'desktop-only' && !isMobile) { 22 | this.isToggled = true; 23 | } else { 24 | this.isToggled = false; 25 | } 26 | 27 | this._config = config; 28 | this._refCards = []; 29 | this._titleCard = null; 30 | this.renderCard(); 31 | } 32 | 33 | async renderCard() { 34 | const config = this._config; 35 | if (window.loadCardHelpers) { 36 | this.helpers = await window.loadCardHelpers(); 37 | } 38 | const promises = config.cards.map((config) => this.createCardElement(config)); 39 | this._refCards = await Promise.all(promises); 40 | 41 | if (config.title_card) { 42 | this._titleCard = await this.createCardElement(config.title_card); 43 | } 44 | 45 | // Create the card 46 | const card = document.createElement('ha-card'); 47 | this.card = card 48 | const cardList = document.createElement('div'); 49 | this.cardList = cardList 50 | card.style.overflow = 'hidden'; 51 | this._refCards.forEach((card) => cardList.appendChild(card)); 52 | this.cardList.className = 'card-list-' + this.id 53 | this.cardList.classList[this.isToggled ? 'add' : 'remove']('is-toggled') 54 | 55 | // create the button 56 | const toggleButton = this.createToggleButton() 57 | 58 | card.appendChild(toggleButton); 59 | card.appendChild(cardList); 60 | 61 | while (this.hasChildNodes()) { 62 | this.removeChild(this.lastChild); 63 | } 64 | this.appendChild(card); 65 | 66 | // Calculate card size 67 | this._cardSize.resolve(); 68 | 69 | const styleTag = document.createElement('style') 70 | styleTag.innerHTML = this.getStyles() 71 | card.appendChild(styleTag); 72 | 73 | if (config.defaultOpen === 'contain-toggled') { 74 | this.toggle(this.isCardActive()); 75 | } 76 | } 77 | 78 | getEntitiesNames(card) { 79 | card = card.hasOwnProperty('tagName') && this.differentConfig.includes(card.tagName) ? card.___config : card; 80 | 81 | if (this.containers.includes(card.type)) 82 | return [].concat(... card.cards.map( (c) => this.getEntitiesNames(c) )); 83 | 84 | if (card.hasOwnProperty('entity')) 85 | return [card.entity]; 86 | 87 | if (card.hasOwnProperty('entities')) 88 | return card.entities; 89 | 90 | return []; 91 | } 92 | 93 | isCardActive() { 94 | return this.getEntitiesNames(this._config).filter((e) => this._hass.states[e].state !== "off").length > 0 95 | } 96 | 97 | toggle(open = null) { 98 | this.isToggled = open === null ? !this.isToggled : open; 99 | this.styleCard(this.isToggled); 100 | } 101 | 102 | createToggleButton() { 103 | const toggleButton = document.createElement('button'); 104 | if (this._titleCard) { 105 | toggleButton.append(this._titleCard); 106 | } else { 107 | toggleButton.innerHTML = this._config.title || 'Toggle'; 108 | } 109 | toggleButton.className = 'card-content toggle-button-' + this.id 110 | toggleButton.addEventListener('click', () => { 111 | this.isToggled = !this.isToggled 112 | this.styleCard(this.isToggled) 113 | }) 114 | 115 | const icon = document.createElement('ha-icon'); 116 | icon.className = 'toggle-button__icon-' + this.id 117 | icon.setAttribute('icon', 'mdi:chevron-down') 118 | this.icon = icon 119 | toggleButton.appendChild(icon) 120 | 121 | return toggleButton 122 | } 123 | 124 | styleCard(isToggled) { 125 | this.cardList.classList[isToggled ? 'add' : 'remove']('is-toggled') 126 | this.icon.setAttribute('icon', isToggled ? 'mdi:chevron-up' : 'mdi:chevron-down') 127 | } 128 | 129 | async createCardElement(cardConfig) { 130 | const createError = (error, origConfig) => { 131 | return createThing('hui-error-card', { 132 | type: 'error', 133 | error, 134 | origConfig 135 | }); 136 | }; 137 | 138 | const createThing = (tag, config) => { 139 | if (this.helpers) { 140 | if (config.type === 'divider') { 141 | return this.helpers.createRowElement(config); 142 | } else { 143 | return this.helpers.createCardElement(config); 144 | } 145 | } 146 | 147 | const element = document.createElement(tag); 148 | try { 149 | element.setConfig(config); 150 | } catch (err) { 151 | console.error(tag, err); 152 | return createError(err.message, config); 153 | } 154 | return element; 155 | }; 156 | 157 | let tag = cardConfig.type; 158 | if (tag.startsWith('divider')) { 159 | tag = `hui-divider-row`; 160 | } else if (tag.startsWith('custom:')) { 161 | tag = tag.substr('custom:'.length); 162 | } else { 163 | tag = `hui-${tag}-card`; 164 | } 165 | 166 | const element = createThing(tag, cardConfig); 167 | element.hass = this._hass; 168 | element.addEventListener( 169 | 'll-rebuild', 170 | (ev) => { 171 | ev.stopPropagation(); 172 | this.createCardElement(cardConfig).then(() => { 173 | this.renderCard(); 174 | }); 175 | }, 176 | { once: true } 177 | ); 178 | return element; 179 | } 180 | 181 | redrawCard(oldHass) { 182 | if (this._config.defaultOpen === 'contain-toggled' && this._haveEntitiesChanged(oldHass)) this.renderCard(); 183 | } 184 | 185 | set hass(hass) { 186 | let oldHass = this._hass; 187 | this._hass = hass; 188 | 189 | if (this._refCards) { 190 | this._refCards.forEach((card) => { 191 | card.hass = hass; 192 | }); 193 | } 194 | 195 | if (this._titleCard) { 196 | this._titleCard.hass = hass; 197 | } 198 | this.redrawCard(oldHass); 199 | } 200 | 201 | _computeCardSize(card) { 202 | if (typeof card.getCardSize === 'function') { 203 | return card.getCardSize(); 204 | } 205 | return customElements 206 | .whenDefined(card.localName) 207 | .then(() => this._computeCardSize(card)) 208 | .catch(() => 1); 209 | } 210 | 211 | _haveEntitiesChanged(oldHass) { 212 | if (!this._hass || !oldHass) return true; 213 | 214 | for (const entity of this.getEntitiesNames(this._config)) { 215 | if (this._hass.states[entity].state !== oldHass.states[entity].state) return true; 216 | } 217 | 218 | return false; 219 | } 220 | 221 | async getCardSize() { 222 | await this._cardSize.promise; 223 | const sizes = await Promise.all(this._refCards.map(this._computeCardSize)); 224 | return sizes.reduce((a, b) => a + b); 225 | } 226 | 227 | getStyles() { 228 | return ` 229 | .toggle-button-${this.id} { 230 | color: var(--primary-text-color); 231 | text-align: left; 232 | background: none; 233 | border: none; 234 | margin: 0; 235 | display: flex; 236 | justify-content: space-between; 237 | align-items: center; 238 | width: 100%; 239 | border-radius: var(--ha-card-border-radius, 4px); 240 | ${this._config.buttonStyle || ''} 241 | } 242 | .toggle-button-${this.id}:focus { 243 | outline: none; 244 | background-color: var(--divider-color); 245 | } 246 | 247 | .card-list-${this.id} { 248 | position: absolute; 249 | width: 1px; 250 | height: 1px; 251 | margin: 0; 252 | padding: 0; 253 | overflow: hidden; 254 | clip: rect(0 0 0 0); 255 | clip-path: inset(50%); 256 | border: 0; 257 | white-space: nowrap; 258 | } 259 | 260 | .card-list-${this.id}.is-toggled { 261 | position: unset; 262 | width: unset; 263 | height: unset; 264 | margin: unset; 265 | padding: unset; 266 | overflow: unset; 267 | clip: unset; 268 | clip-path: unset; 269 | border: unset; 270 | white-space: unset; 271 | } 272 | 273 | .toggle-button__icon-${this.id} { 274 | color: var(--paper-item-icon-color, #aaa); 275 | } 276 | 277 | .type-custom-collapsable-cards { 278 | background: transparent; 279 | } 280 | `; 281 | } 282 | 283 | } 284 | 285 | customElements.define('collapsable-cards', CollapsableCards); 286 | 287 | window.customCards = window.customCards || []; 288 | window.customCards.push({ 289 | type: "collapsable-cards", 290 | name: "Collapsable Card", 291 | preview: false, 292 | description: "The Collapsable Card allows you to hide other cards behind a dropdown toggle." 293 | }); 294 | -------------------------------------------------------------------------------- /examples/collapsable-cards.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RossMcMillan92/lovelace-collapsable-cards/e582f21f288545d9362dede665f07b7226d00a59/examples/collapsable-cards.mp4 -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Collapsable cards", 3 | "content_in_root": true, 4 | "filename": "collapsable-cards.js", 5 | "render_readme": true 6 | } --------------------------------------------------------------------------------