main 5d569a3a31ae cached
78 files
214.2 KB
50.3k tokens
63 symbols
1 requests
Download .txt
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]
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=MelleD_lovelace-expander-card&metric=alert_status)](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.

![Expander Card Styling - Title](../assets/styling2.png)

![Expander Card Styling - Title Card](../assets/styling1.png)

![Expander Card Styling - Title Card & Overlay](../assets/styling3.png)

## 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)

[![PayPal](https://img.shields.io/static/v1.svg?label=%20&message=PayPal.Me&logo=paypal)](https://www.paypal.me/MelleDennis)

:material-coffee: [Buy Me A Coffee](https://www.buymeacoffee.com/melled)

## Demo

![Sample gif](chapter/assets/example.gif)

---

### Expand button as overlay

![Sample lights overlay](chapter/assets/lights_overlay_button.png)

---

### You can even nest expanders

![Sample nesting](chapter/assets/nested.png)

---

### Clear Background (default theme)

![Sample clear router](chapter/assets/clear_router.png)


================================================
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

[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](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
Download .txt
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
Download .txt
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.

Copied to clipboard!