Skip to content

Latest commit

 

History

History
340 lines (277 loc) · 21.4 KB

widget-api.md

File metadata and controls

340 lines (277 loc) · 21.4 KB

Widget API

The contract of a widget module. A widget is one folder, src/widgets/<type>/, that owns everything specific to that kind: markup, actions, guards, confirmations, the services it calls, availability, timed refresh, card-level overlays, editor options, validation, strings, CSS, fixtures, tests and its documentation page. The controller and core/ contain no widget names; adding a folder adds the widget everywhere (card element, picker entry, editor, docs index).

Read architecture first for where widgets sit. Overviews (full-screen views) have their own contract: overview API. To scaffold a widget run npm run new:widget, see CONTRIBUTING.

1. Folder shape

src/widgets/<type>/
  index.js       required  export default defineWidget({...})
  strings.js     required  export default {en: {...}}   every key starts with "<type>."
  fixtures.js    required  export const variants = {...}   entity states used by tests, previews and the contract suite
  test.js        required  export default async function (h) {...}   browser behaviour tests
  README.md      required  front matter and option table; the source of docs/widgets/<type>.md
  style.css      required  phase-tagged sections (section 8); may hold only a comment
  <part>.js      optional  helpers (camera/snapshot.js, timer/alarm.js, clock/dial.js, ...)

src/widgets/index.js is generated by npm run gen; never edit it. src/widgets/_shared/ holds code several widgets use (the icon-tile markup, the month-calendar tap).

