Showing preview only (234K chars total). Download the full file or copy to clipboard to get everything.
Repository: MelleD/lovelace-expander-card
Branch: main
Commit: 5d569a3a31ae
Files: 78
Total size: 214.2 KB
Directory structure:
gitextract_6hobl9xp/
├── .github/
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ └── feature_request.md
│ ├── copilot-instructions.md
│ ├── dependabot.yml
│ ├── labeler.yml
│ ├── release.yml
│ └── workflows/
│ ├── autoClose.yml
│ ├── build.yml
│ ├── label.yml
│ ├── release-workflow.yml
│ └── validate.yml
├── .gitignore
├── .npmrc
├── .vscode/
│ ├── extensions.json
│ └── tasks.json
├── Makefile
├── README.md
├── docs/
│ ├── mkdocs.yml
│ ├── scenarios/
│ │ └── .gitkeep
│ └── source/
│ ├── chapter/
│ │ ├── configuration/
│ │ │ ├── configuration-overview.md
│ │ │ ├── examples.md
│ │ │ ├── gui-configuration.md
│ │ │ └── index.md
│ │ ├── contribution/
│ │ │ └── contribution.md
│ │ ├── faq/
│ │ │ └── faq.md
│ │ ├── style/
│ │ │ ├── card-mod.md
│ │ │ ├── hover.md
│ │ │ ├── index.md
│ │ │ ├── style.md
│ │ │ └── styling-examples.md
│ │ └── templating/
│ │ ├── action.md
│ │ ├── index.md
│ │ └── template.md
│ ├── index.md
│ └── quick-start.md
├── eslint.config.mjs
├── hacs.json
├── license.txt
├── package.json
├── pyproject.toml
├── rollup.config.mjs
├── src/
│ ├── Card.svelte
│ ├── ExpanderCard.svelte
│ ├── ExpanderCardEditor.ts
│ ├── configtype.ts
│ ├── editortype.ts
│ ├── helpers/
│ │ ├── compute-card-size.ts
│ │ ├── forward-haptic.ts
│ │ ├── ha-dialog-styles.ts
│ │ ├── promise-timeout.ts
│ │ ├── raw-config.ts
│ │ ├── style-converter.ts
│ │ └── templates.ts
│ ├── index.ts
│ ├── title-card/
│ │ ├── showTitleCardEditForm.ts
│ │ └── titleCardEditForm.ts
│ └── types.ts
├── svelte.config.js
├── tests/
│ ├── conftest.py
│ ├── doc-image-audit-exclusions.txt
│ ├── ha-config/
│ │ ├── configuration.yaml
│ │ ├── customize.yaml
│ │ └── www/
│ │ └── .gitkeep
│ ├── plugins.yaml
│ ├── test_doc_audit.py
│ └── visual/
│ ├── conftest.py
│ ├── scenarios/
│ │ └── expander/
│ │ ├── expander_01_collapsed.yaml
│ │ ├── expander_02_expanded.yaml
│ │ ├── expander_03_expand_click.yaml
│ │ ├── expander_04_clear_background.yaml
│ │ ├── expander_05_title_card.yaml
│ │ └── expander_06_nested.yaml
│ ├── snapshots/
│ │ └── .gitkeep
│ ├── test_doc_images.py
│ └── test_scenarios.py
├── tsconfig.json
└── vite.config.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/FUNDING.yml
================================================
github: [MelleD]
buy_me_a_coffee: melled
custom: ["https://www.paypal.me/MelleDennis"]
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
READ THIS FIRST:
Thanks for raising a expander card issue. Please take the time to review the following
categories as some of them do not apply here.
🙅 "Please DO NOT Raise an Issue" Cases
- Question
STOP!! Please ask questions about how to use something, or to understand why something isn't
working as you expect it to Github discussion or on forum https://community.home-assistant.io/t/expander-accordion-collapsible-card/738817/4.
- Managed Dependency Upgrade
You DO NOT need to raise an issue for a managed dependency version upgrade as there's a semi-automatic process for checking managed dependencies for new versions before a release. BUT pull requests for upgrades that are more involved than just a version property change are still most welcome.
- With an Immediate Pull Request
An issue will be closed as a duplicate of the immediate pull request, so you don't have to raise an issue if you plan to create a pull request immediately.
🐞 Bug report (please don't include this emoji/text, just add your details)
Please provide details of the problem, including the version of expander card and your Brwoser that you
are using. If possible, please provide a test case or sample application that reproduces
the problem. This makes it much easier for us to diagnose the problem and to verify that
we have fixed it
For quick troubleshooting, prepare a [minimally reproducible example](https://en.wikipedia.org/wiki/Minimal_reproducible_example).
!!!Please check your Browser console for Javascript errors!!!
TIP: You can always edit your issue if it isn't formatted correctly.
See https://guides.github.com/features/mastering-markdown
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: feature request
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
Please start by describing the problem that you are trying to solve. There may already
be a solution, or there may be a way to solve it that you hadn't considered.
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
TIP: You can always edit your issue if it isn't formatted correctly.
See https://guides.github.com/features/mastering-markdown
================================================
FILE: .github/copilot-instructions.md
================================================
---
applyTo: '**/*.svelte,**/*.ts,**/*.js'
---
# Svelte & Lovelace Expander Card Instructions for Copilot Reviewer
## Project Context
This is a Home Assistant Lovelace Custom Card (lovelace-expander-card). It adds expandable UI elements to HA dashboards. Use Lit-like HA conventions combined with Svelte 5 Runes for reactive components.
## Code Style & Best Practices
- Use Svelte 5 Runes ($state, $derived, $effect) for all reactive state—no legacy Svelte 4 stores.
- Keep components small and composable; prefer <200 lines per .svelte file when practical.
- Use TypeScript everywhere: Define interfaces for props like `cardConfig: LovelaceCardConfig`.
- Format with Prettier + prettier-plugin-svelte: No indentation inside <script> and <style> tags.
- Avoid manual DOM manipulation in Svelte components; prefer Svelte transitions and actions. When DOM access is required for Home Assistant web component interop (e.g., `hui-card`), use it sparingly, document/justify it in comments, and keep the logic minimal.
## Home Assistant Integration
- Type props using real HA types such as `HomeAssistant` and `LovelaceCardConfig`.
- Use `ha-card`, `ha-icon`, `ha-switch` as Home Assistant web components provided at runtime (no `home-assistant-frontend` import in this project).
- Update entity state reactively using the existing Home Assistant typing/helpers and $derived.
- Support YAML config: Validate `config.expanded` as boolean.
- No global state; keep everything local per card.
## Performance & Accessibility
- Memoize expensive computations with $derived.
- Avoid unnecessary re-renders: Keep runes in `<script>` (`$state`, `$derived`, `$effect`) and reference the resulting values directly in markup.
- Add ARIA attributes: `aria-expanded`, `role="button/region"`.
- Lazy-load icons and styles only when needed.
## Security & HA Standards
- Validate all config user inputs (sanitize HTML if needed).
- Avoid `eval()` and avoid `innerHTML`/Svelte `{@html}` wherever possible; if rendering HTML is unavoidable, add DOMPurify as a dependency and sanitize all HTML before use.
- Handle `hass.user.is_admin` for advanced features.
## Review Style
- Be concrete and actionable: Suggest code fixes with examples.
- Prioritize: 1. Bugs, 2. Performance, 3. Style.
================================================
FILE: .github/dependabot.yml
================================================
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
# Enable version updates for npm
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
# Enable version updates for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
================================================
FILE: .github/labeler.yml
================================================
# Add/remove labels to PR
breaking-change:
- '(breaking-change|major-update)'
bugfix:
- '(fix)'
documentation:
- '(doc|docs)'
miscellaneous:
- '(misc)'
feature:
- '(feat)'
================================================
FILE: .github/release.yml
================================================
changelog:
categories:
- title: ⚠️ Breaking Changes
labels:
- major-update
- breaking-change
- title: 🐞 Bug Fixes
labels:
- bugfix
- title: 📦 Dependency Upgrades
labels:
- dependencies
- title: 📔 Documentation
labels:
- documentation
- title: ⚙️ Miscellaneous
labels:
- miscellaneous
- title: ⭐ New Features
labels:
- "*"
================================================
FILE: .github/workflows/autoClose.yml
================================================
name: Close inactive issues
on:
schedule:
- cron: "30 1 * * *"
workflow_dispatch:
jobs:
close-issues:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f #10.2.0
with:
days-before-issue-stale: 30
days-before-issue-close: 14
stale-issue-label: "stale"
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity."
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
days-before-pr-stale: -1
days-before-pr-close: -1
repo-token: ${{ secrets.GITHUB_TOKEN }}
any-of-labels: "wait-for-user-input,wait-for-mre"
================================================
FILE: .github/workflows/build.yml
================================================
name: 'Build'
on:
pull_request:
branches:
- main
push:
branches:
- main
jobs:
build:
permissions:
contents: read
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #6.0.2
- name: Setup Git
run: |
git config user.name github-actions
git config user.email github-actions@github.com
- uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788
name: Install pnpm
with:
version: 9
run_install: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e #6.4.0
name: Install Node.js
with:
node-version: 24
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm run build
- name: Archive production artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #7.0.1
with:
name: expander-card
path: |
dist
================================================
FILE: .github/workflows/label.yml
================================================
name: 'Label PR'
on:
pull_request_target:
types: [opened, edited, synchronize]
branches:
- main
jobs:
label:
permissions:
contents: read
pull-requests: write
issues: write
name: Label Pull Request
runs-on: ubuntu-latest
steps:
- name: Checkout configuration
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #6.0.2
# Hier wird bewusst main ausgecheckt für die Config-Datei
- name: Label issues
uses: github/issue-labeler@c1b0f9f52a63158c4adc09425e858e87b32e9685 #3.4.0
with:
configuration-path: .github/labeler.yml
include-title: 1
include-body: 0
enable-versioned-regex: 0
repo-token: ${{ secrets.GITHUB_TOKEN }}
================================================
FILE: .github/workflows/release-workflow.yml
================================================
name: "Create Tagged Release"
on:
workflow_dispatch:
inputs:
release_version:
description: 'Version number of the release'
required: true
prerelease:
description: 'Mark as pre-release (RC versions)'
required: false
type: boolean
default: false
permissions:
contents: write
pages: write
id-token: write
jobs:
gh_tagged_release:
name: Create tagged release
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout project
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #6.0.2
- name: Setup Git
run: |
git config user.name github-actions
git config user.email github-actions@github.com
- uses: pnpm/action-setup@91ab88e2619ed1f46221f0ba42d1492c02baf788
name: Install pnpm
with:
version: 9
run_install: false
- name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
with:
node-version: 24
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Set app version (Unix)
run: npm version ${{ github.event.inputs.release_version }} --no-git-tag-version
- name: Build Project
run: pnpm run build
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d #v6.0.0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 #6.2.0
with:
python-version: 3.x
- run: pip install zensical
working-directory: docs
- run: zensical build --clean
working-directory: docs
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 #5.0.0
with:
path: docs/site
- uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 #5.0.0
- name: "Create Github release (full)"
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda #v2
id: expander_card_release
with:
body: "Release version ${{ github.event.inputs.release_version }}."
tag_name: ${{ github.event.inputs.release_version }}
target_commitish: "main"
draft: false
prerelease: ${{ github.event.inputs.prerelease }}
files: dist/*
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: "Notify issues of release their fix is contained in"
if: ${{ !github.event.inputs.prerelease }}
uses: apexskier/github-release-commenter@e7813a9625eabd79a875b4bc4046cfcae377ab34 # v1.4.1
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
comment-template: |
Release {release_link} addresses this.
================================================
FILE: .github/workflows/validate.yml
================================================
name: Validate
on:
push:
branches:
- main
workflow_dispatch:
jobs:
validate-hacs:
runs-on: "ubuntu-latest"
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #6.0.2
- name: HACS validation
uses: hacs/action@d556e736723344f83838d08488c983a15381059a
with:
category: "plugin"
================================================
FILE: .gitignore
================================================
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
/dist
.venv
.npm
docs/site
# Visual test artifacts
tests/visual/snapshots/*.actual.png
tests/ha-config/www/expander-card.js
.ha_env
__pycache__/
*.pyc
.pytest_cache/
src/*.egg-info/
================================================
FILE: .npmrc
================================================
engine-strict=true
================================================
FILE: .vscode/extensions.json
================================================
{
"recommendations": [
"davidanson.vscode-markdownlint"
]
}
================================================
FILE: .vscode/tasks.json
================================================
{
"version": "2.0.0",
"tasks": [
// -------------------------------------------------------------------------
// Python — virtual environment setup
// -------------------------------------------------------------------------
// Creates .venv, installs all test dependencies, and installs the
// Playwright Chromium browser. Run this once after cloning the repo.
{
"label": "Python: Set up virtual environment",
"type": "shell",
"command": "python3 -m venv .venv && .venv/bin/pip install -e '.[test]' && .venv/bin/playwright install chromium",
"problemMatcher": [],
"presentation": {
"panel": "dedicated",
"reveal": "always",
"group": "setup"
}
},
// -------------------------------------------------------------------------
// Home Assistant — persistent dev server
// -------------------------------------------------------------------------
// Starts a long-lived HA container once and leaves it running so pytest
// can be re-run without waiting for HA to boot each time.
// Writes HA_URL + HA_TOKEN to .ha_env; the pytest tasks below source it
// automatically. Press Ctrl-C in this panel to stop HA.
{
"label": "HA: Start persistent server",
"type": "shell",
"command": "HA_CONFIG_PATH=tests/ha-config HA_PLUGINS_YAML=tests/plugins.yaml .venv/bin/python -m ha_testcontainer.ha_server",
"isBackground": true,
"problemMatcher": {
"owner": "ha-server",
"pattern": {
"regexp": "^$"
},
"background": {
"activeOnStart": true,
"beginsPattern": "^Starting Home Assistant",
"endsPattern": "Home Assistant is ready at"
}
},
"presentation": {
"panel": "dedicated",
"reveal": "always",
"group": "ha"
}
},
// -------------------------------------------------------------------------
// pytest — visual / integration tests
//
// NOTE: These tasks use POSIX shell features (`. .ha_env`, env-var prefixes,
// `2>/dev/null`). They require Bash (or another POSIX-compatible shell) as
// the VS Code terminal shell. On Windows, set your terminal profile to Git
// Bash or WSL before running these tasks; PowerShell/cmd will not work.
//
// Each task uses the .venv binaries directly and loads .ha_env (if present)
// before running so that a pre-running HA instance started by
// "HA: Start persistent server" is used automatically — no Docker boot
// wait. If .ha_env doesn't exist the dot-source silently fails and pytest
// spins up a fresh container.
// -------------------------------------------------------------------------
{
"label": "pytest: All tests",
"type": "shell",
"command": ". .ha_env 2>/dev/null; .venv/bin/pytest tests/",
"group": "test",
"problemMatcher": [],
"presentation": {
"panel": "shared",
"group": "pytest"
}
},
{
"label": "pytest: All tests (Update)",
"type": "shell",
"command": ". .ha_env 2>/dev/null; SNAPSHOT_UPDATE=1 DOC_IMAGE_UPDATE=1 .venv/bin/pytest tests/",
"group": "test",
"problemMatcher": [],
"presentation": {
"panel": "shared",
"group": "pytest"
}
},
{
"label": "pytest: All scenarios",
"type": "shell",
"command": ". .ha_env 2>/dev/null; .venv/bin/pytest tests/visual/test_scenarios.py",
"group": "test",
"problemMatcher": [],
"presentation": {
"panel": "shared",
"group": "pytest"
}
},
{
"label": "pytest: All scenarios (Update)",
"type": "shell",
"command": ". .ha_env 2>/dev/null; SNAPSHOT_UPDATE=1 .venv/bin/pytest tests/visual/test_scenarios.py",
"group": "test",
"problemMatcher": [],
"presentation": {
"panel": "shared",
"group": "pytest"
}
},
// Prompts for a scenario id (or -k expression) and runs just that test.
// Start "HA: Start persistent server" first for instant re-runs — no
// Docker boot wait. Re-running is safe: the dashboard is created once
// and then overwritten per-scenario; there are no stale-state issues.
{
"label": "pytest: Run single scenario",
"type": "shell",
"command": ". .ha_env 2>/dev/null; .venv/bin/pytest tests/visual/test_scenarios.py -k '${input:scenarioId}'",
"group": "test",
"problemMatcher": [],
"presentation": {
"panel": "shared",
"group": "pytest"
}
},
{
"label": "pytest: Run single scenario — update (overwrite)",
"type": "shell",
"command": ". .ha_env 2>/dev/null; SNAPSHOT_UPDATE=1 .venv/bin/pytest tests/visual/test_scenarios.py -k '${input:scenarioId}'",
"group": "test",
"problemMatcher": [],
"presentation": {
"panel": "shared",
"group": "pytest"
}
},
{
"label": "pytest: Run single doc image — generate / verify",
"type": "shell",
"command": ". .ha_env 2>/dev/null; .venv/bin/pytest tests/visual/test_doc_images.py -k '${input:scenarioId}'",
"group": "test",
"problemMatcher": [],
"presentation": {
"panel": "shared",
"group": "pytest"
}
},
{
"label": "pytest: Run single doc image — update (overwrite)",
"type": "shell",
"command": ". .ha_env 2>/dev/null; DOC_IMAGE_UPDATE=1 .venv/bin/pytest tests/visual/test_doc_images.py -k '${input:scenarioId}'",
"group": "test",
"problemMatcher": [],
"presentation": {
"panel": "shared",
"group": "pytest"
}
},
// -------------------------------------------------------------------------
// pytest — documentation images
// -------------------------------------------------------------------------
{
"label": "pytest: Doc images — generate / verify",
"type": "shell",
"command": ". .ha_env 2>/dev/null; .venv/bin/pytest tests/visual/test_doc_images.py",
"group": "test",
"problemMatcher": [],
"presentation": {
"panel": "shared",
"group": "pytest"
}
},
{
"label": "pytest: Doc images — update all (overwrite)",
"type": "shell",
"command": ". .ha_env 2>/dev/null; DOC_IMAGE_UPDATE=1 .venv/bin/pytest tests/visual/test_doc_images.py",
"group": "test",
"problemMatcher": [],
"presentation": {
"panel": "shared",
"group": "pytest"
}
},
// Doc audit needs no HA instance — runs instantly.
{
"label": "pytest: Doc image audit",
"type": "shell",
"command": ".venv/bin/pytest tests/test_doc_audit.py",
"group": "test",
"problemMatcher": [],
"presentation": {
"panel": "shared",
"group": "pytest"
}
}
],
"inputs": [
{
"id": "scenarioId",
"type": "promptString",
"description": "Scenario id or pytest -k expression (e.g. expander_collapsed)"
}
]
}
================================================
FILE: Makefile
================================================
# Expander Card development helpers
#
# All targets assume you are running from the repository root with an activated
# Python virtual environment (source .venv/bin/activate) and the JS already
# built (pnpm run build).
#
# Quick reference
# ---------------
# make ha_up Start a persistent HA container for fast iterative work
# make doc_images_gen Generate any missing documentation images (first-run bootstrap)
# make doc_images_update Regenerate ALL documentation images (use after HA/card visual changes)
# make doc_audit Check that all doc images are scenario-generated or explicitly excluded
.PHONY: ha_up ha-tests-up doc_images_gen doc_images_update doc_audit
# Start a persistent Home Assistant container and leave it running.
# The script prints HA_URL and HA_TOKEN and writes them to .ha_env.
# In a second terminal: source .ha_env && pytest tests/visual/ -k <id>
# Press Ctrl-C here to stop HA.
ha_up ha-tests-up:
HA_CONFIG_PATH=tests/ha-config HA_PLUGINS_YAML=tests/plugins.yaml python -m ha_testcontainer.ha_server
# Run the doc-image test suite. Missing images are created automatically;
# existing images are verified against the current rendered output.
doc_images_gen:
pytest tests/visual/test_doc_images.py
# Regenerate all documentation images, overwriting any existing files.
# Use this after an intentional visual change to the card or Home Assistant,
# then review the diff and commit the updated PNGs/GIFs.
doc_images_update:
DOC_IMAGE_UPDATE=1 pytest tests/visual/test_doc_images.py
# Audit doc images: report any PNG/GIF referenced in docs/source/ that is
# neither generated by a scenario nor listed in tests/doc-image-audit-exclusions.txt.
# Exits non-zero (failing the test) if untracked images are found.
doc_audit:
pytest tests/test_doc_audit.py
================================================
FILE: README.md
================================================
# Expander Card for HomeAssistant
[![release][release-badge]][release-url]
![downloads][downloads-badge]
![build][build-badge]
[](https://sonarcloud.io/summary/new_code?id=MelleD_lovelace-expander-card)
[![PayPal.Me][paypal-me-badge]][paypal-me-url]
[![BuyMeCoffee][buy-me-a-coffee-shield]][buy-me-a-coffee-url]
Expander/Collapsible card for HomeAssistant
<img src="https://melled.github.io/lovelace-expander-card/chapter/assets/logo.png" width="400">
## Introduction
First a few words to start with. A big thank you goes to [@Alia5](https://github.com/Alia5/lovelace-expander-card), who initially launched the card. I forked this card for my own HomeAssistant to make a few improvements. I give no guarantee for the functionality and no promise of lifelong maintenance, as I do the whole thing in my free time. Of course, I am happy about every contribution and PR
## 🚀 Quick Start
Go to [Quick Start](https://melled.github.io/lovelace-expander-card/quick-start/) for installation instruction.
- [Full Documentation](https://melled.github.io/lovelace-expander-card)
## Support
Clone and create a PR to help make the card even better.
Please ⭐️ or sponsor this repo when you like it.
## Sponsor ❤️
<a href="" target="_blank"><img src="https://img.shields.io/static/v1.svg?label=%20&message=PayPal.Me&logo=paypal" alt="PayPal.Me MelleDennis" style="height: auto !important;width: auto !important;" ></a>
<a href="https://www.buymeacoffee.com/melled" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/white_img.png" alt="Buy Me A Coffee" style="height: auto !important;width: auto !important;" ></a>
<!-- Badges -->
[hacs-badge]: https://img.shields.io/badge/hacs-default-orange.svg?style=flat-square
[release-badge]: https://img.shields.io/github/v/release/MelleD/lovelace-expander-card?style=flat-square
[downloads-badge]: https://img.shields.io/github/downloads/MelleD/lovelace-expander-card/total?style=flat-square
[build-badge]: https://img.shields.io/github/actions/workflow/status/MelleD/lovelace-expander-card/build.yml?branch=main&style=flat-square
[paypal-me-badge]: https://img.shields.io/static/v1.svg?label=%20&message=PayPal.Me&logo=paypal
[buy-me-a-coffee-shield]: https://img.shields.io/static/v1.svg?label=%20&message=Buy%20me%20a%20coffee&color=6f4e37&logo=buy%20me%20a%20coffee&logoColor=white
<!-- References -->
[hacs-url]: https://github.com/hacs/integration
[home-assistant]: https://www.home-assistant.io/
[hacs]: https://hacs.xyz
[release-url]: https://github.com/MelleD/lovelace-expander-card/releases
[paypal-me-url]: https://www.paypal.me/MelleDennis
[buy-me-a-coffee-url]: https://www.buymeacoffee.com/melled
================================================
FILE: docs/mkdocs.yml
================================================
site_name: Expander Card documentation
docs_dir: source
theme:
name: material
variant: classic
features:
- navigation.tabs
- navigation.sections
- navigation.indexes
- toc.integrate
- navigation.top
- navigation.footer
- search.suggest
- search.highlight
- content.tabs.link
- content.code.annotation
- content.code.copy
- content.action.edit
palette:
- schema: default
toggle:
icon: material/toggle-switch-off-outline
name: Switch to dark mode
primary: indigo
accent: blue
- scheme: slate
toggle:
icon: material/toggle-switch
name: Switch to light mode
logo: chapter/assets/logo.png
favicon: chapter/assets/logo.png
primary: indigo
accent: light blue
extra:
generator: false
plugins:
- search
- git-revision-date-localized:
enable_creation_date: true
markdown_extensions:
- admonition
- pymdownx.details
- pymdownx.superfences
- pymdownx.tabbed:
alternate_style: true
- attr_list
- pymdownx.emoji:
emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:material.extensions.emoji.to_svg
nav:
- 'index.md'
- 'quick-start.md'
- 'Configuration':
- 'chapter/configuration/index.md'
- 'chapter/configuration/configuration-overview.md'
- 'chapter/configuration/gui-configuration.md'
- 'chapter/configuration/examples.md'
- 'Template':
- 'chapter/templating/index.md'
- 'chapter/templating/template.md'
- 'chapter/templating/action.md'
- 'Styling':
- 'chapter/style/index.md'
- 'chapter/style/style.md'
- 'chapter/style/styling-examples.md'
- 'chapter/style/hover.md'
- 'chapter/style/card-mod.md'
- 'Contribution':
- 'chapter/contribution/contribution.md'
- 'FAQ':
- 'chapter/faq/faq.md'
repo_url: https://github.com/MelleD/lovelace-expander-card
edit_uri: blob/master/docs/source/
================================================
FILE: docs/scenarios/.gitkeep
================================================
# Documentation-asset-only scenarios.
# Place YAML files here for scenarios that exist solely to generate doc images
# or animations (no functional assertions). These scenarios are picked up by
# tests/visual/test_doc_images.py but are NOT run by test_scenarios.py.
# See tests/visual/scenarios/ for scenarios that also include assertions.
================================================
FILE: docs/source/chapter/configuration/configuration-overview.md
================================================
# Configuration Overview
All configurations are available for editing in Graphical config editor. Config items supporting [templating](../templating/index.md) are shown with :octicons-project-template-24:.
|Name|Type|Default|Supported options|Description|
|---|---|---|---|---|
|type|string|**Required**|`custom:expander-card`|Type of the card.|
|title :octicons-project-template-24:|string|Empty|*|Title (Not displayed if using Title-Card)|
|icon :octicons-project-template-24:|string|mdi:chevron-down|mdi icon shortcut|Icon in button|
|expanded :octicons-project-template-24:|boolean|_false_|true\|false|Start expanded|
|animation|boolean|_true_|true\|false|Should the opening/closing of expander be animated?|
|haptic|string|_light_|light\|medium\|heavy\|success\|warning\|failure\|selection\|none|Haptic feedback type for Home Assistant Companion app on iOS and Android when expanding/collapsing the card. Set to 'none' to disable haptic feedback.|
|min-width-expanded|number|0|number|Min screen width (px) to be expanded on start (use with start expanded above)|
|max-width-expanded|number|0|number|Max screen width (px) to be expanded on start (use with start expanded above)|
|storage-id|string|**optional**|*|Save last expander state in local browser storage|
|expander-card-id|string|**optional**|*|An id to use with [Set state via action](../templating/action.md)|
|arrow-color :octicons-project-template-24:|string|primary-text-color,#fff|css-color|Color of icon expand button|
|icon-rotate-degree|string|_180deg_|css-rotate|Changing the degrees of the button icon when clicked|
|header-color|string|primary-text-color,#fff|css-color|Color of expand button|
|button-background|string|_transparent_|css-color|Background color of expand button|
|expander-card-background|string|ha-card-background, card-background-color,#fff|css-color|Expander Card Background|
|expander-card-background-expanded|string|Empty|css-color|Expander Card Background when card is opened/expanded|
|expander-card-display|string|block|css-display|Layout/Display of the card|
|clear|boolean|_false_|true\|false|Remove Background, border|
|gap|string|_0.0em_|css-size|gap between cards when expander is closed. This option depends on your CSS layout: You might need to use `expander-card-display: grid` for this.|
|padding|string|_1em_|css-size|padding of all card content|
|expanded-gap|string|_0.6em_|css-size|gap between child cards when expander open|
|child-padding|string|_0.0em_|css-size|padding of child cards|
|child-margin-top|string|_0.0em_|css-size|Margin top of child cards|
|clear-children|boolean|_false_|true\|false|Remove Background, border from children|
|title-card|object|**optional**|LovelaceCardConfig|Replace Title with card|
|title-card-clickable|boolean|_false_|true\|false|Should the complete div be clickable?|
|title-card-button-overlay|boolean|_false_|true\|false|Overlay expand button over title-card. If you set `title-card-clickable: true` the overlay will extend across the expander, both horizontally and vertically, and capture the click before the title-card. If you wish to adjust the overlay height you can style `height` on `.header-overlay`. See [Style](../style/style.md)|
|overlay-margin|string|_0.0em_|css-size|Margin from top right of expander button (if overlay)|
|title-card-padding|string|_0px_|css-size|padding of title-card|
|show-button-users|object[]|**optional**|*|Choose the persons/users that button is visible to them.|
|start-expanded-users|object[]|**optional**|*|Choose the persons/users that card will be start expanded for them.|
|cards|object[]|**optional**|LovelaceCardConfig[]|Child cards to show when expanded|
|style :octicons-project-template-24:|string or object|**optional**|css style rules or structured style object|Advanced css styling rules. Supports string format (CSS text) or object format (structured with selectors as keys and property string arrays as values). See [Style](../style/style.md) and [Styling Examples](../style/styling-examples.md).|
|variables|dictionary|**optional**|List|See Advanced javascript templates|
|templates|dictionary|**optional**|List|See Advanced javascript templates|
!!! tip "Editing `style` in Graphical config editor"
When editing `style` in Graphical config editor the default mode is CSS text. To enabled Structured object editing, switch to code editor mode, set style to be a YAML object, and switch back to visual editor mode. Your config will now always view as an Structured object in the visual editor.
================================================
FILE: docs/source/chapter/configuration/examples.md
================================================
# Examples
## Title card
This is the title card example from the video on the main page, a room card.
<img width="1032" height="280" alt="image" src="https://github.com/user-attachments/assets/afc16d00-1df8-490c-a57d-bdfb870bd797" />
It requires [Mushroom Cards](https://github.com/piitaya/lovelace-mushroom), [Vertical Stack-in Card](https://github.com/ofekashery/vertical-stack-in-card), and (Lovelace card-mod)(https://github.com/thomasloven/lovelace-card-mod)
```yaml
- type: custom:expander-card
child-margin-top: 0.6em
padding: 0
clear: true
title-card-button-overlay: true
title-card-clickable: true
expanded: true
title-card:
type: custom:vertical-stack-in-card
cards:
# --- TOP ROW ---
- type: horizontal-stack
cards:
- type: custom:mushroom-template-card
primary: Living Room
icon: mdi:sofa
layout: vertical
card_mod:
style:
mushroom-shape-icon$: |
.shape {
background: none !important;
--shape-color: transparent !important;
}
.: |
ha-card {
background: none !important;
border: none !important;
box-shadow: none !important;
/* This moves the entire card (icon + text) up by 6px */
margin-top: -15px !important;
}
mushroom-state-info {
margin-top: -25px !important; /* Keeps the tight text-to-icon gap */
}
mushroom-state-item {
gap: 0px !important;
}
:host {
--mush-icon-size: 70px;
--primary-text-color: white;
--primary-font-weight: 660;
}
- type: custom:mushroom-light-card
entity: light.fibaro_dimmer_2_living_room_spotlight
name: Lights
card_mod:
style: |
ha-card {
background: none !important;
border: none !important;
box-shadow: none !important;
/* Reduced from 35px to 18px */
margin-top: 8px !important;
/* Ensures the card doesn't cut off the text */
width: 100% !important;
}
# --- BOTTOM ROW (Chips) ---
- type: custom:mushroom-chips-card
alignment: center
chips:
- type: template
icon: mdi:thermometer
icon_color: orange
content: "21.71 °C"
card_mod:
style: |
ha-card {
box-shadow: none !important;
border: none !important;
}
- type: template
icon: mdi:water-percent
icon_color: blue
content: "44.89 %"
card_mod:
style: |
ha-card {
box-shadow: none !important;
border: none !important;
}
- type: template
icon: mdi:gauge
icon_color: red
content: "974.3 hPa"
card_mod:
style: |
ha-card {
box-shadow: none !important;
border: none !important;
}
- type: template
icon: mdi:flash
icon_color: yellow
content: "357 W"
card_mod:
style: |
ha-card {
box-shadow: none !important;
border: none !important;
}
```
Example title card that is clickable and has 2 nested cards, which is directly expanded
```yaml
- type: custom:expander-card
child-margin-top: 0.6em
padding: 0
clear: true
title-card-button-overlay: true
title-card-clickable: true
expanded: true
title-card:
type: "custom:digital-clock"
dateFormat:
weekday: "long"
day: "2-digit"
month: "short"
timeFormat:
hour: "2-digit"
minute: "2-digit"
cards:
- type: custom:simple-weather-card
entity: weather.openweathermap
primary_info:
- wind_speed
- wind_bearing
secondary_info:
- precipitation
- precipitation_probability
- type: custom:hourly-weather
entity: weather.openweathermap
icons: true
show_precipitation_probability: true
show_precipitation_amounts: true
forecast_type: "hourly"
num_segments: 10"
label_spacing: "1"
name: null
show_wind: speed
```
## Heading Title card
Example with [heading](https://www.home-assistant.io/dashboards/heading/) title card to provide the possibility of styling your title.
```yaml
- type: custom:expander-card
title-card:
type: heading
heading: Title
heading_style: title
badges:
- type: entity
show_name: false
show_state: true
show_icon: true
entity: light.bed_light
icon: mdi:account
```
## Template Title card with Mushroom
If you need templates in your title, you can make good use of the Mushroom cards. Here's an example using the [Mushroom title card](https://github.com/piitaya/lovelace-mushroom/blob/main/docs/cards/title.md).
```yaml
- type: custom:expander-card
title-card:
type: custom:mushroom-title-card
title: |-
{{ now().hour }}
```
## Simple Title
Example with title that is clickable and has 2 nested cards.
```yaml
- type: custom:expander-card
child-margin-top: 0.6em
padding: 0
title: "Test"
title-card-button-overlay: true
title-card-clickable: true
cards:
- type: custom:simple-weather-card
entity: weather.openweathermap
primary_info:
- wind_speed
- wind_bearing
secondary_info:
- precipitation
- precipitation_probability
- type: custom:hourly-weather
entity: weather.openweathermap
icons: true
show_precipitation_probability: true
show_precipitation_amounts: true
forecast_type: "hourly"
num_segments: 10"
label_spacing: "1"
name: null
show_wind: speed
```
## Title with min-width-expanded
Example with title that is clickable and has 2 nested cards which are automatically expanded when the screen is more than 300px.
```yaml
- type: custom:expander-card
child-margin-top: 0.6em
padding: 0
title: "Test"
title-card-button-overlay: true
title-card-clickable: true
min-width-expanded: 300
cards:
- type: custom:simple-weather-card
entity: weather.openweathermap
primary_info:
- wind_speed
- wind_bearing
secondary_info:
- precipitation
- precipitation_probability
name: in Gärtringen
- type: custom:hourly-weather
entity: weather.openweathermap
icons: true
show_precipitation_probability: true
show_precipitation_amounts: true
forecast_type: "hourly"
num_segments: 10"
label_spacing: "1"
show_wind: speed
```
## Title card with action
The configuration below will open or close the expander when you tap the Mushroom Light Card. This means you cannot switch the light on or off by tapping it, but you can still adjust the brightness.
```yaml
type: custom:expander-card
title: Expander Card
expander-card-id: my-light-card
cards:
- type: entities
entities:
- entity: sun.sun
title-card:
type: tile
entity: light.bed_light
vertical: false
features_position: inline
features:
- type: light-brightness
tap_action:
action: fire-dom-event
expander-card:
data:
expander-card-id: my-light-card
action: toggle
```
================================================
FILE: docs/source/chapter/configuration/gui-configuration.md
================================================
# Graphical config editor
Expander card aligns its concepts as much as possible to Home Assistant Frontend. As such, all expander cards are expanded by default when using UI edit/preview mode. This matches the concept of a Frontend conditional card showing when in edit/preview mode.
On a busy dashboard with many expander cards, and perhaps nested expander cards, showing all as expanded may make it difficult to find the expander card you wish to edit.
If you do not wish to have expander cards expanded by default when in edit/preview mode, you can set `preview-expanded: false` in `expander-card:` yaml key at the top of your dashboard "Raw configuration". It is recommended you place this at the top of your dashboard "Raw configuration" before any Home Assistant view/panel yaml configuration.
> NOTE: Any expander card using `expanded` templates will always honour the template when in preview/edit mode, including sending open state events to other expanders, regardless of the `preview-expanded:` setting. This way the preview/edit mode will match the current display of expander cards on the dashboard.
To access your dashboard "Raw configuration" from the UI use the following steps:
- Go to the dashboard you wish to edit.
- In the top-right corner, select the pencil icon. If you are on a small screen you may need to select the three dots menu ⋮ first and then select **Edit dashboard**.
- Select the three dots menu ⋮ and select **Raw configuration editor**.
Example yaml code to include
```yaml
expander-card:
preview-expanded: false
views:
- title: Home
# ... other dashboard "Raw configuration"
```
================================================
FILE: docs/source/chapter/configuration/index.md
================================================
# Configuration
This section provides you with a comprehensive overview of the diverse configuration and customization options available to help you optimally adapt the Expander Card to your needs.
## Configuration Overview
The Expander Card offers a wide range of configuration options that allow you to customize every aspect of the card. From basic settings like the title and initial state to advanced options such as custom animations, styling adjustments, and conditional displays.
Configuration is primarily done through YAML and can be applied individually per card. This allows you to use different Expander Cards with different settings on the same dashboard.
Key configuration areas include:
- **Basic Properties**: Title, icons, expansion states
- **Behavior**: Default state (expanded/collapsed), animations, gap settings
- **Styling**: Colors, fonts, spacing, backgrounds
- **Advanced Options**: Templating, conditional displays, button functions
- **Card Management**: Managing child cards within the expander
For a detailed overview of all available configuration options and their usage, please visit the [Configuration Overview](configuration-overview.md).
## Graphical Config Editor
To simplify configuration, the Expander Card provides a graphical configuration editor directly within the Home Assistant user interface. This editor allows you to make settings visually without having to manually edit YAML.
With the graphical editor, you can:
- **Configure Visually**: Change settings through user-friendly forms and dropdown menus
- **Live Preview**: See your changes in real-time as you configure the card
- **Validation**: Get immediate feedback on invalid settings
- **Easy Navigation**: Switch between different configuration sections using tabs
- **Code View**: Switch to YAML view when needed for advanced customizations
The graphical editor is particularly useful for beginners or when you want to make quick adjustments without needing to know the YAML syntax in detail.
For detailed information on using the graphical editor, see the [Graphical Configuration Editor Guide](gui-configuration.md).
## Examples
Learn best through practical examples! In our examples section, you'll find a collection of pre-configured Expander Cards demonstrating various use cases and styling possibilities.
Each example includes the complete YAML code. You can copy the examples directly and adapt them to your needs.
Visit the [YAML Examples Page](examples.md) to see what's possible and get inspired for your own dashboard designs!
================================================
FILE: docs/source/chapter/contribution/contribution.md
================================================
# 🤝 Contribution
Contributions of any kind are warmly welcomed! Whether you're reporting bugs, requesting features, improving documentation, or submitting code, your input helps make the Expander Card better for everyone.
## Feature Requests
Have an idea for a new feature or enhancement? We'd love to hear about it!
To submit a feature request:
1. Check if a similar request already exists in [existing issues](https://github.com/MelleD/lovelace-expander-card/issues)
2. If not, create a new feature request using our template
3. Describe the feature, its use case, and why it would be valuable
[Submit a Feature Request](https://github.com/MelleD/lovelace-expander-card/issues/new?template=feature_request.md)
**Template includes:**
- Feature description
- Use case scenarios
- Proposed implementation (optional)
- Alternative solutions considered
## Bug Reports
Found something that's not working as expected? Please let us know!
When reporting a bug:
1. Search [existing issues](https://github.com/MelleD/lovelace-expander-card/issues) to avoid duplicates
2. Use our bug report template for consistency
3. Include reproduction steps, expected vs actual behavior
4. Add relevant configuration YAML and screenshots if possible
5. Mention your Home Assistant version and browser
[Report a Bug](https://github.com/MelleD/lovelace-expander-card/issues/new?template=bug_report.md)
**What to include:**
- Clear description of the issue
- Steps to reproduce
- Expected behavior vs actual behavior
- YAML configuration (sanitized)
- Screenshots or videos (if applicable)
- Environment details (HA version, browser, etc.)
## Discussions
Want to share ideas, ask questions, or connect with the community? Join our discussions!
Discussion categories:
- **General**: Questions and general discussions
- **Ideas**: Brainstorm new features and improvements
- **Show and Tell**: Share your creative expander card configurations
- **Q&A**: Get help from the community
[Join the Discussion](https://github.com/MelleD/lovelace-expander-card/discussions)
**Perfect for:**
- Asking "how do I..." questions
- Sharing your custom configurations
- Discussing potential features before opening an issue
- Getting feedback on your setup
- Helping other users
## Pull Requests
The ultimate contribution! We appreciate code contributions, documentation improvements, and bug fixes.
### How to Submit a Pull Request
1. **Fork the repository**
```bash
# Fork via GitHub UI, then clone your fork
git clone https://github.com/YOUR_USERNAME/lovelace-expander-card.git
cd lovelace-expander-card
```
2. **Create a feature branch**
```bash
git checkout -b feature/AmazingFeature
# or
git checkout -b fix/BugFix
```
3. **Make your changes**
- Write clear, commented code
- Follow the existing code style
- Test your changes thoroughly
- Update documentation if needed
4. **Commit your changes**
```bash
git add .
git commit -m 'Add some AmazingFeature'
```
Use clear, descriptive commit messages
5. **Push to your fork**
```bash
git push origin feature/AmazingFeature
```
6. **Open a Pull Request**
- Go to the [original repository](https://github.com/MelleD/lovelace-expander-card)
- Click "New Pull Request"
- Select your fork and branch
- Provide a clear description of your changes
- Link any related issues
### Pull Request Guidelines
- **One feature per PR**: Keep pull requests focused on a single feature or fix
- **Test thoroughly**: Ensure your changes work in different scenarios
- **Update docs**: If you add features, update the documentation
- **Code style**: Match the existing code formatting and style
- **Commit messages**: Use clear, descriptive messages
- **Be patient**: Maintainers will review your PR as soon as possible
### Local Development Setup
#### Build & Install
```bash
# Install dependencies
npm install
# or
pnpm install
# Start development server
npm run srv
# Build for production
npm run build
# Run es linting
npm run lint
# Fix es linting
npm run lint-fix
```
#### Visual Tests
One-time setup — **VS Code**: open the *Terminal › Run Task* palette and choose *Python: Set up virtual environment*.
One-time setup — **Command line**:
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -e '.[test]'
playwright install chromium
```
**VS Code** — open the *Terminal › Run Task* palette and choose any `pytest:` task (run all, run single scenario, update snapshots, etc.).
**Command line** — activate the virtual environment first, then:
| Goal | Command |
|---|---|
| All tests | `pytest tests/` |
| All tests — update snapshots & doc images | `SNAPSHOT_UPDATE=1 DOC_IMAGE_UPDATE=1 pytest tests/` |
| Scenario tests only | `pytest tests/visual/test_scenarios.py` |
| Update scenario snapshots | `SNAPSHOT_UPDATE=1 pytest tests/visual/test_scenarios.py` |
| Single scenario | `pytest tests/visual/test_scenarios.py -k expander_collapsed` |
| Single scenario — update snapshot | `SNAPSHOT_UPDATE=1 pytest tests/visual/test_scenarios.py -k expander_collapsed` |
| Doc images — generate / verify | `pytest tests/visual/test_doc_images.py` |
| Doc images — update all | `DOC_IMAGE_UPDATE=1 pytest tests/visual/test_doc_images.py` |
| Doc image audit (no HA needed) | `pytest tests/test_doc_audit.py` |
> **Tip:** Start the persistent HA server (`python -m ha_testcontainer.ha_server` or the *HA: Start persistent server* VS Code task) before running tests to skip the Docker boot wait on every run.
> **Tip (debugging):** When the persistent server is running, its URL is logged in the terminal. You can open that URL in a browser and log in with the ha-testcontainer default credentials — username `testadmin`, password `testpassword123` — to inspect the HA instance manually. **Do not browse to the instance before running tests**; doing so may interfere with the test session. If you have already browsed to it, stop the server and start it again before running tests.
#### Docs
Go to docs folder and install [zensical](https://zensical.org/docs/get-started/)
```bash
# cd docs
cd docs
# Install zensical https://zensical.org/docs/get-started/
...
# build docs
zensical build --clean
# start server to see updates in realtime
zensical serve
```
Open browser on [http://localhost:8000](http://localhost:8000)
## Other Ways to Contribute
- **Documentation**: Help improve or translate documentation
- **Examples**: Share your expander card configurations in discussions
- **Support**: Help answer questions from other users
- **Testing**: Test beta releases and provide feedback
- **Spread the word**: Star the repo and tell others about it
## Code of Conduct
Be respectful, constructive, and welcoming. We're all here to make the Expander Card better together.
## Questions?
Not sure where to start? Feel free to:
- Open a [discussion](https://github.com/MelleD/lovelace-expander-card/discussions)
- Comment on existing issues
- Check the [documentation](https://github.com/MelleD/lovelace-expander-card/blob/main/README.md)
Thank you for contributing! 🎉
================================================
FILE: docs/source/chapter/faq/faq.md
================================================
# FAQ
## Issue after upgrade to HA 2025.6
There was an issue after upgrading to HA 2025.6 (this may not be valid anymore in newer versions)
See [forum](https://community.home-assistant.io/t/expander-accordion-collapsible-card/738817/56?u=melled) and [issue](https://github.com/MelleD/lovelace-expander-card/issues/506).
For the view type [sections](https://www.home-assistant.io/blog/2024/03/04/dashboard-chapter-1/) `cards` is not working anymore. You have to rename it to `sections`.
Before
```yaml
views:
- title: MyView
path: my-view
cards: ...
```
Now
```yaml
views:
- title: MyView
path: my-view
sections: ...
```
## Option Gap is not working
If this option doesn't work, check your browser's console output. Your current CSS layout might not support this option.
You can use the `expander-card-display: grid` option to set a layout that supports this option.
================================================
FILE: docs/source/chapter/style/card-mod.md
================================================
## Card Mod
Before the `style` attribute, [card mod](https://github.com/thomasloven/lovelace-card-mod) was used to style the card. Although card-mod still works, it is better to switch everything to use the `style` attribute. Please do not open issue(s) when card mod is not working.
================================================
FILE: docs/source/chapter/style/hover.md
================================================
# Hover/press ripple
The Expander Card uses the inbuilt Home Assistant ripple element `ha-ripple` for hover/press ripple animation on for expander-card button. If you wish to style/hide the ripple you can use the following CSS variables with advanced styling. If these are set in your theme they will be applied and you don't need to do anything at all.
**NOTE**: If you only wish to style the expander-card ripple itself, you will need to apply to the appropriate class listed below. Otherwise if you apply to `.expander-card` it will change the ripple for all cards within the expander.
Config | Class
--- | ---
No title card | `.header`
Title card without overlay | `.title-card-header`
Title card with overlay | `.header`
Change the hover/press ripple color. No title card.
```yaml
style: |
.header {
--ha-ripple-color: red;
}
```
Ripple CSS Variable|Usage|Accepts|Default
-|-|-|-
`--ha-ripple-color`|Hover/press ripple color. Set to `none` if you wish to disable all ripples.|CSS color|`var(--secondary-text-color)`
`--ha-ripple-hover-color`|Hover ripple color. Set if you wish it to be different from pressed color.|CSS color|`var(--ha-ripple-color, var(--secondary-text-color))`
`--ha-ripple-pressed-color`|Pressed ripple color. Set if you wish to be different from hover color.|CSS color|`var(--ha-ripple-color, var(--secondary-text-color))`
`--ha-ripple-hover-opacity`|Opacity of the hover ripple.|CSS opacity|0.08
`--ha-ripple-pressed-opacity`|Opacity of the pressed ripple.|CSS opacity|0.12
================================================
FILE: docs/source/chapter/style/index.md
================================================
# Styling
From basic color changes to advanced CSS animations and transitions, the Expander Card provides comprehensive styling options to match your dashboard design perfectly.
The Expander Card offers multiple approaches to styling, giving you full control over every visual aspect of the card. Whether you're making simple adjustments or creating complex animated effects, you'll find the tools you need here.
## Style Overview
The Expander Card provides a powerful `style` configuration parameter that allows you to apply custom CSS to various elements of the card. This includes the card container, header, title, arrow, children wrapper, and more.
Key styling features:
- **Element-Specific Styling**: Target individual components like headers, titles, and arrows
- **State-Based Styling**: Apply different styles based on expanded/collapsed states
- **Animation Support**: Create smooth transitions and animated effects during expansion
- **Title Card Integration**: Style title cards with or without overlays
- **CSS Variables**: Use and override built-in CSS variables for consistent theming
- **Flexible Selectors**: Use CSS classes for precise control over styling
The styling system is designed to work seamlessly with Home Assistant's theming system while providing the flexibility to create unique, custom designs.
For comprehensive information on available CSS classes, state classes, animation classes, and styling examples, visit the [Advanced Styling Guide](style.md).
## Practical Examples
Learning by example is often the best approach to styling. Our examples section provides a curated collection of ready-to-use styling snippets that demonstrate various techniques and effects.
You can copy these examples directly into your configuration and adapt them to your needs.
Explore the [YAML Styling Examples](examples/yaml-styling-examples.md) to see what's possible and get inspired for your own custom designs.
## Hover Effects
The Expander Card uses Home Assistant's built-in ripple element (`ha-ripple`) for interactive feedback on button presses and hover states.
For detailed information on CSS variables, configuration classes, and hover customization examples, see the [Hover Styling Guide](hover.md).
## Card-Mod
Before the `style` attribute was introduced, [card-mod](https://github.com/thomasloven/lovelace-card-mod) was the primary method for styling expander cards. While card-mod still works with the Expander Card, it is strongly recommended to migrate to the native `style` attribute for better performance and maintainability.
If you're currently using card-mod, migrating to the `style` attribute is usually as simple as moving your CSS from the card-mod configuration to the `style` parameter.
For more information and migration notes, see the [Card-Mod Information](card-mod.md).
================================================
FILE: docs/source/chapter/style/style.md
================================================
# Style
You can do advanced styling using the `style` configuration parameter. The `style` parameter supports two formats:
1. **String format**: CSS rules as a string
2. **Object format**: Structured object with CSS selectors as keys and property string arrays as values
!!! tip "Editing `style` in Graphical config editor"
When editing `style` in Graphical config editor the default mode is CSS text. To enabled Structured object editing, switch to code editor mode, set style to be a YAML object, and switch back to visual editor mode. Your config will now always view as an Structured object in the visual editor.
## Style Format Examples
### String Format
```yaml
style: |
.expander-card {
background-color: red;
}
```
### Object Format
```yaml
style:
.expander-card:
- background-color: red
```
Both formats produce the same result. See [Styling Examples](styling-examples.md) for more details and comprehensive examples.
### Object returned from template
Templates can return either string arrays or object arrays where the first object only is used:
```yaml
templates:
- template: style
value_template: |
[[[
return {
'.expander-card': [
'background-color: red !important',
'padding: 1em'
],
'.header > .title': [
{ 'color': 'white' },
{ 'font-size': 'var(--ha-font-size-l)' }
]
}
]]]
```
## CSS Classes
Classes available are per the images below.



## State
For all elements shown, the class `open` will be added when the Expander card is open, and `closed` added when the Expander is closed.
## Animation
When Expander card animation is enabled, for all elements except those listed below, the class `opening` will be added when the expander is in the process of opening and the class `closing` will be added when the expander is in the process of closing. When not `opening` or `closing`, the class `idle` will be added. The class `animation` will also be added. You may wish to use these classes for transition affects. Expander card uses `0.35s ease` for transitions. See the final example below for transitioning title font size and color.
> NOTE: `.outer-container` for Title card will not have `animation` or `opening`/`closing` applied.
## Special considerations
1. `.children-wrapper` is used for opening/closing animation and hiding children cards. You should not style this element. It is shown for completeness.
2. `margin-bottom` on each children card's `.outer-container` is used to transition cards sliding down and up while animating. Do not style `margin-bottom` and if altering any transitions, extend the included `transition` style for `opening` and `closing`.
3. As much as possible, use class selector combinations to get your styles to a higher specificity. e.g. `.expander-card.animation.open` is more specific than any built in classes so if you use that selector, you as less likely to need to use `!important`.
4. For animation, during opening, the classes will be `open` and `opening`. During closing, classes will be `open` and `closing` until the close sequence has ended after which the classes will be `close` and `idle`.
5. If you are considering any transition effects, check those already applied and extend those with any styling you add.
================================================
FILE: docs/source/chapter/style/styling-examples.md
================================================
# Styling examples
This page provides practical examples of styling the Expander Card using the `style` configuration parameter. Each example includes complete YAML code that you can copy and adapt to your needs.
These examples demonstrate various styling techniques, from simple color changes to complex animations and layout modifications. Understanding these patterns will help you create custom designs that perfectly match your dashboard.
## Style Format
The `style` configuration parameter supports two formats:
### String Format (CSS Text)
The traditional way to define styles using a string containing CSS rules:
```yaml
type: custom:expander-card
title: String Style Example
style: |
.expander-card {
background-color: red;
padding: 1em;
}
cards:
- type: entity
entity: light.living_room
```
### Object Format (Structured)
Alternatively, you can use an object structure where keys are CSS selectors and values are arrays of property strings:
```yaml
type: custom:expander-card
title: Object Style Example
style:
.expander-card:
- background-color: red
- padding: 1em
.header > .title:
- font-size: var(--ha-font-size-l)
- color: green
cards:
- type: entity
entity: light.living_room
```
Both formats produce the same result. The object format can be more readable and easier to maintain for complex styles.
## Background Color Transitions
### Animated Background Based on State
This example sets the background color based on the expansion state with smooth transitions. As background color is a transition element, you need to style both `open` & `opening` and `close` & `closing` to get the background to transition during opening/closing. Otherwise the transition will take place after the expander has fully opened/closed.
```yaml
type: custom:expander-card
title: Animated Background
style: |-
.expander-card.animation.open,
.expander-card.animation.opening {
background-color: red;
}
.expander-card.animation.close,
.expander-card.animation.closing {
background-color: #C8A2C8;
}
cards:
- type: entity
entity: light.living_room
```
### Button Background Only
Style only the background of the header button. Here `!important` is needed if you wish to override the hover ripple effect.
```yaml
type: custom:expander-card
title: Button Background
style: |
.header.animation.open,
.header.animation.opening {
background-color: red !important;
}
.header.animation.close,
.header.animation.closing {
background-color: #C8A2C8 !important;
}
cards:
- type: entity
entity: light.bedroom
```
The same example using the object format:
```yaml
type: custom:expander-card
title: Button Background (Object Format)
style:
.header.animation.open, .header.animation.opening:
background-color: red !important
.header.animation.close, .header.animation.closing:
background-color: '#C8A2C8 !important'
cards:
- type: entity
entity: light.bedroom
```
## Layout Modifications
### Arrow Position - Left Side
Move the arrow from the right side to the left side, with reduced horizontal padding of the button.
```yaml
type: custom:expander-card
title: Left Arrow
style: |
.header {
flex-direction: row-reverse !important;
padding: 0.8em 0 !important;
}
cards:
- type: entity
entity: sensor.temperature
```
### Arrow Position with Title Card (No Overlay)
If you have a title card without overlay, use the `.title-card-header` class.
```yaml
type: custom:expander-card
title-card:
type: entity
entity: sensor.weather
style: |
.title-card-header {
flex-direction: row-reverse !important;
padding: 0.8em 0 !important;
}
cards:
- type: entity
entity: sensor.humidity
```
### Arrow Position with Title Card (With Overlay)
If you have a title card with overlay enabled, target the overlay header.
```yaml
type: custom:expander-card
title-card:
type: picture
image: /local/image.jpg
title-card-overlay: true
style: |
.title-card-header-overlay > .header-overlay {
flex-direction: row-reverse !important;
padding: 0.8em 0 !important;
}
cards:
- type: entity
entity: media_player.living_room
```
## Title Styling
### Animated Title Font and Color
Transition the title font size and color during expansion. The `!important` on `close`/`closing` ensures that the font size and color both change during the closing animation, as the `open` class remains until fully closed.
```yaml
type: custom:expander-card
title: Dynamic Title
style: |
.header > .title {
transition: color 0.35s ease, font-size 0.35s ease;
}
.header.animation.close > .title,
.header.animation.closing > .title
{
color: green !important;
font-size: var(--ha-font-size-l) !important;
}
.header.animation.open > .title,
.header.animation.opening > .title
{
color: red;
font-size: var(--ha-font-size-m);
}
cards:
- type: entities
entities:
- light.kitchen
```
### Static Title Size
Simply change the title font size without animations.
```yaml
type: custom:expander-card
title: Large Title
style: |
.title {
font-size: var(--ha-font-size-l);
}
cards:
- type: entity
entity: climate.thermostat
```
## Title Card Overlay Adjustments
### Match Overlay to Arrow Height
Change the height of the title card overlay to match the arrow height.
```yaml
type: custom:expander-card
title-card:
type: picture
image: /local/background.jpg
title-card-overlay: true
style: |
.header-overlay {
height: unset !important;
}
cards:
- type: weather-forecast
entity: weather.home
```
### Relative Overlay Height
Change the height of the title card overlay relative to the title card height. The CSS variable `--expander-card-overlay-height` is automatically set based on title card height and overlay margin.
```yaml
type: custom:expander-card
title-card:
type: picture
image: /local/header.jpg
title-card-overlay: true
style: |
.header-overlay {
height: calc(var(--expander-card-overlay-height) * 0.66) !important;
}
cards:
- type: entities
entities:
- sensor.temperature
- sensor.humidity
```
## Complex Example Using Object Format
Here's a comprehensive example demonstrating the object format with multiple selectors and properties:
```yaml
type: custom:expander-card
title: Complex Styling Example
style:
.expander-card.animation.open, .expander-card.animation.opening:
- background-color: '#1e3a8a'
- transition: background-color 0.35s ease
.expander-card.animation.close, .expander-card.animation.closing:
- background-color: '#047857'
- transition: background-color 0.35s ease
.header > .title:
- font-size: var(--ha-font-size-l)
- color: '#ffffff'
- font-weight: bold
- transition: color 0.35s ease, font-size 0.35s ease
.header.animation.open > .title, .header.animation.opening > .title:
- color: '#fbbf24'
- font-size: var(--ha-font-size-xl)
.header.animation.close > .title, .header.animation.closing > .title:
- color: '#ffffff'
- font-size: var(--ha-font-size-l)
.arrow:
- color: '#fbbf24'
cards:
- type: entity
entity: light.living_room
```
This example demonstrates:
- Background color transitions between states
- Title color and size changes with smooth transitions
- Custom arrow color
- Multiple CSS properties per selector
- Combining multiple selectors with commas
## Tips and Best Practices
### Style Format Choice
Choose the format that works best for your use case:
- **String format**: Better for copy-pasting existing CSS or when using complex selectors
- **Object format**: More readable and easier to maintain, especially for configurations with many style rules
### Specificity
Use class selector combinations to increase specificity and avoid needing `!important`:
- `.expander-card.animation.open` is more specific than just `.expander-card`
- `.header.animation.open > .title` targets specifically the title in an open animated header
### Animation States
When styling for animations, remember:
- During opening: `open` and `opening` classes are both present
- During closing: `open` and `closing` classes are both present until fully closed
- When idle: `idle` class is present along with either `open` or `close`
### Transitions
For smooth animations:
- Use matching transition durations (expander card uses `0.35s ease` by default)
- Apply transitions to the base element, not just the state classes
- Style both opening and closing states for seamless transitions
### Testing
Always test your styles in the Home Assistant UI:
- Check both light and dark themes if your instance supports theme switching
- Test expansion and collapse animations to ensure smooth transitions
- Verify that hover effects work as expected
- Check responsive behavior at different screen sizes
### Resources
For more advanced styling:
- See the [Advanced Styling Guide](../style.md) for CSS class documentation
- Check [Hover Effects](../hover.md) for ripple customization
- Review Home Assistant's CSS variables in your theme configuration
- Explore the [templating section](../../templating/index.md) for dynamic styling based on entity states
================================================
FILE: docs/source/chapter/templating/action.md
================================================
# Action Configuration
You can set the state of expander card(s) using the `fire-dom-event` action on any card that supports actions.
1. Set expander card(s) to have `expander-card-id`. Multiple expander cards can share the same id if you wish to set their state together.
2. Set action on another card using the `fire-dom-event` action.
```yaml
tap_action:
action: fire-dom-event
expander-card:
data:
expander-card-id: <expander-card-id>
action: < open | close | toggle >
```
## Expander card config
```yaml
- type: custom:expander-card
expander-card-id: my-expander-card
```
## Action on another card
```yaml
show_name: true
show_icon: true
type: button
name: Expand my-expander-card
icon: mdi:chevron-down
tap_action:
action: fire-dom-event
expander-card:
data:
expander-card-id: my-expander-card
action: open
```
================================================
FILE: docs/source/chapter/templating/index.md
================================================
# Templating
Welcome to the templating section of the Expander Card! This powerful feature enables you to create dynamic, state-responsive expander cards that automatically adapt based on entity states, conditions, and user interactions. Templating takes your dashboard from static to intelligent.
Templating allows you to programmatically control various aspects of the Expander Card using JavaScript expressions. Instead of hardcoding values, you can create rules that evaluate in real-time, making your cards react to changes in your Home Assistant entities and their states.
## JavaScript Templates
The Expander Card offers comprehensive JavaScript templating support to dynamically customize behavior, styling, and display properties. Templates allow you to create conditional logic, respond to entity state changes, and build sophisticated automation directly into your cards.
Key templating capabilities include:
- **Dynamic Expansion State**: Control whether cards are expanded or collapsed based on entity states or conditions
- **Conditional Styling**: Change colors, fonts, and visual appearance based on real-time data
- **Responsive Content**: Update titles, icons, and other properties dynamically
- **Variable Support**: Define reusable variables for cleaner template code
- **Cross-Card Communication**: Track and respond to the state of other expander cards
- **Entity State Tracking**: React automatically when your Home Assistant entities change
Templates are evaluated reactively, meaning they automatically update when the underlying data changes, providing a seamless and responsive user experience.
For a comprehensive guide on implementing JavaScript templates, including syntax, available methods, variables, and practical examples, visit the [JavaScript Template Documentation](template.md).
## Actions
The Expander Card supports a powerful action system that allows you to control expander cards programmatically from other cards and automations. This enables you to create interactive dashboards where buttons, entity cards, or other elements can open, close, or toggle expander cards.
Action support includes:
- **Remote Control**: Open, close, or toggle expander cards from any card that supports actions
- **Grouped Control**: Control multiple expander cards simultaneously using shared IDs
- **Fire-DOM-Event Integration**: Seamless integration with Home Assistant's action system
- **Button Integration**: Create dedicated control buttons for your expander cards
- **Automation Triggers**: Integrate with your Home Assistant automations and scripts
Actions provide a flexible way to create interactive dashboard layouts where different parts of your UI can communicate and respond to user interactions.
For detailed information on configuring and using actions, including complete examples and setup instructions, see the [Action Configuration Guide](action.md).
================================================
FILE: docs/source/chapter/templating/template.md
================================================
# Javascript Template
Expander card supports javascript templates for the config items listed below. This list may be added to over time based on user feature requests. If you wish for a config item to be supported by javascript template please submit a feature request.
| Config item | Accepts value | Overrides config items |
| --- | --- | --- |
| `expanded` | boolean (`true\|false`) | `expanded`, `min-width-expanded`, `max-width-expanded`, `start-expanded-users` |
| `title` | string | `title` |
| `icon` | string | `icon` |
| `arrow-color` | CSS color (string) | `arrow-color` |
| `style` | string | `style` |
Javascript templates are implemented using the [home-assistant-javascript-templates](https://github.com/elchininet/home-assistant-javascript-templates) library by @elchininet. For objects and methods supported see [Objects and methods available in the templates](https://github.com/elchininet/home-assistant-javascript-templates#objects-and-methods-available-in-the-templates). The `config` object is also available which is the config object for the expander card where all config items can be read. e.g. `config['expander-card-id']`.
Templates may also use `variables`, which are also javascript templates or just values. Templates are reactive to `variables` such that if a variable template value changes, the template using the variable itself will be evaluated and return its value. Expander card uses the [home-assistant-javascript-templates](https://github.com/elchininet/home-assistant-javascript-templates) `refs` feature using `variables` as the `refsVariableName`. You are best to ignore anything about `refs` in the library unless you know what you are doing.
Templates also have access to special variables which track the expanded state of any expander on the Dashboard which has `expander-card-id` config set. See [Templates - tracking expanded state of other expander cards](#templates---tracking-expanded-state-of-other-expander-cards)
Templates are not continually evaluated but rely on reactive properties for updates. Follow the guidelines in [Objects and methods available in the templates](https://github.com/elchininet/home-assistant-javascript-templates#objects-and-methods-available-in-the-templates).
Javascript for variables and templates are set using `value_template` string, enclosing javascript with `[[[]]]`, that is three open square bracket `[[[` followed by three close square bracket `]]]`. This follows the convention followed by other custom cards for javascript templates. Variables and templates can also return a straight value, which will follow YAML syntax converted to the type required for the config item being templated e.g. for a config item that accepts `boolean`, `true`, `"True"`, `1`, `"1"` are all considered `true`.
## Variables
Variables are defined in the `variables` list of expander card config.
**IMPORTANT**: As variables are evaluated asynchronously, their initial value will be `undefined`. Your templates need to be written to handle this initial case.
| List item | Type | Config |
| --------- | ---- | ------ |
| `variable` | string | The `<name>` of the variable which will be available in templates as `variable.<name>`. |
| `value_template` | string \| value \| object | Either javascript that returns a value or a straight value. Javascript must be enclosed by `[[[]]]` with only whitespace preceding or following. |
Variable `weather_warnings` tracking the state of `input_boolean.weather_warnings`.
```yaml
variables:
- variable: weather_warnings
value_template: |
[[[
return is_state('input_boolean.weather_warnings', 'on');
]]]
```
## Templates
Templates are defined in the `templates` list of expander card config.
| List item | Type | Config |
| --------- | ---- | ------ |
| `template` | string | The config item being templated. Only supported config items will be read by expander card. See list in main [Advanced javascript templates](../templating/template.md) section. |
| `value_template` | string \| value \| object | Either javascript that returns a value or a straight value. Javascript must be enclosed by `[[[]]]` with only whitespace preceding of following. The type of the value \| object returned/set must be applicable to the config item being templated. |
Template for `expanded` config item tracking state of `input_boolean.weather_warnings`.
```yaml
templates:
- template: expanded
value_template: |
[[[
return is_state('input_boolean.weather_warnings', 'on');
]]]
```
Same template using variable `weather_warnings` and adding a `style` template. Note the use of the nullish coalescing (??) operator to handle variables being undefined until their value is set.
```yaml
variables:
- variable: weather_warnings
value_template: |
[[[
return is_state('input_boolean.weather_warnings', 'on');
]]]
templates:
- template: expanded
value_template: |
[[[
return variables.weather_warnings ?? false;
]]]
- template: style
value_template: |
[[[
return `
.title
{
transition: color 0.35s ease, font-weight 0.35s ease;
color: ${variables.weather_warnings ? 'red' : 'var(--primary-text-color)'};
font-weight: ${variables.weather_warnings ? '700' : 'var(--ha-font-weight-body)'};
}`;
]]]
```
More user examples can be found in [Show and tell](https://github.com/MelleD/lovelace-expander-card/discussions/categories/show-and-tell) discussion topic. If you have an example please submit to this discussion topic.
### Templates - tracking expanded state of other expander cards
If you set `expander-card-id` in an expander card it will broadcast its state to all expander cards on the open Dashboard. When using templates, the expanded state will be in special variables of the form `variables.['<expander-card-id>_open']`.
If you have an expander card with `expander-card-id: my-expander-id` its expanded state will be available in templates via `variables['my-expander-id_open']`. Note here that Javascript access to the variable is via `[]` due to the `expander-card-id` having `-` in it. If you use `_` so that the `expander-card-id` is a valid object accessor, you can use `.` accessor. e.g. `expander-card-id: my_expander_id` => `variables.my_expander_id_open`.
### Example - using expander card open state variables
This example shows using expander card open state variable for expanded template of other expander cards. The variables can also be used in any template config supported by expander card.
Master expander card
```yaml
type: custom:expander-card
title: Expander Card - Master
expander-card-id: test_expander_id
cards:
- type: entity
entity: light.kitchen_lights
```
Expander card following master
```yaml
type: custom:expander-card
title: Expander Card - Follow
cards:
- type: entity
entity: light.bed_light
templates:
- template: expanded
value_template: |-
[[[
return variables.test_expander_id_open;
]]]
```
Expander card following with opposite open state
```yaml
type: custom:expander-card
title: Expander Card - Opposite Follow
cards:
- type: entity
entity: light.bed_light
templates:
- template: expanded
value_template: |-
[[[
return !variables.test_expander_id_open;
]]]
```
================================================
FILE: docs/source/index.md
================================================
# About Expander Card
Welcome to the documentation of the Expander Card!
The Expander Card is a highly configurable Lovelace card that allows you to organize multiple cards within an expandable container. Through its numerous configuration options, you can precisely control the appearance, behavior, and functionality of the card, tailoring it to your dashboard design.
Follow [Quick start](quick-start.md) for the initial installation / setup
:material-github: [Github project](https://github.com/MelleD/lovelace-expander-card)
[](https://www.paypal.me/MelleDennis)
:material-coffee: [Buy Me A Coffee](https://www.buymeacoffee.com/melled)
## Demo

---
### Expand button as overlay

---
### You can even nest expanders

---
### Clear Background (default theme)

================================================
FILE: docs/source/quick-start.md
================================================
# Quick Start
## Installation
### HACS
Expander-Card is available in [HACS](https://www.hacs.xyz/) (Home Assistant Community Store) by default.
1. Install HACS if you don't have it already
2. Open HACS in Home Assistant
3. Searching for expander card
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=MelleD&repository=lovelace-expander-card&category=plugin)
### Manual
1. Download `expander-card.js` file from the [latest release](https://github.com/MelleD/lovelace-expander-card/releases/latest).
2. Put `expander-card.js` file into your `config/www` folder.
3. Add reference to `expander-card.js` in Dashboard. There's two way to do that:
- **Using UI:** _Settings_ → _Dashboards_ → _More Options icon_ → _Resources_ → _Add Resource_ → Set _Url_ as `/local/expander-card.js` → Set _Resource type_ as `JavaScript Module`.
**Note:** If you do not see the Resources menu, you will need to enable _Advanced Mode_ in your _User Profile_
- **Using YAML:** Add following code to `lovelace` section.
```yaml
resources:
- url: /local/expander-card.js
type: module
```
================================================
FILE: eslint.config.mjs
================================================
import { FlatCompat } from '@eslint/eslintrc';
import path from 'path';
import { fileURLToPath } from 'url';
import stylistic from '@stylistic/eslint-plugin'
// mimic CommonJS variables -- not needed if using CommonJS
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname
});
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import eslintPluginSvelte from 'eslint-plugin-svelte';
import svelteEslintParser from 'svelte-eslint-parser';
import eslintPluginPrettier from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
export default tseslint.config(
eslintPluginPrettier,
js.configs.recommended,
...tseslint.configs.recommended,
...eslintPluginSvelte.configs['flat/recommended'],
{
ignores: ['eslint.config.mjs','rollup.config.mjs','dist/**/*','vite.config.js', 'svelte.config.js', 'eslint.config.js', '.svelte-kit/**/*', 'build/**/*', 'docs/**/*', '.venv/**/*'],
},
{
plugins: {
'@typescript-eslint': tseslint.plugin,
'@stylistic': stylistic
},
languageOptions: {
parser: tseslint.parser,
globals: {
...globals.browser,
...globals.node,
...globals.es2021,
},
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
project: './tsconfig.json',
extraFileExtensions: ['.svelte'],
},
},
rules: {
'prettier/prettier': 'off',
'@typescript-eslint/no-unused-expressions': 'off',
'@typescript-eslint/no-namespace': 'off',
'@typescript-eslint/adjacent-overload-signatures': 'error',
'@typescript-eslint/array-type': 'error',
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
'@typescript-eslint/no-inferrable-types': 'error',
'@typescript-eslint/no-misused-new': 'error',
'@typescript-eslint/no-this-alias': 'error',
'@typescript-eslint/prefer-for-of': 'error',
'@typescript-eslint/prefer-function-type': 'error',
'@typescript-eslint/prefer-namespace-keyword': 'error',
'no-inner-declarations': 'off',
'@typescript-eslint/triple-slash-reference': 'error',
'@stylistic/type-annotation-spacing': 'error',
'@typescript-eslint/unified-signatures': 'error',
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/unbound-method': 'warn',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
'object-curly-spacing': ['error', 'always'],
'@stylistic/semi': [
'error',
'always'
],
'@stylistic/quotes': [
'warn',
'single'
],
'@stylistic/member-delimiter-style': [
'error',
{
multiline: {
delimiter: 'semi',
requireLast: true
},
singleline: {
delimiter: 'semi',
requireLast: false
}
}
],
'@stylistic/indent': [
'warn',
4,
{
FunctionDeclaration: {
parameters: 'first'
},
FunctionExpression: {
parameters: 'first'
},
SwitchCase: 1
}
],
'@typescript-eslint/explicit-member-accessibility': [
'error',
{
accessibility: 'explicit'
}
],
'@typescript-eslint/no-use-before-define': ['error', { functions: false }],
'no-console': 'warn',
'no-return-await': 'error',
'arrow-body-style': 'error',
'arrow-parens': [
'error',
'always'
],
'comma-dangle': [
'error',
{
objects: 'never',
arrays: 'never',
functions: 'never'
}
],
'prefer-arrow-callback': 'error',
'prefer-const': 'error',
'quote-props': [
'error',
'consistent-as-needed'
],
'no-var': 'error',
'new-parens': 'error',
'no-caller': 'error',
'no-cond-assign': 'error',
'no-debugger': 'error',
'no-empty': 'error',
'no-eval': 'error',
'no-multiple-empty-lines': 'warn',
'no-new-wrappers': 'error',
'no-redeclare': 'error',
'no-shadow': [
'error',
{
hoist: 'all'
}
],
'no-throw-literal': 'error',
'no-trailing-spaces': 'error',
'no-undef-init': 'error',
'no-underscore-dangle': 'error',
'no-unsafe-finally': 'error',
'no-unused-labels': 'error',
'spaced-comment': 'error',
'use-isnan': 'error',
'max-len': [
'warn',
{
code: 180
}
],
'dot-notation': 'error',
'eqeqeq': 'error',
'eol-last': 'error',
'linebreak-style': ['error', 'unix'],
'block-spacing': ['error', 'always'],
'tsdoc/syntax': 'off'
},
},
{
files: ['*.svelte', '**/*.svelte'],
languageOptions: {
parser: svelteEslintParser,
parserOptions: {
parser: tseslint.parser,
}
},
rules: {
'prettier/prettier': ['warn', {
'svelteStrictMode': true,
'svelteBracketNewLine': false,
'svelteAllowShorthand': false,
'svelteIndentScriptAndStyle': false,
'tabWidth': 4,
'bracketSpacing': true,
'trailingComma': 'none',
'arrowParens': 'always',
'semi': true,
'singleQuote': true,
'printWidth': 110,
'proseWrap': 'preserve',
'plugins': ['prettier-plugin-svelte'],
}],
}
}
);
================================================
FILE: hacs.json
================================================
{
"name": "expander-card",
"content_in_root": false,
"render_readme": true,
"filename": "expander-card.js",
"homeassistant": "2026.3.0"
}
================================================
FILE: license.txt
================================================
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================
FILE: package.json
================================================
{
"name": "lovelace-expander-card",
"version": "0.0.1",
"type": "module",
"watch": {
"buildAndStart": {
"patterns": [
"src"
],
"extensions": "svelte"
}
},
"scripts": {
"build": "eslint . && vite build",
"srv": "pnpx serve -l 5000 --cors dist",
"lint": "eslint .",
"lint-fix": "eslint . --fix"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^10.0.1",
"@stylistic/eslint-plugin": "^5.10.0",
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/enhanced-img": "^0.6.1",
"@sveltejs/vite-plugin-svelte": "5.1.1",
"@types/eslint": "9.6.1",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prefer-arrow": "^1.2.3",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-svelte": "^3.17.1",
"globals": "^17.6.0",
"postcss-preset-env": "^11.2.1",
"prettier": "^3.8.3",
"prettier-plugin-svelte": "^3.5.1",
"svelte": "5.55.5",
"svelte-check": "^4.4.8",
"svelte-eslint-parser": "^1.6.1",
"tslib": "^2.8.1",
"typescript": "^6.0.3",
"typescript-eslint": "8.59.2",
"vite": "^6.4.2"
},
"dependencies": {
"home-assistant-javascript-templates": "^7.0.0",
"home-assistant-query-selector": "^6.0.1",
"lit": "^3.3.2",
"svelte": "5.55.5"
},
"pnpm": {
"ignoredBuiltDependencies": [
"@sveltejs/kit"
]
}
}
================================================
FILE: pyproject.toml
================================================
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "lovelace-expander-card"
version = "0.0.1"
requires-python = ">=3.11"
[project.optional-dependencies]
test = [
"ha-testcontainer[test]>=2.1",
"pytest>=7.0",
"PyYAML>=6.0",
"playwright>=1.0",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --tb=short"
================================================
FILE: rollup.config.mjs
================================================
import resolve from '@rollup/plugin-node-resolve';
import replace from '@rollup/plugin-replace';
import svelte from 'rollup-plugin-svelte';
import commonjs from '@rollup/plugin-commonjs';
import typescript from '@rollup/plugin-typescript';
import terser from '@rollup/plugin-terser';
import sveltePreprocess from 'svelte-preprocess';
import json from '@rollup/plugin-json';
const MAIN_COMPONENT_NAME = 'ExpanderCard';
const TAG_NAME = 'expander-card';
const CONTAINER_TAG_NAME ='expander-child-card';
const FILE_NAME = `${TAG_NAME}.js`;
export default (commandlineargs) => {
console.log('commandlineargs: ', commandlineargs);
return ({
input: 'src/index.ts',
output: {
sourcemap: true,
format: 'umd',
name: MAIN_COMPONENT_NAME,
file: `dist/${FILE_NAME}`
},
plugins: [
replace({
'tag-name': TAG_NAME,
'container-tag-name': CONTAINER_TAG_NAME,
'preventAssignment': true
}),
svelte({
preprocess:
sveltePreprocess({
sourceMap: true
}),
compilerOptions: {
customElement: true,
hydratable: true,
dev: true
},
emitCss: true
}),
resolve({
browser: true,
dedupe: ['svelte']
}),
commonjs(),
json(),
typescript({
sourceMap: true,
inlineSources: !production
}),
production && terser()
],
watch: {
clearScreen: false
}
}); };
================================================
FILE: src/Card.svelte
================================================
<!--
/*
Copyright 2021-2022 Peter Repukat - FlatspotSoftware
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
-->
<!-- eslint-disable-next-line svelte/valid-compile -->
<svelte:options customElement='expander-sub-card' />
<script lang="ts">
import { onMount, untrack } from 'svelte';
import type { AnimationState, HomeAssistant, HuiCard, LovelaceCardConfig } from './types';
import { computeCardSize } from './helpers/compute-card-size';
const {
config,
hass,
preview,
marginTop ='0px',
open,
animation = true,
animationState,
clearCardCss = false
}: {
config: LovelaceCardConfig;
hass: HomeAssistant | undefined;
preview: boolean;
marginTop?: string;
open: boolean;
animation: boolean;
animationState: AnimationState;
clearCardCss: boolean;
} = $props();
let outerContainer: HTMLElement | null = null;
let container = $state<HuiCard | null>(null);
let loading = $state(true);
let cardHeight = $state(0);
const cardConfig: LovelaceCardConfig = untrack(() => JSON.parse(JSON.stringify(config)));
$effect(() => {
if (container) {
container.hass = hass;
}
});
$effect(() => {
if (container && preview !== undefined) {
container.preview = preview;
}
});
$effect(() => {
if (container) {
// do not set hui-card hidden as this prevents it from updating its display
// card disabled config sets hui-card to correctly update its display
cardConfig.disabled = !open;
// eslint-disable-next-line no-underscore-dangle
container._element?.dispatchEvent(new CustomEvent('card-visibility-changed', { detail: { value: open }, bubbles: true, composed: false }));
}
});
onMount(async () => {
const el: HuiCard = document.createElement('hui-card') as HuiCard;
el.hass = hass;
el.preview = preview;
// do not set hui-card hidden as this prevents it from updating its display
// card disabled config sets hui-card to correctly update its display
cardConfig.disabled = !open;
el.config = cardConfig;
el.load();
// eslint-disable-next-line svelte/no-dom-manipulating
outerContainer?.appendChild(el);
container = el;
loading = false;
// hui-card will fire card-updated on ll-upgrade which causes some view to reload
// so we capture ll-upgrade, stop propagation and set hass on the upgraded element ourselves
container.addEventListener('ll-upgrade', (ev) => {
ev.stopPropagation();
// eslint-disable-next-line no-underscore-dangle
if (container?._element && hass) {
// eslint-disable-next-line no-underscore-dangle
container._element.hass = hass;
}
}, { capture: true });
if (clearCardCss) {
el.style.setProperty('--ha-card-background', 'transparent');
el.style.setProperty('--ha-card-box-shadow', 'none');
el.style.setProperty('--ha-card-border-color', 'transparent');
el.style.setProperty('--ha-card-border-width', '0px');
el.style.setProperty('--ha-card-backdrop-filter', 'none');
}
if (animation) {
// Start with an estimated height.
// Update with resize observer once we have the real height.
// 56px is the height of one card size unit
cardHeight = await computeCardSize(el) * 56;
if (outerContainer) {
cardHeight += window.getComputedStyle(outerContainer).marginTop
? parseFloat(window.getComputedStyle(outerContainer).marginTop)
: 0;
}
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.contentBoxSize) {
const contentBoxSize = Array.isArray(entry.contentBoxSize)
? entry.contentBoxSize[0]
: entry.contentBoxSize;
if (contentBoxSize.blockSize) {
cardHeight = contentBoxSize.blockSize;
if (container) {
cardHeight += window.getComputedStyle(container).marginTop
? parseFloat(window.getComputedStyle(container).marginTop)
: 0;
}
}
} else if (entry.contentRect) {
cardHeight = entry.contentRect.height;
if (container) {
cardHeight += window.getComputedStyle(container).marginTop
? parseFloat(window.getComputedStyle(container).marginTop)
: 0;
}
}
}
});
resizeObserver.observe(el);
}
});
</script>
<div class="outer-container{open ? ' open' : ' close'}{animation ? ' animation ' + animationState : ''}"
style="--child-card-margin-top: {open ? marginTop : '0px'};{cardHeight ? ` --expander-animation-height: -${cardHeight}px;` : ''}"
bind:this={outerContainer}>
{#if loading}
<span class="loading"> Loading... </span>
{/if}
</div>
<style>
.loading {
padding: 1em;
display: block;
}
.animation :global {
hui-card {
display: flex;
flex-direction: column;
}
}
.outer-container.animation {
transition: margin-bottom 0.35s ease;
}
.outer-container.animation.open,
.outer-container.animation.opening {
margin-bottom: inherit;
}
.outer-container.animation.close,
.outer-container.animation.closing {
margin-bottom: var(--expander-animation-height, -100%);
}
.outer-container.animation.opening {
animation: fadeInOpacity 0.5s forwards ease;
-webkit-animation: fadeInOpacity 0.5s forwards ease;
}
.outer-container.animation.closing {
animation: fadeOutOpacity 0.5s forwards ease;
-webkit-animation: fadeOutOpacity 0.5s forwards ease;
}
.outer-container > :global(hui-card) {
margin-top: var(--child-card-margin-top, 0px);
}
@keyframes fadeInOpacity {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@-webkit-keyframes fadeInOpacity {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fadeOutOpacity {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@-webkit-keyframes fadeOutOpacity {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
</style>
================================================
FILE: src/ExpanderCard.svelte
================================================
<script lang="ts" module>
export const defaults = {
'gap': '0.0em',
'expanded-gap': '0.6em',
'padding': '1em',
'clear': false,
'clear-children': false,
'title': ' ',
'overlay-margin': '0.0em',
'child-padding': '0.0em',
'child-margin-top': '0.0em',
'button-background': 'transparent',
'expander-card-background': 'var(--ha-card-background,var(--card-background-color,#fff))',
'header-color': 'var(--primary-text-color,#fff)',
'arrow-color': 'var(--arrow-color,var(--primary-text-color,#fff))',
'expander-card-display': 'block',
'title-card-clickable': false,
'min-width-expanded': 0,
'max-width-expanded': 0,
'icon': 'mdi:chevron-down',
'icon-rotate-degree': '180deg',
'animation': true,
'haptic': 'light' as const
};
import { loadExpanderCardEditor } from './ExpanderCardEditor';
</script>
<!-- eslint-disable-next-line svelte/valid-compile -->
<svelte:options customElement={{
tag: 'expander-card',
extend: (customElementConstructor) => class extends customElementConstructor {
// re-declare props used in customClass.
public config!: ExpanderConfig;
public static async getConfigElement() {
await loadExpanderCardEditor();
return document.createElement('expander-card-editor');
}
public static getStubConfig() {
return {
type: 'custom:expander-card',
title: 'Expander Card',
cards: []
};
}
public setConfig(conf = {}) {
this.config = { ...defaults, ...conf };
};
}
}}/>
<script lang="ts">
import type { ExpanderCardEventDetail, ExpanderCardLlCustomEventDetail, HaRipple, HomeAssistant } from './types';
import Card from './Card.svelte';
import { onMount, untrack } from 'svelte';
import type { ExpanderCardTemplates, ExpanderConfig, ExpanderCardRawConfig } from './configtype';
import type { AnimationState } from './types';
import { forwardHaptic } from './helpers/forward-haptic';
import { isJSTemplate, getJSTemplateRenderer, trackJSTemplate, setJSTemplateRef, trackJSTemplateEvent } from './helpers/templates';
import type { HomeAssistantJavaScriptTemplatesRenderer } from 'home-assistant-javascript-templates';
import { getDashboardRawConfig } from './helpers/raw-config';
import { styleToString } from './helpers/style-converter';
const {
hass,
preview,
config = defaults
}: {hass: HomeAssistant; preview: boolean; config: ExpanderConfig} = $props();
let touchPreventClick = $state(false);
let touchPreventClickTimeout: ReturnType<typeof setTimeout> | null = $state(null);
let open = $state(untrack(() => preview) ? true : false);
let previewState = $state(untrack(() => preview) ? true : false);
let showButtonUsers = $state(untrack(() => preview || (userInList(config['show-button-users']) ?? true)));
let animationState: AnimationState = $state<AnimationState>('idle');
let animationTimeout: ReturnType<typeof setTimeout> | null = $state(null);
let backgroundAnimationDuration = $state(0);
let overlayHeight = $state(0);
let expanderCard: HTMLElement | null = $state(null);
let titleCardDiv: HTMLElement | null = $state(null);
let buttonElement: HTMLElement | null = $state(null);
let ripple: HaRipple | null = $state(null);
const templateEvents: Record<string, () => void> = {};
const variableRenders: Record<string, Promise<(() => void)>> = {};
const templateRenderers: Record<string, Promise<(() => void)>> = {};
const templateValues: Record<string, unknown> = $state({});
let dashboardRawConfig: ExpanderCardRawConfig = $state( getDashboardRawConfig() );
const userStyleTemplateOrConfig: string | null = $derived.by(() => {
const templateStyle = templateValues.style;
const configStyle = config.style;
let styleString: string | null = null;
if (templateStyle !== undefined) {
// Handle templateValues.style - could be string or object
styleString = typeof templateStyle === 'string'
? templateStyle
: (typeof templateStyle === 'object' && templateStyle !== null)
? styleToString(templateStyle as Record<string, Record<string, string>>)
: String(templateStyle);
} else if (configStyle) {
// Handle config.style - could be string or object
styleString = styleToString(configStyle);
}
return styleString ? `<style>${styleString}</style>` : null;
});
const iconConfigOrTemplate: string | undefined = $derived(
templateValues.icon !== undefined ?
String(templateValues.icon) :
config.icon);
const titleConfigOrTemplate: string | undefined = $derived(
templateValues.title !== undefined ?
String(templateValues.title) :
config.title);
const arrowColorConfigOrTemplate: string | undefined = $derived(
templateValues['arrow-color'] !== undefined ?
String(templateValues['arrow-color']) :
config['arrow-color']);
const configId = untrack(() => config['storage-id']);
const lastStorageOpenStateId = 'expander-open-' + configId;
$effect(() => {
// effect for template 'expanded'. We untrack preview and open to avoid infinite loop effect loops.
if (templateValues.expanded === undefined) {
return;
}
if (untrack(() => preview && dashboardRawConfig['preview-expanded'] !== false)) {
return;
}
const resultBoolean = Boolean(templateValues.expanded);
// Use queueMicrotask to avoid effect loop as open needs to be updated after this effect completes.
queueMicrotask(() => {
if (resultBoolean !== open) {
toggleOpen(resultBoolean);
}
});
});
$effect(() => {
// effect for preview changes. We untrack templateValues.expanded to avoid unnecessary effect triggering.
if (preview === previewState || preview === undefined) {
return;
}
previewState = preview;
if (previewState && dashboardRawConfig['preview-expanded'] !== false) {
setOpenState(true);
showButtonUsers = true;
return;
}
showButtonUsers = userInList(config['show-button-users']) ?? true;
if (configTemplate('expanded')) {
const templateExpanded = untrack(() => templateValues.expanded);
if (templateExpanded !== undefined) {
toggleOpen(Boolean(templateExpanded));
}
return;
}
setDefaultOpenState();
});
function configTemplate(templateKey: string): ExpanderCardTemplates | undefined {
const template = config.templates && Array.isArray(config.templates) ?
config.templates.find((t) => t.template === templateKey) : undefined;
if (template && isJSTemplate(template.value_template)) {
return template;
}
return undefined;
}
function dispatchOpenStateEvent(openState: boolean) {
if (!config['expander-card-id']) {
return;
}
const detail: ExpanderCardEventDetail = {};
detail[config['expander-card-id']] = {
property: 'open',
value: openState
};
document.dispatchEvent(new CustomEvent('expander-card', {
detail: detail,
bubbles: true,
composed: true
}));
}
function userInList(userList: string[] | undefined): boolean | undefined {
if (userList === undefined) {
return undefined;
}
return hass?.user?.name !== undefined && userList.includes(hass?.user?.name);
}
function setDefaultOpenState() {
// Do not run setDefaultOpenState if config.expanded is a JS template
if (configTemplate('expanded')) {
return;
}
if (userInList(config['start-expanded-users'])) {
setOpenStateAndDispatchEvent(true);
return;
}
if (configId === undefined) {
setOpenStateFromConfig();
return;
}
try {
const storageValue = localStorage.getItem(lastStorageOpenStateId);
if(storageValue === null){
setOpenStateFromConfig();
return;
}
// last state is stored in local storage
const openStateByStorage = storageValue ? storageValue === 'true' : open;
setOpenStateAndDispatchEvent(openStateByStorage);
} catch (e) {
console.error(e);
setOpenStateAndDispatchEvent(false);
}
}
function setOpenStateFromConfig() {
// first time, set the state from config
if (config.expanded !== undefined) {
setOpenStateAndDispatchEvent(config.expanded);
return;
}
setOpenStateAndDispatchEvent(false);
}
function toggleOpen(openState?: boolean) {
if (animationTimeout) {
clearTimeout(animationTimeout);
animationTimeout = null;
}
const newOpenState = openState !== undefined ? openState : !open;
if (!config.animation) {
setOpenStateAndDispatchEvent(newOpenState);
return;
}
dispatchOpenStateEvent(newOpenState);
animationState = newOpenState ? 'opening' : 'closing';
if (newOpenState) {
setOpenState(true);
animationTimeout = setTimeout(() => {
animationState = 'idle';
animationTimeout = null;
}, 350);
return;
}
animationTimeout = setTimeout(() => {
setOpenState(false);
animationState = 'idle';
animationTimeout = null;
}, 350);
}
function setOpenStateAndDispatchEvent(openState: boolean) {
setOpenState(openState);
dispatchOpenStateEvent(openState);
}
function setOpenState(openState: boolean) {
open = openState;
if (!preview && configId !== undefined) {
try {
localStorage.setItem(lastStorageOpenStateId, open ? 'true' : 'false');
} catch (e) {
/* eslint no-console: 0 */
console.error(e);
}
}
if (open && backgroundAnimationDuration === 0) {
backgroundAnimationDuration = 0.35;
}
}
function handleRawConfigUpdate(event: Event) {
const rawConfig: ExpanderCardRawConfig = (event as CustomEvent).detail?.rawConfig;
if (!rawConfig) {
return;
}
if (JSON.stringify(rawConfig) !== JSON.stringify(dashboardRawConfig)) {
dashboardRawConfig = rawConfig;
}
};
function handleLlCustomEvent(event: Event) {
const data: ExpanderCardLlCustomEventDetail = (event as CustomEvent).detail?.['expander-card']?.data;
if (data?.['expander-card-id'] && data['expander-card-id'] === config['expander-card-id']) {
if (data.action === 'open' && !open) {
toggleOpen(true);
return;
}
if (data.action === 'close' && open) {
toggleOpen(false);
return;
}
if (data.action === 'toggle') {
toggleOpen();
}
}
};
function cleanup() {
document.body.removeEventListener('ll-custom', handleLlCustomEvent);
document.body.removeEventListener('expander-card-raw-config-updated', handleRawConfigUpdate);
Object.entries(templateRenderers).forEach(([key, renderer]) => {
renderer.then((untrackFunc) => {
untrackFunc();
delete templateRenderers[key];
}).catch(() => {});
});
Object.entries(variableRenders).forEach(([key, renderer]) => {
renderer.then((untrackFunc) => {
untrackFunc();
delete variableRenders[key];
}).catch(() => {});
});
Object.entries(templateEvents).forEach(([key, untrackFunc]) => {
untrackFunc();
delete templateEvents[key];
});
};
const triggerHapticFeedback = (element: HTMLElement) => {
if (config.haptic && config.haptic !== 'none') {
forwardHaptic(element, config.haptic);
}
};
let touchElement: HTMLElement | undefined;
let isScrolling = false;
let startX = 0;
let startY = 0;
const touchStart = (event: TouchEvent) => {
ripple && (ripple.disabled = true);
touchElement = event.target as HTMLElement;
startX = event.touches[0].clientX;
startY = event.touches[0].clientY;
isScrolling = false;
};
const touchMove = (event: TouchEvent) => {
const currentX = event.touches[0].clientX;
const currentY = event.touches[0].clientY;
isScrolling = Math.abs(currentX - startX) > 10 || Math.abs(currentY - startY) > 10;
};
const touchCancel = () => {
ripple && (ripple.disabled = false);
touchElement = undefined;
isScrolling = false;
};
const touchEnd = () => {
ripple && (ripple.disabled = false);
};
const touchEndAction = (event: TouchEvent) => {
if (!isScrolling && touchElement === event.target && config['title-card-clickable']) {
triggerHapticFeedback(touchElement);
toggleOpen();
touchPreventClick = true;
// A touch event may not always be followed by a click event so we set a timeout to reset
touchPreventClickTimeout = window.setTimeout(() => {
touchPreventClick = false;
touchPreventClickTimeout = null;
}, 100);
// A touch event may not always be followed by a click event so we manually control the ripple
if (ripple) {
ripple.startPressAnimation();
ripple.endPressAnimation();
}
}
touchElement = undefined;
isScrolling = false;
};
const bindTemplateVariables = (haJS: Promise<HomeAssistantJavaScriptTemplatesRenderer>) => {
for (const v of Object.values(config.variables ?? {})) {
if (isJSTemplate(v.value_template)) {
variableRenders[v.variable] = trackJSTemplate(
haJS,
(res) => {
setJSTemplateRef(haJS, v.variable, res);
},
v.value_template as string,
{ config: config }
);
} else {
setJSTemplateRef(haJS, v.variable, v.value_template);
}
}
};
const bindExpanderCardEvents = (haJS: Promise<HomeAssistantJavaScriptTemplatesRenderer>) => {
templateEvents['expander-card'] = trackJSTemplateEvent(haJS, 'expander-card');
};
const bindTemplates = () => {
if (!config.templates) return;
const refs = Object.values(config.variables || {}).reduce(
(obj, value) => {
obj[value.variable] = undefined;
return obj;
},
{} as Record<string, unknown>
);
const haJS: Promise<HomeAssistantJavaScriptTemplatesRenderer> = getJSTemplateRenderer( { config: config, expanderCard: {} }, refs );
bindTemplateVariables(haJS);
bindExpanderCardEvents(haJS);
Object.values(config.templates || {}).forEach((t) => {
if (isJSTemplate(t.value_template)) {
templateRenderers[t.template] = trackJSTemplate(
haJS,
(res) => {
templateValues[t.template] = res;
},
t.value_template as string,
{ config: config }
);
} else {
templateValues[t.template] = t.value_template;
}
});
};
function setExpandedFromConfig(){
if (configTemplate('expanded')) {
return;
}
const minWidthExpanded = config['min-width-expanded'];
const maxWidthExpanded = config['max-width-expanded'];
const offsetWidth = document.body.offsetWidth;
if (minWidthExpanded && maxWidthExpanded) {
config.expanded = offsetWidth >= minWidthExpanded && offsetWidth <= maxWidthExpanded;
return;
}
if (minWidthExpanded) {
config.expanded = offsetWidth >= minWidthExpanded;
return;
}
if (maxWidthExpanded) {
config.expanded = offsetWidth <= maxWidthExpanded;
}
}
function setOpenStateByPreview() {
if (preview && dashboardRawConfig['preview-expanded'] !== false) {
// all expanders will be open so we don't dispatch event
setOpenState(true);
return;
}
if (configTemplate('expanded')) {
const templateExpanded = untrack(() => templateValues.expanded);
if (templateExpanded !== undefined) {
setOpenStateAndDispatchEvent(Boolean(templateExpanded));
} else {
setOpenStateAndDispatchEvent(false);
}
} else{
setDefaultOpenState();
}
}
function getTouchEventElement(): HTMLElement | undefined {
if (config['title-card-clickable'] && !config['title-card-button-overlay'] && titleCardDiv) {
return titleCardDiv;
}
if (buttonElement) {
return buttonElement;
}
return undefined;
}
onMount(() => {
bindTemplates();
// dispatch initial state to listeners once templates are bound
dispatchOpenStateEvent(false);
setExpandedFromConfig();
setOpenStateByPreview();
document.body.addEventListener('ll-custom', handleLlCustomEvent);
document.body.addEventListener('expander-card-raw-config-updated', handleRawConfigUpdate);
const touchEventElement = getTouchEventElement();
if (touchEventElement) {
touchEventElement.addEventListener('touchstart', touchStart, { passive: true, capture: true });
touchEventElement.addEventListener('touchmove', touchMove, { passive: true,capture: true });
touchEventElement.addEventListener('touchcancel', touchCancel, { passive: true, capture: true });
touchEventElement.addEventListener('touchend', touchEnd, { passive: true, capture: true });
touchEventElement.addEventListener('touchend', touchEndAction, { passive: false, capture: false });
}
if (config['title-card-clickable'] && config['title-card-button-overlay'] && titleCardDiv) {
const resizeObserver = new ResizeObserver(() => {
if (buttonElement && titleCardDiv && expanderCard) {
const titleRect = titleCardDiv.getBoundingClientRect();
// While margin/padding set by expander-card is equal, users may have styled different margin/padding
overlayHeight = titleRect.height -
parseFloat(getComputedStyle(buttonElement).marginTop) -
parseFloat(getComputedStyle(buttonElement).marginBottom) +
parseFloat(getComputedStyle(expanderCard).paddingTop) +
parseFloat(getComputedStyle(expanderCard).paddingBottom);
}
});
resizeObserver.observe(titleCardDiv);
}
return cleanup;
});
const buttonClick = (event: MouseEvent) => {
if (!touchPreventClick) {
triggerHapticFeedback(event.currentTarget as HTMLElement);
toggleOpen();
return undefined;
}
event.preventDefault();
event.stopImmediatePropagation();
touchPreventClick = false;
if (touchPreventClickTimeout) {
clearTimeout(touchPreventClickTimeout);
touchPreventClickTimeout = null;
}
return false;
};
</script>
<ha-card
class={`expander-card${config.clear ? ' clear' : ''}${open ? ' open' : ' close'} ${animationState}${config.animation ? ' animation ' + animationState : ''}`}
style="--expander-card-display:{config['expander-card-display']};
--gap:{open && animationState !=='closing' ? config['expanded-gap'] : config.gap}; --padding:{config.padding};
--expander-state:{open};
--icon-rotate-degree:{config['icon-rotate-degree']};
--card-background:{open && animationState !== 'closing' &&
config['expander-card-background-expanded'] ?
config['expander-card-background-expanded'] : config['expander-card-background']};
--background-animation-duration:{backgroundAnimationDuration}s;
--expander-card-overlay-height:{overlayHeight ? `${overlayHeight}px` : 'auto'};
"
bind:this={expanderCard}>
{#if config['title-card']}
<div id='id1' class={`title-card-header${config['title-card-button-overlay'] ?
'-overlay' : ''}${open ? ' open' : ' close'}${config.animation ?
' animation ' + animationState : ''}${config['title-card-clickable'] ? ' clickable' : ''}`}
onclick={config['title-card-clickable'] && !config['title-card-button-overlay'] ? buttonClick : null}
role={config['title-card-clickable'] && !config['title-card-button-overlay'] ? 'button' : undefined}
bind:this={titleCardDiv}
>
<div id='id2'
class={`title-card-container${open ? ' open' : ' close'}${config.animation ? ' animation ' + animationState : ''}`}
style="--title-padding:{config['title-card-padding'] ? config['title-card-padding'] : '0px'};">
<Card hass={hass}
preview={preview}
config={config['title-card']}
animation={false}
open={true}
animationState='idle'
clearCardCss={config['clear-children']!}
/>
</div>
{#if showButtonUsers}
<button
onclick={!config['title-card-clickable'] || config['title-card-button-overlay'] ? buttonClick : null }
style="--overlay-margin:{config['overlay-margin']}; --button-background:{config[
'button-background'
]}; --header-color:{config['header-color']};"
class={`header ${config['title-card-button-overlay'] ?
' header-overlay' : ''}${open ? ' open' : ' close'}${config.animation ? ' animation ' + animationState : ''}`}
aria-label="Toggle button"
bind:this={buttonElement}
>
<ha-icon style="--arrow-color:{arrowColorConfigOrTemplate}"
icon={iconConfigOrTemplate}
class={`ico${open && animationState !=='closing' ? ' flipped open' : ' close'}${config.animation ? ' animation ' + animationState : ''}`}>
</ha-icon>
{#if !config['title-card-clickable'] || config['title-card-button-overlay'] }
<ha-ripple bind:this={ripple}></ha-ripple>
{/if}
</button>
{/if}
{#if config['title-card-clickable'] && !config['title-card-button-overlay'] }
<ha-ripple bind:this={ripple}></ha-ripple>
{/if}
</div>
{:else}
{#if showButtonUsers}
<button onclick={buttonClick}
class={`header${open ? ' open' : ' close'}${config.animation ? ' animation ' + animationState : ''}`}
style="--header-width:100%; --button-background:{config['button-background']};--header-color:{config['header-color']};"
bind:this={buttonElement}
>
<div class={`primary title${open ? ' open' : ' close'}`}>{titleConfigOrTemplate}</div>
<ha-icon style="--arrow-color:{arrowColorConfigOrTemplate}"
icon={iconConfigOrTemplate}
class={`ico${open && animationState !=='closing' ? ' flipped open' : ' close'}${config.animation ? ' animation ' + animationState : ''}`}>
</ha-icon>
<ha-ripple bind:this={ripple}></ha-ripple>
</button>
{/if}
{/if}
{#if config.cards}
<div class="children-wrapper {config.animation ? 'animation ' + animationState : ''}{open ? ' open' : ' close'}">
<div
style="--expander-card-display:{config['expander-card-display']};
--gap:{open && animationState !=='closing' ? config['expanded-gap'] : config.gap};
--child-padding:{open && animationState !=='closing' ? config['child-padding'] : '0px'};"
class="children-container{open ? ' open' : ' close'}{config.animation ? ' animation ' + animationState : ''}"
>
{#each config.cards as card (card)}
<Card hass={hass}
preview={open && preview}
config={card}
marginTop={config['child-margin-top']}
open={open}
animation={config.animation!}
animationState={animationState}
clearCardCss={config['clear-children']!}
/>
{/each}
</div>
</div>
{/if}
{#if userStyleTemplateOrConfig}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html userStyleTemplateOrConfig}
{/if}
</ha-card>
<style>
.expander-card {
display: var(--expander-card-display,block);
gap: var(--gap);
padding: var(--padding);
background: var(--card-background,#fff);
-webkit-tap-highlight-color: transparent;
}
.expander-card.animation {
transition: gap 0.35s ease, background-color var(--background-animation-duration, 0) ease;
}
.children-wrapper {
display: flex;
flex-direction: column;
}
.children-wrapper.animation.opening,
.children-wrapper.animation.closing {
overflow: hidden;
}
.children-container.animation {
transition: padding 0.35s ease, gap 0.35s ease;
}
.children-container {
padding: var(--child-padding);
display: var(--expander-card-display,block);
gap: var(--gap);
}
.clear {
background: none !important;
background-color: transparent !important;
border-style: none !important;
box-shadow: none !important;
}
.title-card-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-direction: row;
position: relative;
}
.title-card-header.clickable {
cursor: pointer;
border-style: none;
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
}
.title-card-header-overlay {
display: block;
}
.title-card-container {
width: 100%;
padding: var(--title-padding);
}
.header {
display: flex;
flex-direction: row;
align-items: center;
padding: 0.85em 0.85em;
background: var(--button-background);
border-style: none;
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
width: var(--header-width,auto);
color: var(--header-color,#fff);
cursor: pointer;
position: relative;
font-family: var(--ha-font-family-body);
font-size: var(--ha-font-size-m);
}
.header-overlay {
position: absolute;
top: 0;
right: 0;
margin: var(--overlay-margin);
height: var(--expander-card-overlay-height, auto);
z-index: 1;
}
.title-card-header-overlay.clickable > .header-overlay {
width: calc(100% - var(--overlay-margin) * 2);
justify-content: flex-end;
}
.title-card-header-overlay.clickable > .title-card-container {
width: calc(100% - var(--overlay-margin) * 2);
}
.title {
width: 100%;
text-align: left;
}
.ico.animation {
transition-property: transform;
transition-duration: 0.35s;
}
.ico {
color: var(--arrow-color,var(--primary-text-color,#fff));
}
.flipped {
transform: rotate(var(--icon-rotate-degree,180deg));
}
</style>
================================================
FILE: src/ExpanderCardEditor.ts
================================================
/* eslint-disable no-underscore-dangle */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ExpanderCardEditorNulls, ExpanderCardEditorSchema, expanderCardEditorTemplates, styleSchemaCSS, styleSchemaObject, StyleSchemaTypes } from './editortype';
import { showTitleCardEditFormDialog, TitleCardEditFormParams } from './title-card/showTitleCardEditForm';
import { HomeAssistantUser } from './types';
const wdw = window; // NOSONAR es2019
let helpers = (wdw as any).cardHelpers;
const helperPromise = new Promise<void>((resolve) => {
if (helpers) resolve();
if ((wdw as any).loadCardHelpers) {
(wdw as any).loadCardHelpers().then((loadedHelpers: any) => {
helpers = loadedHelpers;
(wdw as any).cardHelpers = helpers;
resolve();
});
}
});
async function fetchUsers(): Promise<void> {
const el = document.querySelector('home-assistant');
const hass = (el as any)?.hass;
if (!hass) return;
const users = await hass.callWS({ type: 'config/auth/list' });
return users
.filter((user: HomeAssistantUser) => !user.system_generated)
.map((user: HomeAssistantUser) => user.name);
}
const loader = async (): Promise<any> => {
// create a temporary vertical-stack card to inherit from
const verticalStackCard = await helperPromise.then(() =>
helpers.createCardElement({ type: 'vertical-stack', cards: [] }));
// get its editor class once hui-vertical-stack-card is defined
// we need check hui-vertical-stack-card is defined as it is lazily loaded
const verticalStackEditor = await customElements.whenDefined('hui-vertical-stack-card')
.then(() => verticalStackCard.constructor.getConfigElement());
// fetch users
const users = await fetchUsers();
// return a new class that extends the vertical-stack editor
return class ExpanderCardEditor extends verticalStackEditor.constructor {
public constructor() {
super();
this._users = users;
}
// override setConfig to store config only and not assert stack editor config
// we also upgrade any old config here if needed
public setConfig(config: any): void {
this._config = config;
}
// define _schema getter to return our own schema
public get _schema(): any {
const schema = ExpanderCardEditorSchema;
const schemaJSON = JSON.stringify(schema);
const usersEscaped = this._users
.map((u: string) => u.replace(/\\/g, '\\\\').replace(/"/g, '\\"')) // NOSONAR es2019
.join('","');
let populatedSchemaJSON = schemaJSON.replace(/\[\[users\]\]/g, usersEscaped); // NOSONAR es2019
// populate templates options, but only those not already in config
populatedSchemaJSON = populatedSchemaJSON.replace(/\[\[templates\]\]/g, // NOSONAR es2019
expanderCardEditorTemplates
.filter((t: any) => !this._config.templates?.some((ct: any) => ct.template === t))
.join('","'));
// populate advanced styling schema
const styleSchemaType: StyleSchemaTypes = this._config.style && typeof this._config.style === 'object' ? StyleSchemaTypes.Object : StyleSchemaTypes.CSS;
const styleSchema = styleSchemaType === StyleSchemaTypes.CSS ? JSON.stringify(styleSchemaCSS) : JSON.stringify(styleSchemaObject);
populatedSchemaJSON = populatedSchemaJSON.replace(/"\[\[style\]\]"/g, styleSchema); // NOSONAR es2019
const populatedSchema = JSON.parse(populatedSchemaJSON);
return populatedSchema;
}
// _schema setter does nothing as we want to use our own schema
public set _schema(_) {
// do nothing
}
public connectedCallback(): void {
super.connectedCallback();
this.addEventListener('show-dialog', this.showDialogCallback.bind(this), true);
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener('show-dialog', this.showDialogCallback.bind(this), true);
}
private readonly showDialogCallback = (ev: CustomEvent): void => {
const isExpanderCardTitleCardSchema =
ev.detail?.dialogParams?.schema?.find((s: any) => s.name === 'expander_card_title_card_marker');
if (isExpanderCardTitleCardSchema) {
ev.stopPropagation();
// load the form-dialog element to make sure ha-dialog is defined
// then show the title card edit form dialog
if (ev.detail?.dialogImport) {
ev.detail.dialogImport().then(async () => {
const params: TitleCardEditFormParams = {
title: 'Title card',
config: this._config['title-card'] || {},
submit: ev.detail?.dialogParams?.submit,
cancel: ev.detail?.dialogParams?.cancel,
submitText: ev.detail?.dialogParams?.submitText,
cancelText: ev.detail?.dialogParams?.cancelText,
lovelace: this.lovelace
};
await showTitleCardEditFormDialog(
this as unknown as HTMLElement,
params
);
});
}
}
};
// override _computeLabelCallback to show label or name
public _computeLabelCallback = (item: any): string => item.label ?? item.name ?? '';
// override _valueChanged to remove null values from config before storing and firing event
public _valueChanged = (ev: CustomEvent): void => {
const config = ev.detail.value;
const entries = Object.entries(ExpanderCardEditorNulls);
for (const [key, value] of entries) {
if (typeof value === 'object' && Array.isArray(value) && Array.isArray(config[key])) {
if (JSON.stringify(config[key]) === JSON.stringify(value)) {
delete config[key];
}
continue;
}
if (config[key] === value) {
delete config[key];
}
}
this._config = config;
this.dispatchEvent(new CustomEvent('config-changed', { detail: { config: this._config } }));
};
};
};
export const loadExpanderCardEditor = (async () => {
// Wait for scoped customElements registry to be set up
while (customElements.get('home-assistant') === undefined)
await new Promise((resolve) => wdw.setTimeout(resolve, 100));
if (!customElements.get('expander-card-editor')) {
const expanderCardEditor = await loader();
customElements.define('expander-card-editor', expanderCardEditor);
}
});
================================================
FILE: src/configtype.ts
================================================
/*
Copyright 2021-2022 Peter Repukat - FlatspotSoftware
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import type { LovelaceCardConfig } from './types';
export interface ExpanderCardVariables {
variable: string;
value_template: unknown;
}
export interface ExpanderCardTemplates {
template: string;
value_template: unknown;
}
export interface ExpanderConfig {
clear?: boolean;
'clear-children'?: boolean;
cards?: { type: string }[];
gap?: string;
'expanded-gap'?: string;
padding?: string;
title?: string;
'title-card'?: LovelaceCardConfig;
'title-card-padding'?: string;
'title-card-button-overlay'?: false;
'title-card-clickable'?: boolean;
'overlay-margin'?: string;
'child-padding'?: string;
'child-margin-top'?: string;
expanded?: boolean;
'expander-card-background'?: string;
'expander-card-background-expanded'?: string;
'header-color'?: string;
'button-background'?: string;
'arrow-color'?: string;
'expander-card-display'?: string;
'min-width-expanded'?: number;
'max-width-expanded'?: number;
icon?: string;
'storage-id'?: string;
'icon-rotate-degree'?: string;
'show-button-users'?: string[];
'start-expanded-users'?: string[];
animation?: boolean;
haptic?: 'success' | 'warning' | 'failure' | 'light' | 'medium' | 'heavy' | 'selection' | 'none';
'expander-card-id'?: string;
style?: string | Record<string, (string | Record<string, string>)[]>;
variables?: Record<string, ExpanderCardVariables>;
templates?: Record<string, ExpanderCardTemplates>;
}
export interface ExpanderCardRawConfig {
'preview-expanded'?: boolean;
}
================================================
FILE: src/editortype.ts
================================================
/* eslint-disable quote-props */
import { ExpanderConfig } from './configtype';
export const ExpanderCardEditorNulls: ExpanderConfig = {
icon: '',
'arrow-color': '',
'icon-rotate-degree': '',
'header-color': '',
'button-background': '',
'min-width-expanded': 0,
'max-width-expanded': 0,
'storage-id': '',
'expander-card-id': '',
'show-button-users': [],
'start-expanded-users': [],
'expander-card-background': '',
'expander-card-background-expanded': '',
'expander-card-display': '',
gap: '',
padding: '',
'expanded-gap': '',
'child-padding': '',
'child-margin-top': '',
'overlay-margin': '',
'title-card-padding': '',
'style': ''
};
export const expanderCardEditorTemplates = [
'expanded',
'icon',
'arrow-color',
'title',
'style'
];
export enum StyleSchemaTypes {
CSS = 'css',
Object = 'object'
}
export const styleSchemaCSS = { name: 'style', label: 'CSS text', selector: { text: { multiline: true } } };
export const styleSchemaObject = { name: 'style', label: 'CSS structured object', selector: { object: {} } };
const iconSelector = { icon: {} };
const textSelector = { text: {} };
const booleanSelector = { boolean: {} };
const numberSelector = (unit_of_measurement: string) => ({
number: {
unit_of_measurement
}
});
const iconField = (name: string, label: string) => ({
name,
label,
selector: iconSelector
});
const textField = (name: string, label: string) => ({
name,
label,
selector: textSelector
});
const booleanField = (name: string, label: string) => ({
name,
label,
selector: booleanSelector
});
const numberField = (name: string, label: string, unit_of_measurement: string) => ({
name,
label,
selector: numberSelector(unit_of_measurement)
});
// See https://www.home-assistant.io/docs/blueprint/selectors
export const ExpanderCardEditorSchema = [
{
type: 'expandable',
label: 'Expander Card Settings',
icon: 'mdi:arrow-down-bold-box-outline',
schema: [
{
...textField('title', 'Title')
},
{
...iconField('icon', 'Icon')
},
{
type: 'expandable',
label: 'Expander control',
icon: 'mdi:cog-outline',
schema: [
{
type: 'grid',
schema: [
{
...booleanField('expanded', 'Start expanded')
},
{
...booleanField('animation', 'Enable animation')
},
{
name: 'haptic',
label: 'Haptic feedback',
selector: {
select: {
mode: 'dropdown',
options: [
{ value: 'light', label: 'Light' },
{ value: 'medium', label: 'Medium' },
{ value: 'heavy', label: 'Heavy' },
{ value: 'success', label: 'Success' },
{ value: 'warning', label: 'Warning' },
{ value: 'failure', label: 'Failure' },
{ value: 'selection', label: 'Selection' },
{ value: 'none', label: 'None' }
]
}
}
},
{
...numberField('min-width-expanded', 'Min width expanded', 'px')
},
{
...numberField('max-width-expanded', 'Max width expanded', 'px')
},
{
...textField('storage-id', 'Storage ID')
},
{
...textField('expander-card-id', 'Expander card ID')
}
]
}
]
},
{
type: 'expandable',
label: 'Expander styling',
icon: 'mdi:palette-swatch',
schema: [
{
type: 'grid',
schema: [
{
...textField('arrow-color', 'Icon color')
},
{
...textField('icon-rotate-degree', 'Icon rotate degree')
},
{
...textField('header-color', 'Header color')
},
{
...textField('button-background', 'Button background color')
},
{
...textField('expander-card-background', 'Background')
},
{
...textField('expander-card-background-expanded', 'Background when expanded')
},
{
...textField('expander-card-display', 'Expander card display')
},
{
...booleanField('clear', 'Clear border and background')
},
{
...textField('gap', 'Gap')
},
{
...textField('padding', 'Padding')
}
]
}
]
},
{
type: 'expandable',
label: 'Card styling',
icon: 'mdi:palette-swatch-outline',
schema: [
{
type: 'grid',
schema: [
{
...textField('expanded-gap', 'Card gap')
},
{
...textField('child-padding', 'Card padding')
},
{
...textField('child-margin-top', 'Card margin top')
},
{
...booleanField('clear-children', 'Clear card border and background')
}
]
}
]
},
{
type: 'expandable',
label: 'Title card',
icon: 'mdi:subtitles-outline',
schema: [
{
// title-card selector. We will override Add and Edit to show card UI editor
name: 'title-card',
label: 'Title card',
selector: {
object: {
label_field: 'type',
fields: {
type: {
label: 'Card type',
required: true,
selector: { text: {} }
},
// include a marker field so we can identify schema in show-dialog event
expander_card_title_card_marker: {
required: false,
selector: { text: {} }
}
}
}
}
},
{
type: 'grid',
schema: [
{
...booleanField('title-card-clickable', 'Make title card clickable to expand/collapse')
},
{
...booleanField('title-card-button-overlay', 'Overlay expand button on title card')
},
{
...textField('overlay-margin', 'Overlay margin')
},
{
...textField('title-card-padding', 'Title card padding')
}
]
}
]
},
{
type: 'expandable',
label: 'User settings',
icon: 'mdi:account-multiple-outline',
schema: [
{
type: 'grid',
schema: [
{
name: 'show-button-users',
label: 'Show button users',
selector: {
select: {
multiple: true,
mode: 'dropdown',
custom: true, // to allow for unknown users
options: ['[[users]]'] // to be populated dynamically
}
}
},
{
name: 'start-expanded-users',
label: 'Start expanded users',
selector: {
select: {
multiple: true,
mode: 'dropdown',
custom: true, // to allow for unknown users
options: ['[[users]]'] // to be populated dynamically
}
}
}
]
}
]
},
{
type: 'expandable',
label: 'Advanced styling',
icon: 'mdi:brush-outline',
schema: ['[[style]]'] // to be populated dynamically
},
{
type: 'expandable',
label: 'Advanced templates',
icon: 'mdi:code-brackets',
schema: [
{
type: 'expandable',
label: 'Variables',
icon: 'mdi:variable',
schema: [
{
name: 'variables',
label: 'Variables',
selector: {
object: {
label_field: 'variable',
multiple: true,
fields: {
variable: {
label: 'Variable name',
required: true,
selector: { text: {} }
},
value_template: {
label: 'Value template',
required: true,
selector: { text: { multiline: true } }
}
}
}
}
}
]
},
{
type: 'expandable',
label: 'Templates',
icon: 'mdi:code-brackets',
schema: [
{
name: 'templates',
label: 'Templates',
selector: {
object: {
label_field: 'template',
multiple: true,
fields: {
template: {
label: 'Config item',
required: true,
selector: {
select: {
mode: 'dropdown',
custom_value: true, // to allow for current templates not in dropdown
sort: true,
options: ['[[templates]]'] // to be populated dynamically
}
}
},
value_template: {
label: 'Value template',
required: true,
selector: { template: {} }
}
}
}
}
}
]
}
]
}
]
}
];
================================================
FILE: src/helpers/compute-card-size.ts
================================================
import { HuiCard } from '../types';
import { TimeoutError } from './promise-timeout';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const promiseTimeout = (ms: number, promise: Promise<any> | any) => { // NOSONAR
const timeout = new Promise((_resolve, reject) => {
setTimeout(() => {
reject(new TimeoutError(ms));
}, ms);
});
// Returns a race between our timeout and the passed in promise
return Promise.race([promise, timeout]);
};
export const computeCardSize = (
card: HuiCard
): number | Promise<number> => {
if (typeof card.getCardSize === 'function') {
try {
return promiseTimeout(500, card.getCardSize()).catch(
() => 1
) as Promise<number>;
} catch {
return 1;
}
}
if (customElements.get(card.localName)) {
return 1;
}
return customElements
.whenDefined(card.localName)
.then(() => computeCardSize(card));
};
================================================
FILE: src/helpers/forward-haptic.ts
================================================
// Allowed types are from iOS HIG.
// https://developer.apple.com/design/human-interface-guidelines/ios/user-interaction/feedback/#haptics
// Implementors on platforms other than iOS should attempt to match the patterns (shown in HIG) as closely as possible.
export type HapticType = 'success' | 'warning' | 'failure' | 'light' | 'medium' | 'heavy' | 'selection' | 'none';
declare global {
// for fire event
interface HASSDomEvents {
haptic: HapticType;
}
}
export const forwardHaptic = (node: HTMLElement, hapticType: HapticType) => {
node.dispatchEvent?.(
new CustomEvent('haptic',
{ detail: hapticType, bubbles: true, composed: true }
)
);
};
================================================
FILE: src/helpers/ha-dialog-styles.ts
================================================
import { css } from 'lit';
export const haStyleDialog = css`
ha-dialog,
ha-adaptive-dialog {
--mdc-dialog-min-width: 400px;
--mdc-dialog-max-width: 600px;
--mdc-dialog-max-width: min(600px, 95vw);
--justify-action-buttons: space-between;
--dialog-container-padding: var(--safe-area-inset-top, 0)
var(--safe-area-inset-right, 0) var(--safe-area-inset-bottom, 0)
var(--safe-area-inset-left, 0);
--dialog-surface-padding: 0px;
}
ha-dialog .form,
ha-adaptive-dialog .form {
color: var(--primary-text-color);
}
a {
color: var(--primary-color);
}
/* make dialog fullscreen on small screens */
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-dialog,
ha-adaptive-dialog {
--mdc-dialog-min-width: 100vw;
--mdc-dialog-max-width: 100vw;
--mdc-dialog-min-height: 100vh;
--mdc-dialog-min-height: 100svh;
--mdc-dialog-max-height: 100vh;
--mdc-dialog-max-height: 100svh;
--dialog-container-padding: 0px;
--dialog-surface-padding: var(--safe-area-inset-top, 0)
var(--safe-area-inset-right, 0) var(--safe-area-inset-bottom, 0)
var(--safe-area-inset-left, 0);
--vertical-align-dialog: flex-end;
}
ha-dialog {
--ha-dialog-border-radius: var(--ha-border-radius-square);
}
}
.error {
color: var(--error-color);
}
`;
export const haStyleDialogFixedTop = css`
ha-dialog,
ha-adaptive-dialog {
/* Pin dialog to top so it doesn't jump when content changes size */
--vertical-align-dialog: flex-start;
--dialog-surface-margin-top: var(--ha-space-10);
--mdc-dialog-max-height: calc(
100vh - var(--dialog-surface-margin-top) - var(--ha-space-2) - var(
--safe-area-inset-y,
0px
)
);
--mdc-dialog-max-height: calc(
100svh - var(--dialog-surface-margin-top) - var(--ha-space-2) - var(
--safe-area-inset-y,
0px
)
);
--ha-dialog-max-height: calc(
100vh - var(--dialog-surface-margin-top) - var(--ha-space-2) - var(
--safe-area-inset-y,
0px
)
);
--ha-dialog-max-height: calc(
100svh - var(--dialog-surface-margin-top) - var(--ha-space-2) - var(
--safe-area-inset-y,
0px
)
);
}
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-dialog,
ha-adaptive-dialog {
/* When in fullscreen, dialog should be attached to top */
--dialog-surface-margin-top: 0px;
--mdc-dialog-min-height: 100vh;
--mdc-dialog-min-height: 100svh;
--mdc-dialog-max-height: 100vh;
--mdc-dialog-max-height: 100svh;
--ha-dialog-max-height: 100vh;
--ha-dialog-max-height: 100svh;
}
}
`;
================================================
FILE: src/helpers/promise-timeout.ts
================================================
/* eslint-disable @typescript-eslint/no-explicit-any */
export class TimeoutError extends Error {
public timeout: number;
// eslint-disable-next-line @typescript-eslint/explicit-member-accessibility
constructor(timeout: number, ...params: undefined[]) {
super(...params);
// Maintains proper stack trace for where our error was thrown (only available on V8)
if ((Error as any).captureStackTrace) {
(Error as any).captureStackTrace(this, TimeoutError);
}
this.name = 'TimeoutError';
// Custom debugging information
this.timeout = timeout;
this.message = `Timed out in ${timeout} ms.`;
}
}
export const promiseTimeout = (ms: number, promise: Promise<any> | any) => { // NOSONAR
const timeout = new Promise((_resolve, reject) => {
setTimeout(() => {
reject(new TimeoutError(ms));
}, ms);
});
// Returns a race between our timeout and the passed in promise
return Promise.race([promise, timeout]);
};
================================================
FILE: src/helpers/raw-config.ts
================================================
import { HAQuerySelector } from 'home-assistant-query-selector';
import { ExpanderCardRawConfig } from '../configtype';
import { HuiRoot } from '../types';
const instance = new HAQuerySelector();
let rawConfig: ExpanderCardRawConfig = {};
instance.addEventListener('onLovelacePanelLoad', ({ detail }) => {
detail.HUI_ROOT.element.then((root) => {
const lovelaceConfig = (root as HuiRoot)?.lovelace;
if (lovelaceConfig?.config) {
rawConfig = lovelaceConfig.config['expander-card'] || {};
}
}).catch(() => {
rawConfig = {};
}).finally(() => {
document.body.dispatchEvent(new CustomEvent('expander-card-raw-config-updated', {
detail: { rawConfig }, bubbles: true, composed: true
}));
});
});
instance.listen();
export const getDashboardRawConfig = (): ExpanderCardRawConfig => rawConfig;
================================================
FILE: src/helpers/style-converter.ts
================================================
/**
* Converts a style value (string or object) to a CSS string.
* @param style - The style value to convert, can be a string or an object with CSS selectors as keys
* and CSS property arrays as values (either strings or objects)
* @returns A CSS string suitable for injection into a <style> tag
*/
export const styleToString = (style: string | Record<string, (string | Record<string, string>)[]> | undefined): string | null => {
if (!style) {
return null;
}
if (typeof style === 'string') {
return style;
}
// Convert nested object to CSS string
// Object format: { ".selector": ["property1: value", ...] } or { ".selector": [{"property1": "value"}, ...] }
return Object.entries(style)
.map(([selector, properties]) => {
// Skip if properties is not an array
if (!Array.isArray(properties)) {
return null;
}
const rules = properties
.map((property) => {
// Handle both string format and object format
if (typeof property === 'string') {
return ` ${property};`;
}
// For objects, get the first key-value pair
const [key, value] = Object.entries(property)[0];
return ` ${key}: ${value};`;
})
.join('\n');
return `${selector} {\n${rules}\n}`;
})
.filter((rule) => rule !== null)
.join('\n');
};
================================================
FILE: src/helpers/templates.ts
================================================
import HomeAssistantJavaScriptTemplates, { HomeAssistant, HomeAssistantJavaScriptTemplatesRenderer } from 'home-assistant-javascript-templates';
import { ExpanderCardEventDetail } from '../types';
export function getJSTemplateRenderer(variables: Record<string, unknown> = {}, refs: Record<string, unknown> = {}): Promise<HomeAssistantJavaScriptTemplatesRenderer> {
return new HomeAssistantJavaScriptTemplates(
document.querySelector('home-assistant') as HomeAssistant,
{
autoReturn: false,
variables,
refs,
refsVariableName: 'variables'
}
).getRenderer();
}
export function isJSTemplate(template: unknown): boolean {
if (!template || typeof template !== 'string') return false;
return String(template).trim().startsWith('[[[') && String(template).trim().endsWith(']]]');
}
export function renderJSTemplate(
templatesRenderer: Promise<HomeAssistantJavaScriptTemplatesRenderer>,
template: string,
variables: Record<string, unknown> = {}) {
if (!isJSTemplate(template)) {
throw new Error('Not a valid JS template');
}
template = String(template).trim().slice(3, -3);
void templatesRenderer.then((renderer) => renderer.renderTemplate(template, { variables } ));
}
export function trackJSTemplate(
templatesRenderer: Promise<HomeAssistantJavaScriptTemplatesRenderer>,
callback: (result: unknown) => void,
template: string,
variables: Record<string, unknown> = {}): Promise<(() => void)> {
if (!isJSTemplate(template)) {
throw new Error('Not a valid JS template');
}
template = String(template).trim().slice(3, -3);
return templatesRenderer.then((renderer) => renderer.trackTemplate(template, callback, { variables }));
}
export function setJSTemplateRef(
templatesRenderer: Promise<HomeAssistantJavaScriptTemplatesRenderer>,
refName: string,
refValue: unknown) {
void templatesRenderer.then((renderer) => {
renderer.refs[refName] = refValue;
});
}
function eventHandler(templatesRenderer: Promise<HomeAssistantJavaScriptTemplatesRenderer>, event: Event) {
void templatesRenderer.then((renderer) => {
const detail = (event as CustomEvent).detail as ExpanderCardEventDetail;
Object.keys(detail).forEach((key) => {
const property = detail[key].property;
const value = detail[key].value;
const variableName = `${key}_${property}`;
renderer.refs[variableName] = value;
});
});
}
export function trackJSTemplateEvent(
templatesRenderer: Promise<HomeAssistantJavaScriptTemplatesRenderer>,
eventName: string): () => void {
const boundEventHandler: EventListener = eventHandler.bind(null, templatesRenderer);
document.addEventListener(eventName, boundEventHandler);
return () => {
document.removeEventListener(eventName, boundEventHandler);
};
}
================================================
FILE: src/index.ts
================================================
/*
Copyright 2021-2022 Peter Repukat - FlatspotSoftware
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export { default } from './ExpanderCard.svelte';
import { version } from '../package.json';
import { TitleCardEditForm } from './title-card/titleCardEditForm';
declare global {
interface Window {
customCards?: {
type: string;
name: string;
preview: boolean;
description: string;
}[];
}
}
/* eslint no-console: 0 */
console.info(
`%c Expander-Card \n%c Version ${version}`,
'color: orange; font-weight: bold; background: black',
'color: white; font-weight: bold; background: dimgray'
);
window.customCards = window.customCards || []; // NOSONAR es2019
window.customCards.push(...[ // NOSONAR es2019
{
type: 'expander-card',
name: 'Expander Card',
preview: true,
description: 'Expander card'
}
]);
if (!customElements.get('expander-card-title-card-edit-form')) {
customElements.define('expander-card-title-card-edit-form', TitleCardEditForm);
}
================================================
FILE: src/title-card/showTitleCardEditForm.ts
================================================
export type TitleCardConfig = {
type?: string;
} & Record<string, unknown>;
export interface TitleCardEditFormParams {
title: string;
config?: TitleCardConfig;
submit?: (config: TitleCardConfig) => void;
cancel?: () => void;
submitText?: string;
cancelText?: string;
lovelace?: unknown;
};
export const showTitleCardEditFormDialog = (
element: HTMLElement,
dialogParams: TitleCardEditFormParams
) =>
new Promise<TitleCardConfig | null>((resolve) => {
const origCancel = dialogParams.cancel;
const origSubmit = dialogParams.submit;
element.dispatchEvent(
new CustomEvent('show-dialog',
{
detail:{
dialogTag: 'expander-card-title-card-edit-form',
dialogImport: () => customElements.whenDefined('expander-card-title-card-edit-form'),
dialogParams: {
...dialogParams,
cancel: () => {
resolve(null);
if (origCancel) {
origCancel();
}
},
submit: (data: TitleCardConfig) => {
resolve(data);
if (origSubmit) {
origSubmit(data);
}
}
}
},
bubbles: true,
composed: true
}
)
);
});
================================================
FILE: src/title-card/titleCardEditForm.ts
================================================
/* eslint-disable no-underscore-dangle */
import { css, html, LitElement, nothing } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import { HomeAssistant, HuiElementEditor } from '../types';
import { TitleCardConfig, TitleCardEditFormParams } from './showTitleCardEditForm';
import { ifDefined } from 'lit/directives/if-defined.js';
import { haStyleDialog, haStyleDialogFixedTop } from '../helpers/ha-dialog-styles';
export const CUSTOM_TYPE_PREFIX = 'custom:';
const isCustomType = (type: string) =>
type.startsWith(CUSTOM_TYPE_PREFIX);
const getCustomCardEntry = (type: string) =>
window.customCards?.find((card) => card.type === type); // NOSONAR es2019
const stripCustomPrefix = (type: string) =>
type.replace(CUSTOM_TYPE_PREFIX, '');
@customElement('expander-card-title-card-edit-form')
export class TitleCardEditForm extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ type: Boolean, reflect: true }) public large = false;
@property({ attribute: false }) public lovelace?: unknown;
@state() private _params?: TitleCardEditFormParams;
@state() private _config: TitleCardConfig = {};
@state() private _cardGUIMode = true;
@state() private _cardGUIModeAvailable = true;
@state() private _error = false;
@query('hui-card-element-editor') private _cardEditorEl?: HuiElementEditor; // NOSONAR Lit @query decorator updates
public async showDialog(params: TitleCardEditFormParams): Promise<void> {
this._params = params;
this._config = params.config ?? {};
this.lovelace = params.lovelace;
this.large = false;
}
public closeDialog() {
this._params = undefined;
this._config = {};
this.dispatchEvent(new CustomEvent('dialog-closed', { detail: { dialog: this.localName } }));
return true;
}
private _submit(): void {
this._params?.submit?.(this._config);
this.closeDialog();
}
private _cancel(): void {
this._params?.cancel?.();
this.closeDialog();
}
private _enlarge() {
this.large = !this.large;
}
private _ignoreKeydown(ev: KeyboardEvent): void {
ev.stopPropagation();
}
protected render() {
if (!this._params || !this.hass) {
return nothing;
}
const disableSave = (!this._config.type || this._error) || undefined;
let heading: string = this._params.title ?? '';
if (this._config.type) {
let cardName: string | undefined;
if (isCustomType(this._config.type)) {
// prettier-ignore
cardName = getCustomCardEntry(
stripCustomPrefix(this._config.type)
)?.name;
// Trim names that end in " Card" so as not to redundantly duplicate it
if (cardName?.toLowerCase().endsWith(' card')) {
cardName = cardName.substring(0, cardName.length - 5);
}
} else {
cardName = this.hass.localize(
`ui.panel.lovelace.editor.card.${this._config.type}.name`
);
}
heading = `${heading} - ${this.hass.localize(
'ui.panel.lovelace.editor.edit_card.typed_header',
{ type: cardName }
)}`;
}
return html`
<ha-dialog
open
scrimClickAction
escapeKeyAction
@keydown=${this._ignoreKeydown.bind(this)}
@closed=${ this._cancel.bind(this) }
.heading=${heading}
.width=${this.large ? 'full' : 'large'}
>
<ha-dialog-header slot="header">
<ha-icon-button
slot="navigationIcon"
dialogAction="cancel"
.label=${this.hass.localize('ui.common.close')}
>
<ha-icon .icon=${'mdi:close'}></ha-icon>
</ha-icon-button>
<span slot="title" @click=${this._enlarge.bind(this)}>${heading}</span>
</ha-dialog-header>
${this._renderCardEditor()}
<ha-dialog-footer slot="footer">
<div slot="primaryAction" @click=${this._submit.bind(this)}>
<ha-button
appearance="plain"
size="small"
@click=${this._cancel.bind(this)}
dialogInitialFocus
>
${this._params.cancelText || this.hass.localize('ui.common.cancel')}
</ha-button>
<ha-button
size="small"
@click=${this._submit.bind(this)}
disabled=${ifDefined(disableSave)}
>
${this._params.submitText || this.hass.localize('ui.common.save')}
</ha-button>
</div>
${this._renderCardEditorActions()}
</ha-dialog-footer>
</ha-dialog>
`;
}
private _toggleCardMode() {
this._cardEditorEl?.toggleMode();
}
private _deleteCard() {
this._config = {};
}
private _cardConfigChanged(ev: CustomEvent) {
ev.stopPropagation();
this._config = { ...ev.detail.config };
this._error = ev.detail.error;
this._cardGUIModeAvailable = ev.detail.guiModeAvailable;
}
private _cardGUIModeChanged(ev: CustomEvent) {
ev.stopPropagation();
this._cardGUIMode = ev.detail.guiMode;
this._cardGUIModeAvailable = ev.detail.guiModeAvailable;
}
private _renderCardEditorActions() {
if (!this._config.type) {
return nothing;
}
const cardMode: string = this.hass!.localize(
(!this._cardEditorEl || this._cardGUIMode)
? 'ui.panel.lovelace.editor.edit_card.show_code_editor'
: 'ui.panel.lovelace.editor.edit_card.show_visual_editor'
);
return html`
<div slot="secondaryAction">
<ha-button
appearance="plain"
size="small"
@click=${this._toggleCardMode.bind(this)}
.disabled=${!this._cardGUIModeAvailable}
>
${cardMode}
</ha-button>
<ha-button
appearance="plain"
size="small"
@click=${this._deleteCard.bind(this)}
>
Change card
</ha-button>
</div>
`;
}
private _renderCardEditor() {
const cardBlurClass = this._error ? 'blur' : '';
const cardRenderBlurSpinner = this._error
? html` <ha-spinner aria-label="Can't update card"></ha-spinner> `
: '';
return html`
${this._config.type
? html`
<div class="content">
<div class="element-editor">
<hui-card-element-editor
.hass=${this.hass}
.lovelace=${this.lovelace}
.value=${this._config}
@config-changed=${this._cardConfigChanged.bind(this)}
@GUImode-changed=${this._cardGUIModeChanged.bind(this)}
></hui-card-element-editor>
</div>
<div class="element-preview">
<hui-card
.hass=${this.hass}
.config=${this._config}
preview
class=${cardBlurClass}
></hui-card>
${cardRenderBlurSpinner}
</div>
</div>
`
: html`
<hui-card-picker
.hass=${this.hass}
.lovelace=${this.lovelace}
@config-changed=${this._cardConfigChanged.bind(this)}
></hui-card-picker>
`}
`;
}
public static readonly styles = [
haStyleDialog,
haStyleDialogFixedTop,
css`
:host {
--code-mirror-max-height: calc(100vh - 176px);
}
ha-dialog {
--dialog-z-index: 6;
--dialog-content-padding: var(--ha-space-2);
}
.content {
width: 100%;
max-width: 100%;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
/* overrule the ha-style-dialog max-height on small screens */
.content {
width: 100%;
max-width: 100%;
}
}
@media all and (min-width: 451px) and (min-height: 501px) {
:host([large]) .content {
max-width: none;
}
}
.center {
margin-left: auto;
margin-right: auto;
}
.content {
display: flex;
flex-direction: column;
}
.content hui-card {
display: block;
padding: 4px;
margin: 0 auto;
max-width: 390px;
}
.content hui-section {
display: block;
padding: 4px;
margin: 0 auto;
max-width: var(--ha-view-sections-column-max-width, 500px);
}
.content .element-editor {
margin: 0 10px;
}
@media (min-width: 1000px) {
.content {
flex-direction: row;
}
.content > * {
flex-basis: 0;
flex-grow: 1;
flex-shrink: 1;
min-width: 0;
}
.content hui-card {
padding: 8px 10px;
margin: auto 0px;
max-width: 500px;
}
.content hui-section {
padding: 8px 10px;
margin: auto 0px;
max-width: var(--ha-view-sections-column-max-width, 500px);
}
}
.hidden {
display: none;
}
.element-editor {
margin-bottom: 8px;
}
.blur {
filter: blur(2px) grayscale(100%);
}
.element-preview {
position: relative;
height: max-content;
background: var(--primary-background-color);
padding: 4px;
border-radius: var(--ha-border-radius-sm);
position: sticky;
top: 0;
}
.element-preview ha-spinner {
top: calc(50% - 24px);
left: calc(50% - 24px);
position: absolute;
z-index: 10;
}
hui-card {
padding-top: 8px;
margin-bottom: 4px;
display: block;
width: 100%;
box-sizing: border-box;
}
[slot="primaryAction"] {
gap: var(--ha-space-2);
display: flex;
}
[slot="secondaryAction"] {
gap: var(--ha-space-2);
display: flex;
margin-left: 0px;
margin-right: auto;
margin-inline-end: auto;
margin-inline-start: initial;
}
[slot="navigationIcon"] {
--ha-icon-display: block;
}
`];
}
declare global {
interface HTMLElementTagNameMap {
'expander-card-title-card-edit-form': TitleCardEd
gitextract_6hobl9xp/ ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ ├── copilot-instructions.md │ ├── dependabot.yml │ ├── labeler.yml │ ├── release.yml │ └── workflows/ │ ├── autoClose.yml │ ├── build.yml │ ├── label.yml │ ├── release-workflow.yml │ └── validate.yml ├── .gitignore ├── .npmrc ├── .vscode/ │ ├── extensions.json │ └── tasks.json ├── Makefile ├── README.md ├── docs/ │ ├── mkdocs.yml │ ├── scenarios/ │ │ └── .gitkeep │ └── source/ │ ├── chapter/ │ │ ├── configuration/ │ │ │ ├── configuration-overview.md │ │ │ ├── examples.md │ │ │ ├── gui-configuration.md │ │ │ └── index.md │ │ ├── contribution/ │ │ │ └── contribution.md │ │ ├── faq/ │ │ │ └── faq.md │ │ ├── style/ │ │ │ ├── card-mod.md │ │ │ ├── hover.md │ │ │ ├── index.md │ │ │ ├── style.md │ │ │ └── styling-examples.md │ │ └── templating/ │ │ ├── action.md │ │ ├── index.md │ │ └── template.md │ ├── index.md │ └── quick-start.md ├── eslint.config.mjs ├── hacs.json ├── license.txt ├── package.json ├── pyproject.toml ├── rollup.config.mjs ├── src/ │ ├── Card.svelte │ ├── ExpanderCard.svelte │ ├── ExpanderCardEditor.ts │ ├── configtype.ts │ ├── editortype.ts │ ├── helpers/ │ │ ├── compute-card-size.ts │ │ ├── forward-haptic.ts │ │ ├── ha-dialog-styles.ts │ │ ├── promise-timeout.ts │ │ ├── raw-config.ts │ │ ├── style-converter.ts │ │ └── templates.ts │ ├── index.ts │ ├── title-card/ │ │ ├── showTitleCardEditForm.ts │ │ └── titleCardEditForm.ts │ └── types.ts ├── svelte.config.js ├── tests/ │ ├── conftest.py │ ├── doc-image-audit-exclusions.txt │ ├── ha-config/ │ │ ├── configuration.yaml │ │ ├── customize.yaml │ │ └── www/ │ │ └── .gitkeep │ ├── plugins.yaml │ ├── test_doc_audit.py │ └── visual/ │ ├── conftest.py │ ├── scenarios/ │ │ └── expander/ │ │ ├── expander_01_collapsed.yaml │ │ ├── expander_02_expanded.yaml │ │ ├── expander_03_expand_click.yaml │ │ ├── expander_04_clear_background.yaml │ │ ├── expander_05_title_card.yaml │ │ └── expander_06_nested.yaml │ ├── snapshots/ │ │ └── .gitkeep │ ├── test_doc_images.py │ └── test_scenarios.py ├── tsconfig.json └── vite.config.js
SYMBOL INDEX (63 symbols across 14 files)
FILE: rollup.config.mjs
constant MAIN_COMPONENT_NAME (line 11) | const MAIN_COMPONENT_NAME = 'ExpanderCard';
constant TAG_NAME (line 12) | const TAG_NAME = 'expander-card';
constant CONTAINER_TAG_NAME (line 13) | const CONTAINER_TAG_NAME ='expander-child-card';
constant FILE_NAME (line 14) | const FILE_NAME = `${TAG_NAME}.js`;
FILE: src/ExpanderCardEditor.ts
function fetchUsers (line 22) | async function fetchUsers(): Promise<void> {
method constructor (line 44) | public constructor() {
method setConfig (line 51) | public setConfig(config: any): void {
method _schema (line 56) | public get _schema(): any {
method _schema (line 77) | public set _schema(_) {
method connectedCallback (line 81) | public connectedCallback(): void {
method disconnectedCallback (line 87) | public disconnectedCallback(): void {
FILE: src/configtype.ts
type ExpanderCardVariables (line 14) | interface ExpanderCardVariables {
type ExpanderCardTemplates (line 19) | interface ExpanderCardTemplates {
type ExpanderConfig (line 23) | interface ExpanderConfig {
type ExpanderCardRawConfig (line 60) | interface ExpanderCardRawConfig {
FILE: src/editortype.ts
type StyleSchemaTypes (line 37) | enum StyleSchemaTypes {
FILE: src/helpers/forward-haptic.ts
type HapticType (line 4) | type HapticType = 'success' | 'warning' | 'failure' | 'light' | 'medium'...
type HASSDomEvents (line 8) | interface HASSDomEvents {
FILE: src/helpers/promise-timeout.ts
class TimeoutError (line 2) | class TimeoutError extends Error {
method constructor (line 6) | constructor(timeout: number, ...params: undefined[]) {
FILE: src/helpers/templates.ts
function getJSTemplateRenderer (line 4) | function getJSTemplateRenderer(variables: Record<string, unknown> = {}, ...
function isJSTemplate (line 16) | function isJSTemplate(template: unknown): boolean {
function renderJSTemplate (line 21) | function renderJSTemplate(
function trackJSTemplate (line 32) | function trackJSTemplate(
function setJSTemplateRef (line 44) | function setJSTemplateRef(
function eventHandler (line 53) | function eventHandler(templatesRenderer: Promise<HomeAssistantJavaScript...
function trackJSTemplateEvent (line 65) | function trackJSTemplateEvent(
FILE: src/index.ts
type Window (line 17) | interface Window {
FILE: src/title-card/showTitleCardEditForm.ts
type TitleCardConfig (line 1) | type TitleCardConfig = {
type TitleCardEditFormParams (line 5) | interface TitleCardEditFormParams {
FILE: src/title-card/titleCardEditForm.ts
constant CUSTOM_TYPE_PREFIX (line 9) | const CUSTOM_TYPE_PREFIX = 'custom:';
class TitleCardEditForm (line 21) | class TitleCardEditForm extends LitElement {
method showDialog (line 34) | public async showDialog(params: TitleCardEditFormParams): Promise<void> {
method closeDialog (line 41) | public closeDialog() {
method _submit (line 48) | private _submit(): void {
method _cancel (line 53) | private _cancel(): void {
method _enlarge (line 58) | private _enlarge() {
method _ignoreKeydown (line 62) | private _ignoreKeydown(ev: KeyboardEvent): void {
method render (line 66) | protected render() {
method _toggleCardMode (line 140) | private _toggleCardMode() {
method _deleteCard (line 144) | private _deleteCard() {
method _cardConfigChanged (line 148) | private _cardConfigChanged(ev: CustomEvent) {
method _cardGUIModeChanged (line 155) | private _cardGUIModeChanged(ev: CustomEvent) {
method _renderCardEditorActions (line 161) | private _renderCardEditorActions() {
method _renderCardEditor (line 191) | private _renderCardEditor() {
type HTMLElementTagNameMap (line 360) | interface HTMLElementTagNameMap {
FILE: src/types.ts
type AnimationState (line 1) | type AnimationState = 'opening' | 'closing' | 'idle';
type HomeAssistantUser (line 3) | interface HomeAssistantUser {
type HomeAssistant (line 10) | interface HomeAssistant {
type LovelaceCardConfig (line 16) | interface LovelaceCardConfig {
type LovelaceCard (line 24) | interface LovelaceCard extends HTMLElement {
type HuiCard (line 32) | interface HuiCard extends LovelaceCard {
type HaRipple (line 37) | interface HaRipple extends HTMLElement {
type HuiRoot (line 43) | interface HuiRoot extends HTMLElement {
type ExpanderCardLlCustomEventDetail (line 51) | interface ExpanderCardLlCustomEventDetail {
type ExpanderCardEventDetail (line 56) | interface ExpanderCardEventDetail {
type HuiElementEditor (line 63) | interface HuiElementEditor extends HTMLElement {
FILE: tests/test_doc_audit.py
function _collect_referenced_images (line 61) | def _collect_referenced_images() -> set[str]:
function _collect_scenario_outputs (line 89) | def _collect_scenario_outputs() -> set[str]: # NOSONAR
function _load_exclusions (line 120) | def _load_exclusions() -> set[str]:
function test_all_doc_images_are_tracked (line 137) | def test_all_doc_images_are_tracked() -> None:
FILE: tests/visual/test_doc_images.py
function test_doc_image (line 105) | def test_doc_image(
FILE: tests/visual/test_scenarios.py
function test_scenario (line 53) | def test_scenario(
Condensed preview — 78 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (231K chars).
[
{
"path": ".github/FUNDING.yml",
"chars": 87,
"preview": "github: [MelleD]\nbuy_me_a_coffee: melled\ncustom: [\"https://www.paypal.me/MelleDennis\"]\n"
},
{
"path": ".github/ISSUE_TEMPLATE/bug_report.md",
"chars": 1757,
"preview": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: bug\nassignees: ''\n---\n\nREAD THIS FIRST:"
},
{
"path": ".github/ISSUE_TEMPLATE/feature_request.md",
"chars": 906,
"preview": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: feature request\nassignees: ''\n---\n\n*"
},
{
"path": ".github/copilot-instructions.md",
"chars": 2269,
"preview": "---\napplyTo: '**/*.svelte,**/*.ts,**/*.js'\n---\n\n# Svelte & Lovelace Expander Card Instructions for Copilot Reviewer\n\n## "
},
{
"path": ".github/dependabot.yml",
"chars": 631,
"preview": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where "
},
{
"path": ".github/labeler.yml",
"chars": 191,
"preview": "# Add/remove labels to PR\nbreaking-change:\n - '(breaking-change|major-update)'\nbugfix:\n - '(fix)'\ndocumentation:\n "
},
{
"path": ".github/release.yml",
"chars": 440,
"preview": "changelog:\n categories:\n - title: ⚠️ Breaking Changes\n labels:\n - major-update\n - breaking-change"
},
{
"path": ".github/workflows/autoClose.yml",
"chars": 832,
"preview": "name: Close inactive issues\non:\n schedule:\n - cron: \"30 1 * * *\"\n workflow_dispatch:\n\njobs:\n close-issues:\n run"
},
{
"path": ".github/workflows/build.yml",
"chars": 1115,
"preview": "name: 'Build'\n\non:\n pull_request:\n branches: \n - main\n push: \n branches:\n - main\n\njobs:\n build:\n p"
},
{
"path": ".github/workflows/label.yml",
"chars": 788,
"preview": "name: 'Label PR'\n\non:\n pull_request_target:\n types: [opened, edited, synchronize]\n branches:\n - main\n\njobs:\n"
},
{
"path": ".github/workflows/release-workflow.yml",
"chars": 2930,
"preview": "name: \"Create Tagged Release\"\n\non:\n workflow_dispatch:\n inputs:\n release_version:\n description: 'Version"
},
{
"path": ".github/workflows/validate.yml",
"chars": 359,
"preview": "name: Validate\n\non:\n push:\n branches:\n - main\n workflow_dispatch:\n\njobs:\n validate-hacs:\n runs-on: \"ubuntu"
},
{
"path": ".gitignore",
"chars": 315,
"preview": ".DS_Store\nnode_modules\n/build\n/.svelte-kit\n/package\n.env\n.env.*\n!.env.example\nvite.config.js.timestamp-*\nvite.config.ts."
},
{
"path": ".npmrc",
"chars": 18,
"preview": "engine-strict=true"
},
{
"path": ".vscode/extensions.json",
"chars": 75,
"preview": "{\n \"recommendations\": [\n \"davidanson.vscode-markdownlint\"\n ]\n}"
},
{
"path": ".vscode/tasks.json",
"chars": 7110,
"preview": "{\n \"version\": \"2.0.0\",\n \"tasks\": [\n // -------------------------------------------------------------------------\n "
},
{
"path": "Makefile",
"chars": 1826,
"preview": "# Expander Card development helpers\n#\n# All targets assume you are running from the repository root with an activated\n# "
},
{
"path": "README.md",
"chars": 2817,
"preview": "# Expander Card for HomeAssistant\n\n[![release][release-badge]][release-url]\n![downloads][downloads-badge]\n![build][build"
},
{
"path": "docs/mkdocs.yml",
"chars": 1954,
"preview": "site_name: Expander Card documentation\ndocs_dir: source\ntheme:\n name: material\n variant: classic\n features:\n - nav"
},
{
"path": "docs/scenarios/.gitkeep",
"chars": 341,
"preview": "# Documentation-asset-only scenarios.\n# Place YAML files here for scenarios that exist solely to generate doc images\n# o"
},
{
"path": "docs/source/chapter/configuration/configuration-overview.md",
"chars": 4510,
"preview": "# Configuration Overview\n\nAll configurations are available for editing in Graphical config editor. Config items supporti"
},
{
"path": "docs/source/chapter/configuration/examples.md",
"chars": 8751,
"preview": "# Examples\n\n## Title card\n\nThis is the title card example from the video on the main page, a room card. \n\n<img width=\"10"
},
{
"path": "docs/source/chapter/configuration/gui-configuration.md",
"chars": 1633,
"preview": "# Graphical config editor\n\nExpander card aligns its concepts as much as possible to Home Assistant Frontend. As such, al"
},
{
"path": "docs/source/chapter/configuration/index.md",
"chars": 2561,
"preview": "# Configuration\n\nThis section provides you with a comprehensive overview of the diverse configuration and customization "
},
{
"path": "docs/source/chapter/contribution/contribution.md",
"chars": 7118,
"preview": "# 🤝 Contribution\n\nContributions of any kind are warmly welcomed! Whether you're reporting bugs, requesting features, imp"
},
{
"path": "docs/source/chapter/faq/faq.md",
"chars": 896,
"preview": "# FAQ\n\n## Issue after upgrade to HA 2025.6\n\nThere was an issue after upgrading to HA 2025.6 (this may not be valid anymo"
},
{
"path": "docs/source/chapter/style/card-mod.md",
"chars": 284,
"preview": "## Card Mod\n\nBefore the `style` attribute, [card mod](https://github.com/thomasloven/lovelace-card-mod) was used to styl"
},
{
"path": "docs/source/chapter/style/hover.md",
"chars": 1516,
"preview": "# Hover/press ripple\n\nThe Expander Card uses the inbuilt Home Assistant ripple element `ha-ripple` for hover/press rippl"
},
{
"path": "docs/source/chapter/style/index.md",
"chars": 2838,
"preview": "# Styling\n\n From basic color changes to advanced CSS animations and transitions, the Expander Card provides comprehensiv"
},
{
"path": "docs/source/chapter/style/style.md",
"chars": 3546,
"preview": "# Style\n\nYou can do advanced styling using the `style` configuration parameter. The `style` parameter supports two forma"
},
{
"path": "docs/source/chapter/style/styling-examples.md",
"chars": 9214,
"preview": "# Styling examples\n\nThis page provides practical examples of styling the Expander Card using the `style` configuration p"
},
{
"path": "docs/source/chapter/templating/action.md",
"chars": 888,
"preview": "# Action Configuration\n\nYou can set the state of expander card(s) using the `fire-dom-event` action on any card that sup"
},
{
"path": "docs/source/chapter/templating/index.md",
"chars": 2910,
"preview": "# Templating\n\nWelcome to the templating section of the Expander Card! This powerful feature enables you to create dynami"
},
{
"path": "docs/source/chapter/templating/template.md",
"chars": 7406,
"preview": "# Javascript Template\n\nExpander card supports javascript templates for the config items listed below. This list may be a"
},
{
"path": "docs/source/index.md",
"chars": 1073,
"preview": "# About Expander Card\n\nWelcome to the documentation of the Expander Card!\n\nThe Expander Card is a highly configurable Lo"
},
{
"path": "docs/source/quick-start.md",
"chars": 1304,
"preview": "# Quick Start\n\n## Installation\n\n### HACS\n\nExpander-Card is available in [HACS](https://www.hacs.xyz/) (Home Assistant Co"
},
{
"path": "eslint.config.mjs",
"chars": 6892,
"preview": "import { FlatCompat } from '@eslint/eslintrc';\nimport path from 'path';\nimport { fileURLToPath } from 'url';\nimport styl"
},
{
"path": "hacs.json",
"chars": 157,
"preview": "{\n \"name\": \"expander-card\",\n \"content_in_root\": false,\n \"render_readme\": true,\n \"filename\": \"expander-card.j"
},
{
"path": "license.txt",
"chars": 11356,
"preview": " Apache License\n Version 2.0, January 2004\n "
},
{
"path": "package.json",
"chars": 1613,
"preview": "{\n \"name\": \"lovelace-expander-card\",\n \"version\": \"0.0.1\",\n \"type\": \"module\",\n \"watch\": {\n \"buildAndSt"
},
{
"path": "pyproject.toml",
"chars": 390,
"preview": "[build-system]\nrequires = [\"setuptools>=61.0\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[project]\nname = \"lovelace-expan"
},
{
"path": "rollup.config.mjs",
"chars": 1765,
"preview": "import resolve from '@rollup/plugin-node-resolve';\nimport replace from '@rollup/plugin-replace';\nimport svelte from 'rol"
},
{
"path": "src/Card.svelte",
"chars": 7419,
"preview": "<!--\n/*\nCopyright 2021-2022 Peter Repukat - FlatspotSoftware\nLicensed under the Apache License, Version 2.0 (the \"Licens"
},
{
"path": "src/ExpanderCard.svelte",
"chars": 29219,
"preview": "<script lang=\"ts\" module>\n export const defaults = {\n 'gap': '0.0em',\n 'expanded-gap': '0.6"
},
{
"path": "src/ExpanderCardEditor.ts",
"chars": 7109,
"preview": "/* eslint-disable no-underscore-dangle */\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\nimport { ExpanderCard"
},
{
"path": "src/configtype.ts",
"chars": 2167,
"preview": "/*\nCopyright 2021-2022 Peter Repukat - FlatspotSoftware\nLicensed under the Apache License, Version 2.0 (the \"License\");\n"
},
{
"path": "src/editortype.ts",
"chars": 14800,
"preview": "/* eslint-disable quote-props */\nimport { ExpanderConfig } from './configtype';\n\nexport const ExpanderCardEditorNulls: E"
},
{
"path": "src/helpers/compute-card-size.ts",
"chars": 1013,
"preview": "import { HuiCard } from '../types';\nimport { TimeoutError } from './promise-timeout';\n\n// eslint-disable-next-line @type"
},
{
"path": "src/helpers/forward-haptic.ts",
"chars": 704,
"preview": "// Allowed types are from iOS HIG.\n// https://developer.apple.com/design/human-interface-guidelines/ios/user-interaction"
},
{
"path": "src/helpers/ha-dialog-styles.ts",
"chars": 2778,
"preview": "import { css } from 'lit';\n\nexport const haStyleDialog = css`\n ha-dialog,\n ha-adaptive-dialog {\n --mdc-dialog-min-w"
},
{
"path": "src/helpers/promise-timeout.ts",
"chars": 1038,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any */\nexport class TimeoutError extends Error {\n public timeout: nu"
},
{
"path": "src/helpers/raw-config.ts",
"chars": 876,
"preview": "import { HAQuerySelector } from 'home-assistant-query-selector';\nimport { ExpanderCardRawConfig } from '../configtype';\n"
},
{
"path": "src/helpers/style-converter.ts",
"chars": 1561,
"preview": "/**\n * Converts a style value (string or object) to a CSS string.\n * @param style - The style value to convert, can be a"
},
{
"path": "src/helpers/templates.ts",
"chars": 2943,
"preview": "import HomeAssistantJavaScriptTemplates, { HomeAssistant, HomeAssistantJavaScriptTemplatesRenderer } from 'home-assistan"
},
{
"path": "src/index.ts",
"chars": 1552,
"preview": "/*\nCopyright 2021-2022 Peter Repukat - FlatspotSoftware\nLicensed under the Apache License, Version 2.0 (the \"License\");\n"
},
{
"path": "src/title-card/showTitleCardEditForm.ts",
"chars": 1689,
"preview": "export type TitleCardConfig = {\n type?: string;\n} & Record<string, unknown>;\n\nexport interface TitleCardEditFormParam"
},
{
"path": "src/title-card/titleCardEditForm.ts",
"chars": 12124,
"preview": "/* eslint-disable no-underscore-dangle */\nimport { css, html, LitElement, nothing } from 'lit';\nimport { customElement, "
},
{
"path": "src/types.ts",
"chars": 1498,
"preview": "export type AnimationState = 'opening' | 'closing' | 'idle';\n\nexport interface HomeAssistantUser {\n id: string;\n n"
},
{
"path": "svelte.config.js",
"chars": 1009,
"preview": "import adapter from '@sveltejs/adapter-node';\nimport { vitePreprocess } from '@sveltejs/vite-plugin-svelte';\n\n/** @type "
},
{
"path": "tests/conftest.py",
"chars": 2015,
"preview": "\"\"\"Expander-card-specific pytest configuration.\n\nSets environment-variable defaults consumed by the ``ha_testcontainer``"
},
{
"path": "tests/doc-image-audit-exclusions.txt",
"chars": 949,
"preview": "# Doc image audit exclusions.\n#\n# Images listed here are intentionally hand-crafted and are NOT auto-generated\n# by a do"
},
{
"path": "tests/ha-config/configuration.yaml",
"chars": 484,
"preview": "homeassistant:\n country: US\n unit_system: metric\n time_zone: UTC\n customize: !include customize.yaml\n\ndefault_config"
},
{
"path": "tests/ha-config/customize.yaml",
"chars": 100,
"preview": "# Entity customization overrides.\n# Add any per-entity friendly names, icons, or hidden flags here.\n"
},
{
"path": "tests/ha-config/www/.gitkeep",
"chars": 247,
"preview": "# expander-card.js is made available here for tests by the Home Assistant test container/plugin setup\n# (see plugins.yam"
},
{
"path": "tests/plugins.yaml",
"chars": 666,
"preview": "# Lovelace plugins to load on every HA instance startup.\n#\n# Local plugin (this card):\n# local_path - path to the buil"
},
{
"path": "tests/test_doc_audit.py",
"chars": 6054,
"preview": "\"\"\"Audit test: every PNG/GIF image referenced in docs/source/ must be either\ngenerated by a ``doc_image:`` / ``doc_anima"
},
{
"path": "tests/visual/conftest.py",
"chars": 982,
"preview": "\"\"\"Expander card visual-test conftest — configure the ha_testcontainer scenario runner.\n\nAll Playwright and HA container"
},
{
"path": "tests/visual/scenarios/expander/expander_01_collapsed.yaml",
"chars": 549,
"preview": "id: expander_collapsed\ndescription: \"Expander card renders in collapsed state with title and chevron button\"\nview_path: "
},
{
"path": "tests/visual/scenarios/expander/expander_02_expanded.yaml",
"chars": 643,
"preview": "id: expander_expanded\ndescription: \"Expander card renders in expanded state showing child cards\"\nview_path: expander-exp"
},
{
"path": "tests/visual/scenarios/expander/expander_03_expand_click.yaml",
"chars": 748,
"preview": "id: expander_expand_click\ndescription: \"Clicking the expander button opens the card and reveals child cards\"\nview_path: "
},
{
"path": "tests/visual/scenarios/expander/expander_04_clear_background.yaml",
"chars": 611,
"preview": "id: expander_clear_background\ndescription: \"Expander card with clear:true removes ha-card background and border\"\nview_pa"
},
{
"path": "tests/visual/scenarios/expander/expander_05_title_card.yaml",
"chars": 666,
"preview": "id: expander_title_card\ndescription: \"Expander card with a heading title-card replacing the text title\"\nview_path: expan"
},
{
"path": "tests/visual/scenarios/expander/expander_06_nested.yaml",
"chars": 691,
"preview": "id: expander_nested\ndescription: \"Expander cards can be nested — an outer expander contains an inner expander\"\nview_path"
},
{
"path": "tests/visual/snapshots/.gitkeep",
"chars": 202,
"preview": "# Visual test baseline snapshots.\n# PNG baselines are committed here (generated by ha_testcontainer on first run\n# or wh"
},
{
"path": "tests/visual/test_doc_images.py",
"chars": 4678,
"preview": "\"\"\"Generate and verify documentation images and animations from scenario YAML files.\n\nAny scenario that declares a ``doc"
},
{
"path": "tests/visual/test_scenarios.py",
"chars": 2763,
"preview": "\"\"\"YAML-driven parametrised visual tests for the expander card.\n\nEach ``.yaml`` file under ``tests/visual/scenarios/`` d"
},
{
"path": "tsconfig.json",
"chars": 411,
"preview": "{\n\t\"compilerOptions\": {\n\t\t\"allowJs\": true,\n\t\t\"checkJs\": true,\n\t\t\"types\": [\"svelte\"],\n\t\t\"esModuleInterop\": true,\n\t\t\"force"
},
{
"path": "vite.config.js",
"chars": 1022,
"preview": "/* eslint-disable no-console */\nimport { svelte, vitePreprocess } from '@sveltejs/vite-plugin-svelte';\nimport { defineCo"
}
]
About this extraction
This page contains the full source code of the MelleD/lovelace-expander-card GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 78 files (214.2 KB), approximately 50.3k tokens, and a symbol index with 63 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.