This document describes the current widget configuration flow system used by MHA Widget Hub.
The current system is manifest-driven: each configurable widget owns its draft creation, build logic and field rendering through its config manifest.
Config flows let MHA ask the user for the information a widget needs before placing it on the grid or before updating an existing widget configuration.
The user-facing goal is simple:
Users choose human concepts like “Light”, “Media player”, “Mode”, “Routine” or “Display name”, while MHA stores the Home Assistant ids and widget metadata internally.
Entity ids are still stored in widget config, but selection lists display friendly names and respect MHA Admin visibility filtering.
Typical create flow:
User opens widget manager
↓
User selects a widget entry
↓
MHA checks whether the widget supports configuration
↓
If configurable, a config popup opens before drop
↓
User selects entities/options/name
↓
The config manifest builds the configured widget
↓
Widget placement continues
↓
Widget is placed on the grid
Important detail:
The popup appears after selecting the widget in the manager and before dropping it onto the grid. This preserves manager-selected metadata such as variant, size, title, category and future catalog fields.
The following widgets currently expose config manifests:
| Widget kind | Config type | Config file |
|---|---|---|
button |
button |
src/widget-config/button-config.js |
scenes |
scenes |
src/widget-config/scenes-config.js |
toggle |
toggle |
src/widget-config/toggle-config.js |
slider |
slider |
src/widget-config/slider-config.js |
toggle-slider |
toggle-slider |
src/widget-config/toggle-slider-config.js |
weather |
weather |
src/widget-config/weather-config.js |
media |
media |
src/widget-config/media-config.js |
Widgets without a config manifest are placed directly.
Each configurable widget module attaches a config manifest to its WIDGET_MODULE.
Current manifest shape:
export const MY_WIDGET_CONFIG_MANIFEST = Object.freeze({
type: "my-widget",
title: "Configure my widget",
hint: "Choose the options to display.",
titleKey: "widgets.config.myWidget.title",
hintKey: "widgets.config.myWidget.hint",
getTitle,
getHint,
createDraft,
build,
renderFields,
});Not every manifest needs every optional field.
| Field | Required | Purpose |
|---|---|---|
type |
yes | Stable config type id |
title |
optional | Fallback popup title |
hint |
optional | Fallback helper text |
titleKey |
optional | i18n key for the title |
hintKey |
optional | i18n key for the hint |
getTitle |
optional | Dynamic title resolver |
getHint |
optional | Dynamic hint resolver |
createDraft |
yes | Creates editable config state |
build |
yes | Converts draft into final widget config |
renderFields |
yes for UI config | Renders the config fields for this manifest |
The popup is now generic. It asks the manifest to render fields instead of knowing every config type itself.
The manifest is attached to the widget module:
export const WIDGET_MODULE = Object.freeze({
kind: "toggle",
definition: TOGGLE_WIDGET_DEFINITION,
renderer: TOGGLE_WIDGET_CONTENT_RENDERER,
config: TOGGLE_WIDGET_CONFIG_MANIFEST,
preview: TOGGLE_WIDGET_PREVIEW,
});The widget definition still references the config type:
export const TOGGLE_WIDGET_DEFINITION = Object.freeze({
...
config: "toggle",
});Both sides matter:
widget definition config id
↓
widget module config manifest
↓
widget-config-registry
↓
widget-config-popup
The config registry lives in:
src/widget-config/widget-config-registry.js
It collects config manifests from WIDGET_MODULES, then builds:
WIDGET_CONFIG_REGISTRYas:
{
[manifest.type]: manifest
}This means a new config flow is discovered through the widget module registry. A normal new config flow should not require adding a new branch in widget-config-popup.js.
The popup does not mutate the widget directly.
It creates a session:
createWidgetConfigSession(widget, hass, {
mode = "create",
visibilityConfig,
})The session shape is:
{
mode,
widget,
configType,
draft,
...metadata
}Where:
modeis usuallycreateoredit;widgetis the original widget or manager item;configTypeis the resolved config type;draftis editable temporary state;- metadata can include contextual fields such as a scenes button index.
This avoids mutating the real widget while the user is changing fields.
Every config flow follows the same pattern:
createDraft(widget, hass, visibilityConfig)
↓
user edits draft in popup
↓
build(widget, draft, hass, visibilityConfig)
↓
configured widget object
createDraft reads the original widget and creates editable state. It usually also calls a reconcile function.
Example shape:
export function createMediaConfigDraft(widget = {}, hass, visibilityConfig) {
const draft = {
mediaEntityId: widget.mediaEntityId || widget.entityId || widget.entity_id || "",
label: String(widget.label || "").trim(),
labelCustomized: Boolean(String(widget.label || "").trim()),
};
return reconcileMediaConfigDraft(draft, hass, visibilityConfig);
}build merges the original widget with the chosen config values.
Recommended pattern:
export function buildMediaWidgetConfig(widget, draft, hass, visibilityConfig) {
const { selected } = reconcileMediaConfigDraft(draft, hass, visibilityConfig);
const mediaEntityId = draft.mediaEntityId || "";
return {
...widget,
kind: "media",
type: "media",
component: "media-widget",
entityId: mediaEntityId,
mediaEntityId,
label: String(draft.label || selected?.label || "").trim(),
};
}Always spread the original widget first unless there is a very specific reason not to.
This preserves:
- selected variant;
- selected size;
- manager category;
- title/label metadata;
- catalog entry values;
- future widget metadata.
Field rendering now belongs to the config manifest.
A manifest exposes:
renderFields(session, hass, visibilityConfig, onChange, helpers)It returns:
{
fields,
canSave,
isValid,
}| Field | Purpose |
|---|---|
fields |
DOM node containing the config fields |
canSave |
Initial primary-button enabled state |
isValid |
Optional function called after input changes |
The popup uses canSave for the initial button state and isValid() after input events.
The popup passes common helpers so config files can share consistent markup and labels:
{
createField,
configOptionLabel,
emptyLabelForConfigOption,
t,
}createField() should be preferred for standard label/control/hint layout.
Most config files use a reconcile...Draft() function.
Reconcile functions are responsible for:
- finding valid entity options;
- replacing invalid selected entities;
- picking the first valid option when needed;
- resolving selected option metadata;
- auto-filling labels when the user has not customized them;
- returning save validity data.
Typical return shape:
{
draft,
options,
selected,
}Some flows also return type-specific data:
{
draft,
action,
options,
selected,
}or:
{
draft,
deviceType,
options,
selected,
}Human-friendly entity options are centralized in:
src/widget-config/light-options.js
Despite the filename, it now contains general entity option helpers.
Core helpers:
humanizeEntityId(entityId)
getEntityDisplayName(entityState, entityId)
getEntityOptionsByDomain(hass, domain, visibilityConfig)
getLightOptions(hass, visibilityConfig)Entity labels are resolved in this order:
attributes.friendly_name
↓
humanized entity_id
Example:
light.salon_principal
becomes:
Salon Principal
Entity options are filtered through:
filterEntitiesForCurrentUser(hass, options, visibilityConfig)This means config popups respect MHA Admin entity permissions.
getEntityOptionsByDomain() filters entities by domain and availability.
General rule:
getEntityDomain(entityId) === domainand:
isEntityAvailable(entityState)Special case:
domain === "button" && entityState?.state === "unknown"Home Assistant button entities often report unknown, but they are still valid action targets.
Lights get extra filtering through:
getLightOptions(hass, visibilityConfig)This keeps only lights that support brightness.
The capability comes from:
supportsLightBrightness(attributes)This matters for:
- toggle-slider;
- brightness slider.
A basic on/off light without brightness support should not appear in brightness-specific config flows.
The popup is created by:
createWidgetConfigPopup({
session,
hass,
visibilityConfig,
onCancel,
onSave,
onChange,
})from:
src/widget-config/widget-config-popup.js
It now uses the shared panel shell:
src/panels/panel-shell.js
src/panels/panel-surface-contract.js
The config popup remains visually aligned with the Page Creator sheet and other panel surfaces through shared panel classes and surface roles.
The popup has two actions:
| Button | Behavior |
|---|---|
| Cancel | closes without saving |
| Continue | create mode: builds widget and continues to placement |
| Save | edit mode: builds widget and saves changes |
The save/continue button is disabled until the current manifest content is valid.
Files:
src/widget-config/toggle-slider-config.js
src/widgets/toggle-slider-widget.js
Config type:
"toggle-slider"Purpose:
- choose a compatible light;
- customize display name;
- build a combined light widget.
Draft fields:
{
lightEntityId,
label,
labelCustomized,
sliderMode,
}Current UI fields:
| Label | Control |
|---|---|
| Display name | text input |
| Light | select |
Current build output includes:
{
...widget,
kind: "toggle-slider",
type: "toggle-slider",
component: "toggle-slider-widget",
lightEntityId,
entityId: lightEntityId,
label,
sliderMode: "brightness",
}White-temperature mode can be added later, but the current complete flow is brightness-based.
Files:
src/widget-config/slider-config.js
src/widgets/slider-widget.js
Config type:
"slider"Current slider actions:
| Value | Label | Domain | Empty label |
|---|---|---|---|
volume |
Volume | media_player |
No media player available |
brightness |
Light brightness | light |
No light available |
Draft fields:
{
entityId,
label,
labelCustomized,
sliderAction,
}Current UI fields:
| Label | Control |
|---|---|
| Action | select |
| Device | select |
| Display name | text input |
Files:
src/widget-config/toggle-config.js
src/widgets/toggle-widget.js
Config type:
"toggle"Supported device types:
| Value | Label | Domain |
|---|---|---|
light |
Light | light |
switch |
Switch | switch, input_boolean |
MHA presents switch and input_boolean entities together as switches. MHA
Admin keeps both domains separate for entity permissions.
Draft fields:
{
deviceType,
entityId,
label,
labelCustomized,
}Files:
src/widget-config/button-config.js
src/widgets/simple-button-widget.js
Config type:
"button"Current button types:
| Value | Label |
|---|---|
light |
Light |
switch |
Switch (switch and input_boolean entities) |
button |
HA button |
action |
Custom action |
Draft fields:
{
buttonType,
entityId,
label,
labelCustomized,
actionDomain,
actionService,
actionData,
actionDataValid,
}Custom action mode validates actionData as a JSON object. Arrays and invalid JSON are rejected.
Files:
src/widget-config/scenes-config.js
src/widgets/scenes-widget.js
Config type:
"scenes"Purpose:
- configure up to four buttons;
- support Home Assistant
scene,scriptandautomationentities; - present
sceneas a Mode andscript/automationas Routine-style shortcuts; - support per-slot configuration from the widget.
Current widget definition marks:
capabilities: {
configurable: true,
slotConfigurable: true,
}and:
placementFlow: "slot-config-first"This lets the widget open configuration for a specific button slot without adding special popup branches.
Files:
src/widget-config/weather-config.js
src/widgets/weather-widget.js
Config type:
"weather"Purpose:
- choose an allowed
weatherentity.
Draft fields:
{
entityId,
}Weather config currently does not expose a custom display label.
Files:
src/widget-config/media-config.js
src/widgets/media-widget.js
Config type:
"media"Purpose:
- choose a
media_player; - customize display name.
Draft fields:
{
mediaEntityId,
label,
labelCustomized,
}Most flows use this pattern:
labelCustomized: Boolean(String(widget.label || "").trim())When the user has not customized the label:
draft.label = selected?.label || "";When the user edits the label:
labelCustomized = true;This gives a good default while still allowing user override.
Config builds should always spread the original widget:
return {
...widget,
...configSpecificFields,
};Avoid rebuilding a widget from scratch because that can lose selected manager metadata.
Popup CSS lives in:
styles/widget-manager/widget-config-popup.css
Shared panel CSS also applies through:
styles/panels/panel-surface-contract.css
styles/panels/panel-frame-alignment.css
styles/panels/page-creator-sheet.css
Theme-specific visual styling should come from tokens. The config popup should not define a separate visual system.
To add a new config flow today:
src/widget-config/my-widget-config.js
Export draft/build/render functions:
createMyWidgetConfigDraft()
reconcileMyWidgetConfigDraft()
buildMyWidgetConfig()
renderMyWidgetConfigFields()export const MY_WIDGET_CONFIG_MANIFEST = Object.freeze({
type: "my-widget",
title: "Configure my widget",
hint: "Choose the options to display.",
createDraft: createMyWidgetConfigDraft,
build: buildMyWidgetConfig,
renderFields: renderMyWidgetConfigFields,
});export const MY_WIDGET_DEFINITION = Object.freeze({
...
config: "my-widget",
});export const WIDGET_MODULE = Object.freeze({
kind: "my-widget",
definition: MY_WIDGET_DEFINITION,
renderer: MY_WIDGET_RENDERER,
config: MY_WIDGET_CONFIG_MANIFEST,
preview: MY_WIDGET_PREVIEW,
});No central widget-config-popup.js field branch should be required for a normal config flow.
renderFields is manifest-driven, but field UI is still hand-built by each config flow. Future work can extract reusable primitives for selects, labels, text inputs, segmented controls, JSON fields and entity selectors.
The file now handles general entity option helpers, not only light options.
Future rename candidate:
src/widget-config/entity-options.js
Weather config currently only selects an entity. That may be fine, but it differs from media/toggle/slider/button UX.
The current combined light config documents brightness mode. If white temperature becomes a real option, the flow should add:
sliderMode: "brightness" | "color_temp"and filter lights by matching capability.
When creating or updating a config flow:
- Keep draft state separate from widget state.
- Always reconcile draft state against current
hass.states. - Filter entities through MHA Admin visibility.
- Display human names, not raw entity ids.
- Preserve custom labels once edited.
- Preserve selected manager variant and size.
- Keep build output explicit and stable.
- Disable save when no valid entity/action exists.
- Do not call Home Assistant services from config UI.
- Keep field UI small and touch-friendly.
- Put field rendering in the manifest via
renderFields.
For every config flow, test:
- no available entities;
- one available entity;
- multiple entities sorted alphabetically;
- custom label entry;
- label auto-fill before customization;
- entity change after custom label;
- invalid saved entity no longer available;
- MHA Admin visibility filtering;
- create mode button label;
- edit mode button label;
- selected widget variant preserved;
- selected widget size preserved;
- widget renders after save;
- popup cancel does not mutate widget;
- Home Assistant reload/cache behavior.
For button custom action, also test:
- invalid JSON disables save;
- array JSON is rejected;
- empty JSON becomes
{}; - domain + service required.
For scenes, also test:
- empty slot configuration;
- configured slot activation;
- scene/script/automation entity options;
- per-slot edit mode;
- MHA Admin visibility filtering per slot.
The config-flow system is now strongly manifest-driven.
Strengths:
- config manifests are collected from widget modules;
- draft/build pattern is clean;
- field rendering is delegated to
renderFields; - title/hint can be static, i18n-driven or dynamic;
- entity lists are human-friendly;
- MHA Admin visibility is respected;
- selected manager metadata is preserved;
- popup appears before drop when configuration is required;
- the shared panel shell keeps popup visuals consistent.
Main remaining work:
- extract more reusable config field primitives;
- rename
light-options.jsto a broader entity helper name; - add or maintain tests for every config type;
- document any future edit-mode flows when placed-widget reconfiguration expands.
Target state:
Add widget config file
+
Attach manifest to widget module
No central popup branching should be needed for ordinary config flows.