Rules for index.js and its parts:

  • Importable in plain Node: no window, document or HTMLElement at import time. Touch the DOM only in hooks.
  • Import only from ../../core/*, ../_shared/* and your own folder. Never from controller/, elements/, editor/ or another widget (ESLint no-restricted-imports and npm run check:widgets enforce this).
  • Never call hass.callService, window.confirm, localStorage or fetch. Use ctx.call and the confirm seam.
  • The context is the whole API. There is no ctx.card.

2. Definition

defineWidget(definition) fills defaults, checks the shape, deep-freezes the result and remembers that it did. The registry refuses an object that did not come through it. In a browser a bad definition is skipped with a console error; in Node (tests, CI, linters) it throws.

Field Default Meaning
type required snake_case. Equals the folder name, the widget_type value and the card tag houseview-<type>-card
label required Picker and editor name (English; translated through the string key <type>.label)
order required Unique positive integer; picker and editor order. Take the next free multiple of 10
render(ctx) required Pure. Returns one root element string that carries data-widget
entity 'required' 'required': entity must start with <type>.. 'none': no entity (clock, date). 'custom': validate() owns every check (action)
domains [type] ([] for none) Entity-picker domains and stub domains
aliases [] Other type names folded into this widget (action: scene, script, button, input_button)
defaultSize 'normal' normal, wide, tall or large; used by layout, the size-* class and the editor
availability 'entity' How ctx.ok is computed: 'entity' (exists and not unavailable/unknown), 'always' (clock, date), 'allow-unknown' (blinds: unknown stays usable)
unknownOkDomains [] Domains whose state unknown still counts as available (action: scene, button, input_button)
layoutRoles [] Roles the controller uses to find a widget: clock, camera and timer pick the widgets whose afterRender hook refreshes the clock, camera snapshots and countdowns, and camera marks the widget whose extension owns the gallery. Page layout does not use them
family null 'icon-tile' joins the shared icon-widget proportions in CSS (light, switch, fan, lock, action, alarm panel, vacuum)
standalone true Register houseview-<type>-card and a picker entry
tag houseview-<type>-card Element tag override, ^houseview-[a-z0-9]+(-[a-z0-9]+)*-card$. The default stays legal with underscores (houseview-media_player-card)
preview true The picker may preview the card; a render must never call services anyway
description string <type>.description Picker description
cardRows 3 getCardSize() of the standalone card
standaloneHeight 240 Default height in px of the standalone card
gridOptions {} Extra getGridOptions() keys (min_columns, min_rows, ...)
stub(hass) none Extra keys for the card's stub config; the entity is picked from domains
options {} Declarative per-widget keys (below). They drive enum validation, editor fields and documentation
validate(widget, config) none Throw Error('<exact message>'). Sees the original widget.type (an alias)
normalize(widget, config) none Pure. Runs after validation and alias folding. Return the widget to keep
watch(widget, config) none Extra entity ids whose change re-renders the card (default: widget.entity)
editor {name: true} name: false hides the label field; toForm(widget) maps stored values to form data; onKindChange(config)
actions {} Map of data-action name to action definition (section 4). A trailing * makes a prefix action
afterRender(ctx) none Hook (section 5)
tick none {every, run, align?, when?} scheduled DOM work (section 5)
onHass(ctx, prev, next) none Hook (section 5)
patch(ctx, tile, html) none Custom in-place update. Return true when handled
alwaysPatch false Skip the "markup unchanged" shortcut (the camera needs it)
extension none Card-level feature (section 6)
overview none Set only by defineOverview() for the derived card-mode widget of an overview

Unknown fields are rejected.

Option specs

options: {
  icon: {type: 'icon'},
  action: {type: 'enum', values: ['toggle', 'off'], default: 'toggle', error: 'Light action must be off or toggle.'}
}

type is one of text, icon, enum, boolean, number, entity, enum_list. Allowed keys: default, values (enum, enum_list), domains (entity: required, non-empty), min, max, step, error (exact message for an invalid enum value), when(widget) (show the field only when true). Labels and helper text come from strings.js: <type>.option.<name>.label and <type>.option.<name>.helper; icon and text fall back to editor.<name>.

Validation order, which decides which message a broken config gets: type known (Unsupported widget: <type>), option enums, def.validate, the entity rule (<type> widget needs a <type> entity. when entity is 'required'), the size rule (Widget size must be normal, wide, tall or large.), the action keys (tap_action and friends), then alias folding and def.normalize.

3. Render context

render(ctx) and every hook receive a context from src/core/context.js. It is rebuilt per call and is cheap.

Base fields (also in action and extension contexts):

Member Meaning
hass, config The Home Assistant object and the card config (read only)
stateOf(id), attrsOf(id), availableOf(id) hass.states[id], its attributes, availability
esc(v) HTML-escape. Everything not produced by a context helper must go through it
num(v) True for a finite number
t(key, vars) Translated string (hass.locale.language, base language, en, then the key)
unit(), lang() Temperature unit; hass.locale.language
icon(name) <ha-icon icon="mdi:name" aria-hidden="true">; a name containing : is used as it is
isPending(id) A service call for this entity is running
time(v) Epoch milliseconds of a timestamp string or number (NaN when unparsable)
now() Date.now(), or config.preview_time when set. Use it instead of reading the clock
call(domain, service, data) The only route to hass.callService
info(id) Open the more-info dialog

Render and hook context adds:

Member Meaning
widget, index, type The normalised widget config, its position in the card, the canonical type
entityId, state, attrs widget.entity, its state object, its attributes ({} when missing)
ok Availability under the widget's policy (always true for entity: 'none')
name, rawName widget.name, else the friendly name, else the type; escaped and raw
size, cls, attr Size, root classes (widget <type> size-<size>), data-widget="<index>"
tile(action, content, extra) The widget's root as a <button data-action>
root(tag, extra, inner) A non-button root (panels with several buttons)
btn(action, content, cls, enabled, {entity, attrs}) A button; disabled when not enabled or while the entity is pending
feature(bit) ok and the supported_features bit is set
iconName(fallback) widget.icon or the fallback; pass it to icon()
stateText(stateObj) The raw state with _ replaced by a space (localised when localized_states is on)

Hook context (afterRender, tick.run, onHass, patch) also has el (the mounted root, null when the widget is not on the page that is showing), requestRender(), store (a Map that lives as long as the widget instance), and ext (the state of the widget's own extension).

A render must return exactly one root element carrying data-widget; escape everything with ctx.esc; render a sensible disabled state when !ctx.ok; and be pure: no clock (ctx.now(); the date and calendar widgets read the clock on purpose and are the documented exception), no window, document or random values.

4. Actions

actions: {
  'cover-open': {
    availability: 'allow-unknown',          // optional: 'entity' (default) | 'allow-unknown'
    guard: (ctx, id) => true,               // optional: true runs, false ignores, 'info' opens more-info instead
    confirm: (ctx, id) => 'Open all?',      // optional: a message to confirm first, or null
    run: (ctx, id) => ctx.call('cover', 'open_cover', {entity_id: id}),
    immediate: false                        // optional: bypass the pending and availability gates
  }
}

The action context is the base context plus action (the full name), param (the part after a prefix, '15' for timer-start-15 under timer-start-*), widget (the first configured widget of that owner with this entity; may be undefined) and fail(message).

  • Action names form one global namespace; a duplicate or overlapping prefix throws at startup. Names are <type>-<verb>, with a short list of un-prefixed names kept for the built-in widgets (see GRANDFATHERED_ACTIONS in tests/fixtures/contract-rules.mjs). Core owns info and toggle.
  • Pipeline (src/core/dispatch.js): immediate actions run at once; then the pending and availability gates; then guard; then confirm (the native window.confirm, or the inline sheet with confirm_mode: inline); then the entity is marked pending and the feedback line cleared; then run; a thrown error becomes Could not complete action: <message>; finally pending is cleared. pending is per entity, so pressing one device never disables another.
  • A guard does not apply to an immediate action.
  • Service payloads are ctx.call(domain, service, {entity_id: id, ...}).
  • Card-level immediate actions that belong to no widget (dim, page-prev, page-next, page-select, calendar-view, overview-open, goto-widget) are core features registered with registerFeature, not widget code.

A press on a widget's primary button can be replaced by the user's tap_action, hold_action and double_tap_action (see actions); inner buttons of panel widgets always run their own action.

5. Hooks

Hook When Contract
afterRender(ctx) After the widget is created, patched or re-laid out Touch ctx.el only; idempotent and cheap (clock hands, camera image, countdown text)
tick: {every, run, align?, when?} The shared one-second ticker, throttled to every ms; align: 'minute' runs it when the minute changes Only for mounted widgets; skipped while the document is hidden or an overview covers the card
onHass(ctx, prev, next) Every hass update, before the render decision, for every configured widget (also on other pages) prev/next are the entity's old and new state objects; keep memory in ctx.store
patch(ctx, tile, html) Instead of the generic DOM morph Return true when handled. With alwaysPatch it also runs when the markup is unchanged
watch(widget, config) The change detector Extra entity ids that must trigger a render

Hooks must be safe when the element is missing and must not throw; the controller isolates each hook in a try/catch and logs once.

6. Extension (card-level features)

For behaviour bigger than one widget: the camera grid overlay, the timer alarm banner and its audio, and the full-screen music player. Hosted by src/controller/extensions.js; the controller never names the widget.

Field Meaning
configKeys Top-level config keys the widget owns (camera_gallery)
validateConfig(config) Throw an Error; runs after cards are converted, before widget validation
entities(config) Extra watched entity ids
state() Initial state, kept at card._ext[type]
compat Property accessors on the card, {name: {get, set?}}
shellClasses(card) Classes for the shell (camera-view-open)
overlay(ctx) HTML inserted when the overlay opens or the shell is rebuilt. A state update leaves a mounted overlay in place, and removes it when this returns nothing
retain(ctx) Optional. On a state update, true keeps the mounted overlay and skips overlay() (afterRender syncs it); false removes it
afterRender(ctx), tick As the widget hooks, card-level
blocksNavigation(ext) True stops page changes and swipes (the grid is open)
onKeydown(ctx, event), onPointerDown(ctx, event) Key and pointer handling; onKeydown returns true when handled
dispose(ctx) Called on every re-render and on disconnect (ctx.reason is 'render' or 'disconnect')
actions Immediate actions the extension owns (camera-grid, dismiss-alarm)

The extension context is the base context plus type, ext, root (the card's shadow root), q(selector), render() and focus(selector).

7. Strings

strings.js exports {en: {...}, <lang>: {...}}. Every key starts with <type>.. Interpolation is {name}.

export default {
  en: {
    'cover.label': 'Blinds',
    'cover.description': 'Open, stop and close blinds or shades.',
    'cover.percent_open': '{n}% open'
  }
};

npm run gen writes src/widgets/index.js so that each folder's strings.js reaches the translator through registerWidget(def, strings). A test that imports a widget's index.js directly must also register its strings or ctx.t() returns the key. Core keys any widget may use: state.* (state.unavailable, state.offline), common.on, common.off, editor.widget_type, editor.entity, editor.name, editor.size, editor.icon, error.action, error.try_again. English is the only language shipped for the widgets; add others as siblings of en.

8. CSS

  • Every section of style.css starts with /* @phase <name> */. Phases are listed in src/styles/phases.json, and sections must appear in phase order. A file without any marker lands in carousel-widgets, the right default for a new widget. /* @owns a b */ lists the classes the file owns.
  • Rules are rooted at a class you own; specificity at most (0,3,0); no !important without a /* @important reason */ comment; no .surface-*, .night-mode or .layout-* as the subject; no @import, @layer or url().
  • Use the card's custom properties (--hv-glass, --hv-edge, --hv-text) and --mdc-icon-size, so surfaces, dark mode and high contrast keep working.
  • A widget that looks like the icon widgets sets family: 'icon-tile'; the shared selector lists are generated.
  • Weak devices: avoid filter, large backdrop-filter areas and layered animation. See performance.

