/** * `` — create / edit form for a single entity. * * Props: * .entityId — pre-populated when editing; empty for create * .state — pre-populated state value * .attributes — pre-populated JSON object * .editing — true to lock entity_id (HA wire-compat doesn't rename) * * Emits: * hc-entity-submit detail: { entity_id, state, attributes } * hc-entity-cancel * * Validation (client-side; backend validates again): * - entity_id matches /^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/ * - state is non-empty * - attributes parses as a JSON object (not array, not scalar) */ import { LitElement, html, css } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; const ENTITY_ID_RE = /^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/; @customElement('hc-entity-form') export class EntityForm extends LitElement { @property({ type: String }) entityId = ''; @property({ type: String }) state = ''; @property({ type: Object }) entityAttrs: Record = {}; @property({ type: Boolean }) editing = false; @state() private _attrs = ''; @state() private _err: string | null = null; static styles = css` :host { display: block; font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); color: var(--hc-text, #e6eaee); } label { display: block; margin: 12px 0 4px; font-size: 12px; color: var(--hc-text-muted, #7b899d); } input, textarea { width: 100%; box-sizing: border-box; padding: 8px 10px; background: hsl(220 25% 10%); border: 1px solid var(--hc-border, #2a323e); border-radius: 6px; color: var(--hc-text, #e6eaee); font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); font-size: 13px; } input:focus, textarea:focus { outline: 2px solid hsl(185 80% 50% / 0.5); border-color: var(--hc-primary, #19d4e5); } input[disabled] { opacity: 0.5; cursor: not-allowed; } textarea { min-height: 90px; resize: vertical; } .hint { font-size: 11px; color: var(--hc-text-muted, #7b899d); margin-top: 4px; } .err { margin-top: 10px; padding: 10px; border: 1px solid #b35a5a; border-radius: 6px; background: hsl(0 35% 12%); color: #f0c0c0; font-size: 12px; } button { padding: 8px 16px; border: 1px solid var(--hc-border, #2a323e); border-radius: 6px; background: hsl(220 25% 14%); color: var(--hc-text, #e6eaee); font-size: 13px; font-weight: 500; cursor: pointer; font-family: inherit; } button.primary { background: var(--hc-primary, #19d4e5); color: var(--hc-primary-fg, #0b0e13); border-color: var(--hc-primary, #19d4e5); font-weight: 600; } button:hover { background: hsl(220 20% 18%); } button.primary:hover { background: hsl(185 80% 55%); } `; protected updated(changed: Map): void { if (changed.has('entityAttrs')) { this._attrs = JSON.stringify(this.entityAttrs, null, 2); } } /** Public — call from host to trigger validation + emit submit event. */ public requestSubmit(): void { this._submit(); } /** Public — call from host to dispatch cancel. */ public requestCancel(): void { this._cancel(); } private _submit() { const id = this.entityId.trim(); if (!ENTITY_ID_RE.test(id)) { this._err = `entity_id must match domain.snake_case (got "${id}")`; return; } const stateVal = this.state.trim(); if (!stateVal) { this._err = 'state must not be empty'; return; } let attrs: Record = {}; if (this._attrs.trim()) { try { const parsed = JSON.parse(this._attrs); if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) { this._err = 'attributes must be a JSON object (not array, not scalar)'; return; } attrs = parsed as Record; } catch (e) { this._err = `attributes JSON parse failed: ${e instanceof Error ? e.message : String(e)}`; return; } } this._err = null; this.dispatchEvent(new CustomEvent('hc-entity-submit', { detail: { entity_id: id, state: stateVal, attributes: attrs }, bubbles: true, composed: true, })); } private _cancel() { this._err = null; this.dispatchEvent(new CustomEvent('hc-entity-cancel', { bubbles: true, composed: true })); } render() { return html`
{ e.preventDefault(); this._submit(); }}> (this.entityId = (e.target as HTMLInputElement).value)} placeholder="light.kitchen_ceiling" />
format: domain.snake_case — domain like sensor / light / switch / binary_sensor
(this.state = (e.target as HTMLInputElement).value)} placeholder="on / off / 42 / 14.5 / detected" />
optional; leave blank for {}
${this._err ? html`
${this._err}
` : ''}
`; } } declare global { interface HTMLElementTagNameMap { 'hc-entity-form': EntityForm; } }