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
{#if loading} Loading... {/if}
================================================ FILE: src/ExpanderCard.svelte ================================================ 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 }; }; } }}/> {#if config['title-card']}
{#if showButtonUsers} {/if} {#if config['title-card-clickable'] && !config['title-card-button-overlay'] } {/if}
{:else} {#if showButtonUsers} {/if} {/if} {#if config.cards}
{#each config.cards as card (card)} {/each}
{/if} {#if userStyleTemplateOrConfig} {@html userStyleTemplateOrConfig} {/if}
================================================ 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((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 { 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 => { // 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)[]>; variables?: Record; templates?: Record; } 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) => { // 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 => { if (typeof card.getCardSize === 'function') { try { return promiseTimeout(500, card.getCardSize()).catch( () => 1 ) as Promise; } 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) => { // 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