See architecture for how the sections become one stylesheet.

9. Fixtures and tests

// fixtures.js: no imports (the browser harness loads it unbundled)
export const variants = {
  ok: {widget: {entity: 'cover.test'}, states: {'cover.test': {state: 'open', attributes: {current_position: 40, supported_features: 15}}}},
  unavailable: {widget: {entity: 'cover.test'}, states: {'cover.test': {state: 'unavailable', attributes: {}}}},
  missing: {widget: {entity: 'cover.test'}, states: {}}
};

A widget with an entity provides at least ok, unavailable and missing (and unknown when it matters), plus one variant per feature bit or state branch its render or actions read.

// test.js: runs in a real browser against the built bundle with a mock hass (no network, no Home Assistant)
export default async function ({mount, check, variants}) {
  const t = await mount(variants.ok);
  await t.click('cover-open');
  check('open calls cover.open_cover', t.calls.length === 1 && t.calls[0].s === 'open_cover');
}

mount(variant, {as, size, config, hass}) returns a handle with q, qa, text, click(action, entity?), act, calls (every callService, as {d, s, data}), infos (entities whose more-info was opened), setState, remove. click resets calls first, so read calls after each press.

The contract suite (tests/contract/) holds every registered widget to this document: required files, a frozen and well-formed definition, exactly one root with data-widget for every variant, size and pending state, purity, every rendered data-action has an owner, hostile names create no elements, strings keys exist and are prefixed, guards that return false cause no service call, and payloads have the form {entity_id: id, ...}.

