Skip to content
Open
87 changes: 86 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ You can override the default behavior of each option by changing their action. T

You can also choose to not give any of the dropdown options `Option` values, so that none are ever marked as the selected option. This makes it so that the default dropdown icon and label are always displayed, and the dropdown feature becomes more of a menu for firing different actions rather than one for selecting an option.

Instead of writing out every option by hand, you can also generate them from an entity attribute that contains a list (such as a light's `effect_list`) or from a template. This is useful for entities with long, dynamic attribute lists. See [Dynamic Options](#dynamic-options) below and [Example 9](#example-9) for a worked example.

## Inputs

<img src="https://raw.githubusercontent.com/Nerwyn/custom-card-features/main/assets/inputs_tile.png" width="600"/>
Expand All @@ -73,6 +75,8 @@ Like dropdowns, this feature works best with Home Assistant `select/input_select

Since each selector option is a custom feature button, you can override its default behavior by changing its tap action. The `Option` field will be the value to compare against the feature's value, whether that is its entity's state or one of its attributes. If they match and are not undefined, then the the option will be highlighted. The option highlight color defaults to the parent card color (usually the tile card color), but can be changed by setting the CSS attribute `--color` to a different value, either for the entire feature or an individual option.

Like dropdowns, selectors can also generate their options from an entity attribute list or a template. See [Dynamic Options](#dynamic-options) below.

## Sliders

<img src="https://raw.githubusercontent.com/Nerwyn/custom-card-features/main/assets/sliders_tile.png" width="600"/>
Expand Down Expand Up @@ -166,6 +170,40 @@ Like sliders and spinboxes, selectors have a one second delay before updating th

Dropdowns can also be assigned their own default icon and label. When no option is selected, the default icon and label are used.

#### Dynamic Options

Instead of writing out every option by hand, a dropdown or selector can generate its options from a list — one option per item — using the **Options source** setting in the configuration UI:

- **Entity attribute** reads the list straight from an attribute, for example a light's `effect_list`:

```yaml
- type: dropdown
entity_id: light.my_light
options_attribute: effect_list
```

`select` and `input_select` entities default to their `options` attribute, so the value can be left blank. Use `options_entity` to read the list from a different entity than the one being controlled.

- **Template** points `options` at a template that renders to a list (comma or newline separated, or a JSON/YAML array via `| dump` for values containing commas or `{ value, label, icon }` objects):

```yaml
options: "{{ state_attr('light.my_light', 'effect_list') }}"
```

Each item's value is exposed to its templates as `option` (and `config.option`). An optional **Option template** sets the label, icon, and action shared by every generated option:

```yaml
option_template:
label: '{{ option }}'
tap_action:
action: perform-action
perform_action: light.turn_on
target: { entity_id: light.my_light }
data: { effect: '{{ option }}' }
```

For recognized list attributes (`effect_list`, `source_list`, `sound_mode_list`, `activity_list`, `hvac_modes`, `fan_modes`, `swing_modes`, `preset_modes`, `available_modes`, `operation_list`, `fan_speed_list`, and a select's `options`) the matching action and tracked attribute are filled in automatically, so generating options works with no option template at all — and the configuration UI pre-fills the option template so you can tweak it. Generated options re-render when their source changes, and the resolved list is cached so even very long lists stay cheap.

### Toggle General Options

<img src="https://raw.githubusercontent.com/Nerwyn/custom-card-features/main/assets/toggle_general_options.png" width="600"/>
Expand All @@ -188,7 +226,7 @@ Buttons, dropdowns, selectors, sliders, and toggles have design variants that ca

Almost all fields support nunjucks templating. Nunjucks is a templating engine for JavaScript, which is heavily based on the jinja2 templating engine for Python which Home Assistant uses. While the syntax of nunjucks and jinja2 is almost identical, you may find the [nunjucks documentation](https://mozilla.github.io/nunjucks/templating.html) useful. Most extensions supported by Home Assistant templates are supported by this templating system, but not all and the syntax may vary. Please see the [ha-nunjucks](https://github.com/Nerwyn/ha-nunjucks) repository for a list of available extensions. If you want additional extensions to be added or have templating questions or bugs, please make an issue or discussion on that repository, not this one.

You can include the current value of a feature and its units by using the variables `value` and `unit` in a label template. You can also include `hold_secs` in a template if performing a momentary repeat or end action. For toggles you can use the boolean variable `checked` to check whether the toggle is on or off. Each custom feature can also reference its entry using `config` within templates. `config.entity` and `config.attribute` will return the features entity ID and attribute with their templates rendered (if they have them), and other templated config fields can be rendered within templates by wrapping them in the function `render` within a template. Information about the parent card such as its entity ID, state, and attributes can be accessed using `stateObj`. The structure of `stateObj` can be found [here](https://github.com/home-assistant/home-assistant-js-websocket/blob/1d51737f6092b95e2bc98e85aca752771b97b760/lib/types.ts#L72-L96) as a `HassEntity` type and is listed below.
You can include the current value of a feature and its units by using the variables `value` and `unit` in a label template. You can also include `hold_secs` in a template if performing a momentary repeat or end action. For toggles you can use the boolean variable `checked` to check whether the toggle is on or off. For dropdown and selector options you can use the variable `option` (also available as `config.option`) to reference that option's own value, which is especially useful with [dynamic options](#dynamic-options). Each custom feature can also reference its entry using `config` within templates. `config.entity` and `config.attribute` will return the features entity ID and attribute with their templates rendered (if they have them), and other templated config fields can be rendered within templates by wrapping them in the function `render` within a template. Information about the parent card such as its entity ID, state, and attributes can be accessed using `stateObj`. The structure of `stateObj` matches the [Home Assistant websocket `HassEntity` type definition](https://github.com/home-assistant/home-assistant-js-websocket/blob/1d51737f6092b95e2bc98e85aca752771b97b760/lib/types.ts#L72-L96) and is listed below.

<details>

Expand Down Expand Up @@ -2457,3 +2495,50 @@ transparent: true
```

</details>

## Example 9

Generating dropdown options dynamically from a light's `effect_list` attribute. The dropdown lists every effect the light supports, marks the active one as selected, and tapping an option sets that effect — all without hand writing a single option. If the light's firmware adds or removes effects, the dropdown updates automatically.

<details>

<summary>Config</summary>

```yaml
type: tile
entity: light.wled
features:
- type: custom:service-call
entries:
- type: dropdown
entity_id: light.wled
value_attribute: effect
icon: mdi:string-lights
label: '{{ state_attr(config.entity, "effect") }}'
options_attribute: effect_list
option_template:
label: '{{ option }}'
tap_action:
action: perform-action
perform_action: light.turn_on
target:
entity_id: light.wled
data:
effect: '{{ option }}'
```

</details>

The same pattern works for any entity with a list attribute, for example a media player's `source_list` (paired with `media_player.select_source`) or a climate entity's `preset_modes`. For `select` and `input_select` entities you can leave the `options_attribute` value blank and omit `option_template`, since the `options` attribute is used and a `select_option` action is generated for you:

```yaml
- type: dropdown
entity_id: input_select.scene
options_attribute:
```
Comment on lines +2532 to +2538

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Clarify the blank options_attribute syntax in the example.

Line 2537 shows options_attribute: with no value after the colon. While this is valid YAML (parses as null), it may confuse readers. Consider either:

  1. Adding a comment explaining this means "use the default options attribute"
  2. Showing the example without the options_attribute key at all (since the docs state select/input_select default to options)

The second option would be clearer and more consistent with the "you can leave the value blank and omit option_template" phrasing in the text above.

📝 Clearer example
 ```yaml
 - type: dropdown
   entity_id: input_select.scene
-  options_attribute:

</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @README.md around lines 2532 - 2538, The example shows an empty
options_attribute: which is valid YAML but confusing; update the snippet for
clarity by removing the options_attribute line entirely (since
select/input_select default to the options attribute) or replace it with an
inline comment like # uses default "options" attribute so readers understand
this signals the default behavior for select/input_select (reference
options_attribute, option_template, options, and the example entity
input_select.scene).


</details>

<!-- fingerprinting:phantom:triton:puma -->

<!-- cr-comment:v1:ca6d818d6ce0ce6782566cbb -->

<!-- This is an auto-generated comment by CodeRabbit -->


If you need a computed list instead of a single attribute, point `options` at a template that renders to a list, for example to merge two lists or filter them:

```yaml
options: "{{ (state_attr(config.entity, 'effect_list') or []) | reject('eq', 'Solid') | list }}"
```
14 changes: 7 additions & 7 deletions dist/custom-card-features.min.js

Large diffs are not rendered by default.

250 changes: 246 additions & 4 deletions src/classes/base-custom-feature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,24 @@ import { CSSResult, LitElement, PropertyValues, css, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';

import { load } from 'js-yaml';
import { UPDATE_AFTER_ACTION_DELAY } from '../models/constants';
import { ActionType, IAction, IActions, IEntry } from '../models/interfaces';
import { AUTOFILL, UPDATE_AFTER_ACTION_DELAY } from '../models/constants';
import {
ActionType,
IAction,
IActions,
IEntry,
IOption,
} from '../models/interfaces';
import { MdRipple } from '../models/interfaces/MdRipple';
import { deepGet, deepSet, getDeepKeys } from '../utils';
import {
buildTemplatedOption,
deepGet,
deepSet,
defaultOptionAction,
getDeepKeys,
parseOptionsList,
resolveOptionsAttribute,
} from '../utils';
import { handleConfirmation } from '../utils/cardHelpers';

@customElement('base-custom-feature')
Expand Down Expand Up @@ -364,7 +378,8 @@ export class BaseCustomFeature extends LitElement {

this.valueAttribute = (
this.renderTemplate(
(this.config.value_attribute as string) ?? 'state',
(this.config.value_attribute as string) ??
this.defaultValueAttribute(),
Comment on lines +381 to +382

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-read the selected value when the source default changes

When a dynamic dropdown/selector omits value_attribute, this new default can change with the selected options source, but setValue() is only run from super.shouldUpdate() on hass/state/value changes. If the editor changes options_attribute from one recognized list to another (for example effect_list to source_list) without an HA state update, setOptions() rebuilds the choices while this.valueAttribute and this.value still come from the old source, so the current option is not highlighted until a later state update or remount.

Useful? React with 👍 / 👎.

) as string
).toLowerCase();
if (!this.hass.states[this.entityId]) {
Expand Down Expand Up @@ -491,6 +506,230 @@ export class BaseCustomFeature extends LitElement {
}
}

/**
* Options resolved from `config.options`, used by dropdowns and selectors.
* When `config.options` is a list it is used directly; when it is a template
* string it is rendered, parsed into a list, and one option is generated per
* item using `config.option_template`.
*/
options: IOption[] = [];
private optionsSignature?: string;

/**
* Resolve `config.options` into the concrete `options` array.
*
* Returns whether the resolved options changed and an update is required.
* Dynamic sources (attribute or template) are cached on a signature of the
* source value so that the (potentially large) list is only rebuilt when the
* source actually changes, keeping frequent hass updates cheap.
*/
setOptions(): boolean {
const config = this.config.options;

// Explicit list of options (backwards compatible, unchanged behavior). The
// signature snapshot detects in-place edits to option objects, which a
// reference comparison would miss.
if (Array.isArray(config)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Let explicit dynamic sources override stale arrays

If a user converts an existing manual dropdown/selector by adding options_attribute: effect_list or setting optionType: attribute but leaves the old options: []/array in place, this early array branch returns before resolveDynamicOptions() can honor the dynamic source. The editor infers attribute mode from options_attribute, yet the runtime keeps rendering the stale manual array or an empty list, so the new source appears broken until the user manually deletes options; check the explicit source fields before accepting the array.

Useful? React with 👍 / 👎.

const signature = `list:${JSON.stringify(config)}`;
if (signature == this.optionsSignature) {
return false;
}
this.optionsSignature = signature;
// A shallow copy avoids aliasing the config array.
this.options = [...(config as IOption[])];
return true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize primitive manual options before caching

When an explicit options array contains primitive entries such as options: [A, B], this stores the raw strings in this.options; the dropdown/selector render paths then clone each entry and assign fields like option.haptics/option.label, which throws for primitives in module strict mode (or leaves option.option undefined). Normalize primitive array items to option objects before caching them, matching the dynamic source path.

Useful? React with 👍 / 👎.

}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Dynamic source: an entity attribute or a template that renders to a list.
const source = this.resolveDynamicOptions();
if (!source) {
this.optionsSignature = undefined;
if (this.options.length) {
this.options = [];
return true;
}
return false;
}

if (source.signature == this.optionsSignature) {
return false;
}
this.optionsSignature = source.signature;
this.options = source.items
.filter(Boolean)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve falsy generated option values

When an attribute or JSON/YAML template source returns primitive options like 0 or false, this truthiness filter drops them before buildOption() runs. Numeric lists are explicitly accepted by isOptionListAttribute(), and selected-state comparison stringifies values later, so a valid first option such as 0 disappears from the dropdown/selector and can never be selected; filter only null/undefined/blank items instead of all falsy values.

Useful? React with 👍 / 👎.

.map((item) => this.buildOption(item, source.attribute));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return true;
}

/**
* Resolve the `options_attribute`/`options_entity` source into the entity and
* attribute to read. A blank `options_attribute:` in YAML is null (not
* undefined), so a strict check is used to treat its presence — even when
* empty — as opt-in. Returns undefined when no attribute source is configured.
*/
private resolveAttributeSource():
| { entityId: string; attribute: string }
| undefined {
// No attribute source when no attribute fields are set, or when an explicit
// `options` template is present (it wins over leftover attribute-source
// fields, so a hand-written config containing both renders the template
// instead of silently reading the attribute).
if (
(this.config.options_attribute === undefined &&
this.config.options_entity === undefined) ||
(typeof this.config.options == 'string' && this.config.options.trim())
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
) {
return undefined;
}
// Resolve from the current config (not the cached entityId, which is stale
// after a config-only entity change) and fall back to the feature entity
// when the source entity is unset or blank (`||`, so '' falls through).
const entityId = String(
this.renderTemplate(
(this.config.options_entity || this.config.entity_id || '') as string,
),
);
const attribute = resolveOptionsAttribute(
String(
this.renderTemplate((this.config.options_attribute || '') as string),
),
entityId,
);
return { entityId, attribute };
}

/**
* Default value attribute to track for the current options source, so the
* selected option is highlighted without configuring `value_attribute`. Falls
* back to 'state' when there is no recognized attribute source.
*/
private defaultValueAttribute(): string {
const attrSource = this.resolveAttributeSource();
if (attrSource?.attribute) {
// Derive from the controlled (feature) entity's domain, not the source
// entity's, so a cross-domain source (e.g. an input_select reading a
// light's effect_list) tracks the feature entity's state instead of an
// attribute it does not have.
const featureEntity = String(
this.renderTemplate((this.config.entity_id ?? '') as string),
);
const action = defaultOptionAction(
featureEntity.split('.')[0],
attrSource.attribute,
Comment thread
kristofferR marked this conversation as resolved.
);
if (action) {
return action.value_attribute;
}
}
return 'state';
}

/**
* Resolve a dynamic options source into a raw list of items plus a cache
* signature. Supports reading a list straight from an entity attribute
* (`options_attribute`/`options_entity`) and rendering an `options` template
* string. Returns undefined when no dynamic source is set.
*/
private resolveDynamicOptions():
| { items: unknown[]; signature: string; attribute?: string }
| undefined {
const config = this.config.options;

// The generated options also depend on the option template and the default
// action inputs, so they must be part of the cache signature — otherwise an
// option_template edit that leaves the source list unchanged would not
// rebuild the resolved options. The feature entity is rendered (not the raw
// template or the cached entityId) so changing the controlled entity — even
// via a template whose value changes — invalidates the cache.
const optionSignature = JSON.stringify([
this.config.option_template ?? null,
this.config.autofill_entity_id ?? null,
Comment on lines +664 to +665

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include rendered autofill state in the options cache key

When autofill_entity_id is templated on the parent feature or inside option_template, buildOption() renders it to decide whether to add default tap actions, but this signature only includes the raw template/config object. If the rendered autofill value changes while the option list itself stays the same, the generated options are not rebuilt and keep stale default actions until the source list changes or the card remounts.

Useful? React with 👍 / 👎.

String(this.renderTemplate((this.config.entity_id ?? '') as string)),
]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Attribute source: `options_attribute`/`options_entity`.
const attrSource = this.resolveAttributeSource();
if (attrSource) {
const { entityId, attribute } = attrSource;
if (!attribute) {
return undefined;
}
const value =
entityId && this.hass?.states?.[entityId]
? deepGet(this.hass.states[entityId].attributes, attribute)
: undefined;
return {
items: parseOptionsList(value),
signature: `attr:${entityId}:${attribute}:${JSON.stringify(value ?? null)}:${optionSignature}`,
attribute,
};
}

// Template source.
if (typeof config == 'string' && config.trim()) {
const rendered = String(this.renderTemplate(config));
return {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Render option templates with the current entity

When an options template uses the documented parent context, e.g. {{ state_attr(config.entity, 'effect_list') }}, changing the dropdown/selector entity_id in the editor is a config-only update and super.shouldUpdate() does not refresh this.entityId. This render therefore still evaluates config.entity as the previous entity (while the cache key is already based on the new one), so the generated options can keep showing the old entity's list until a later hass update or remount; render the source template with the freshly resolved feature entity instead of the cached context.

Useful? React with 👍 / 👎.

items: parseOptionsList(rendered),
signature: `tmpl:${rendered}:${optionSignature}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Pass the raw template result into parseOptionsList().

String(this.renderTemplate(config)) flattens any structured template result before parsing. A native array becomes a comma-joined string, and an array of objects degrades to "[object Object],...", so the structured-item support added in parseOptionsList()/buildTemplatedOption() never runs for template-backed sources.

Suggested fix
 		// Template source.
 		if (typeof config == 'string' && config.trim()) {
-			const rendered = String(this.renderTemplate(config));
+			const rendered = this.renderTemplate(config as string) as unknown;
 			return {
 				items: parseOptionsList(rendered),
-				signature: `tmpl:${rendered}:${optionSignature}`,
+				signature: `tmpl:${JSON.stringify(rendered ?? null)}:${optionSignature}`,
 			};
 		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/classes/base-custom-feature.ts` around lines 662 - 666, The code
currently stringifies the template result before parsing which loses structured
data; change the logic in base-custom-feature.ts so renderTemplate(config) is
assigned to a variable without coercing to String (e.g., const rendered =
this.renderTemplate(config)) and pass that raw rendered value into
parseOptionsList(rendered) so parseOptionsList/buildTemplatedOption receive the
original array/object structure; also update the signature construction to use a
stable serialization (e.g., JSON.stringify(rendered)) instead of the flattened
string so optionSignature remains unique and consistent.

};
}

return undefined;
}

/**
* Build a single generated option from a list item, applying the option
* template and a default action derived from the source attribute, so that
* generating options from common list attributes (effects, sources, modes,
* select options, …) works with no action configuration.
*/
private buildOption(item: unknown, sourceAttribute?: string): IOption {
const option = buildTemplatedOption(item, this.config.option_template);
Comment thread
kristofferR marked this conversation as resolved.
Comment thread
kristofferR marked this conversation as resolved.

// The feature entity the action controls. Resolved from the current config
// (not the cached entityId, which is stale after a config-only change).
const featureEntity = String(
this.renderTemplate((this.config.entity_id ?? '') as string),
);

// Inherit the parent feature's entity, like the editor autofill does for
// manual options, so option templates can use `{{ config.entity }}`.
if (featureEntity && option.entity_id == undefined) {
option.entity_id = featureEntity;
}

// Provide a sensible default action so generating options is zero
// configuration (mirrors the editor autofill behavior for manual options).
// The option template can opt out per option with `autofill_entity_id: false`.
const autofill = this.renderTemplate(
(option.autofill_entity_id ??
this.config.autofill_entity_id ??
AUTOFILL) as unknown as string,
);
Comment on lines +730 to +734

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor option-template autofill when adding default actions

For dynamic select/input_select options, the default select_option action is controlled only by the parent feature's autofill_entity_id. If a user edits the option template and turns Autofill off (option_template.autofill_entity_id: false) to prevent generated options from getting an implicit tap action, this check still evaluates the parent and adds the default service call to every generated option.

Useful? React with 👍 / 👎.

if (
autofill &&
!option.tap_action &&
!option.double_tap_action &&
!option.hold_action &&
!option.momentary_start_action &&
!option.momentary_repeat_action &&
!option.momentary_end_action
) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const domain = featureEntity.split('.')[0];
const action = defaultOptionAction(domain, sourceAttribute ?? '');
Comment thread
kristofferR marked this conversation as resolved.
if (action) {
option.tap_action = {
action: 'perform-action',
perform_action: action.perform_action,
data: { [action.data_key]: option.option },
target: { entity_id: featureEntity },
} as IAction;
}
}

return option;
}

renderTemplate(
str: string | number | boolean,
context?: object,
Expand All @@ -514,6 +753,9 @@ export class BaseCustomFeature extends LitElement {
currentY: this.currentY,
deltaX: this.deltaX,
deltaY: this.deltaY,
// Convenience alias for an option's own value (see issue #198), so that
// option templates can use `{{ option }}` as well as `{{ config.option }}`.
option: (this.config as IOption).option,
config: {
...this.config,
entity: this.entityId,
Expand Down
Loading