10. Contract linter

npm run check:widgets fails on: missing required files; a type that differs from the folder; duplicate order, tag, alias or action name; an action name that is neither grandfathered nor <type>-<verb>; render referencing callService, window, document, localStorage, fetch, confirm, timers, Date.now, Math.random or ctx.card; imports across widget boundaries or from controller/, elements/, editor/; ctx.t keys missing from strings.js; style.css rules outside @owns, specificity above (0,3,0), !important without a reason, url(; and src/widgets/index.js out of date.

11. Worked example: the light widget

import {defineWidget} from '../../core/define-widget.js';
import {controlTile} from '../_shared/control-tile.js';

export default defineWidget({
  type: 'light',
  label: 'Light',
  order: 60,
  family: 'icon-tile',

  options: {
    icon: {type: 'icon'},
    action: {type: 'enum', values: ['toggle', 'off'], default: 'toggle', error: 'Light action must be off or toggle.'}
  },

  // YAML reads an unquoted `action: off` as false; keep it meaning "turn off".
  normalize: (widget) => (widget.action === false ? {...widget, action: 'off'} : widget),

  render: (ctx) =>
    controlTile(ctx, {action: ctx.widget.action === 'off' ? 'light-off' : 'light', icon: 'lightbulb-outline'}),

  actions: {
    light: {run: (ctx, id) => ctx.call('light', 'toggle', {entity_id: id})},
    'light-off': {run: (ctx, id) => ctx.call('light', 'turn_off', {entity_id: id})}
  }
});

controlTile (in src/widgets/_shared/control-tile.js) renders the icon, the name and the state text as one <button>, marks it active while the entity is on, and disables it when the entity is unavailable.

12. Add a widget: checklist

  1. npm run new:widget -- --type humidifier --label "Humidifier" --domains humidifier creates the folder from the templates, picks the next free order and refreshes the generated files.
  2. Fill in index.js: render, actions, options, validate or normalize if needed.
  3. Write strings.js (all keys prefixed with <type>., including label and description).
  4. Write fixtures.js with the required variants.
  5. Write test.js: every control, every guard, every service payload.
  6. Fill style.css (phase markers and @owns) and, for an icon widget, set family: 'icon-tile'.
  7. Write README.md: front matter (type, label, entity, since), a one-paragraph description, an option table that matches options, and a YAML example. Run npm run gen-docs.
  8. Run npm run gen, npm run check:widgets, npm run test:unit, npm run test:widgets and npm run lint.

Asynchronous native editor choices

A widget can provide editor.loadOptions(hass, config, signal) to read choices such as a speaker’s favourites. The editor runs it once per entity and connection, cancels it when those change or the editor disconnects, and ignores stale responses. It must not call device services.

editor.schema(hass, config, schema, result) can add native HA form selectors using those choices; editor.helper(config, result, item) provides status/help text for a field. Saved config remains authoritative while choices load or fail. The Music card uses these hooks for an ordered native multi-select without putting media-specific logic in the shared editor.