From 89190b6c2d357dff3ecbe8daaea225b7569032b0 Mon Sep 17 00:00:00 2001 From: ruv Date: Tue, 26 May 2026 14:48:49 -0400 Subject: [PATCH] feat(homecore-ui iter 2): Edit Entity modal + shadow-DOM focus delegation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRUD increment 2/6 — clicking any state card on the Dashboard opens the Add Entity modal in EDIT mode: pre-populated, entity_id locked, "Save" primary button, idempotent POST to /api/states/ (backend returns 200 if existed, 201 if created — same handler). frontend/src/components/StateCard.ts: - card div is now role="button" tabindex=0, dispatches `hc-state-card-click` on click + Enter/Space keydown - aria-label="Edit " for screen readers - shadowRootOptions delegatesFocus=true so the outer Tab sequence can reach the inner focusable div (caught by browser agent — without this Tab couldn't pierce the shadow root) frontend/src/pages/Dashboard.ts: - new state: editingState (null = create, StateView = edit) - _openEdit() catches `hc-state-card-click` from the grid container - modal heading switches: "Add entity" ↔ "Edit " - primary button text switches: "Create" ↔ "Save" - EntityForm receives .editing=true so entity_id input is disabled - submit toast reads "Updated" or "Created" depending on mode Browser-verified end-to-end (real homecore-server :8123, 12 entities): - Click `light.kitchen_ceiling` → modal opens with all 4 attributes (brightness=230, color_temp_kelvin=4000, friendly_name, supported_color_modes) pre-populated - Change state to "off", click Save → toast "Updated light.kitchen_ceiling = off", grid card reflects new state - Backend curl confirms /api/states/light.kitchen_ceiling.state = "off" - Enter key on focused card opens the modal too - 0 console errors Co-Authored-By: claude-flow --- frontend/src/components/StateCard.ts | 20 +++++++++++++++- frontend/src/pages/Dashboard.ts | 34 +++++++++++++++++++++------- 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/StateCard.ts b/frontend/src/components/StateCard.ts index 27bc6e94..90fdfff9 100644 --- a/frontend/src/components/StateCard.ts +++ b/frontend/src/components/StateCard.ts @@ -9,6 +9,12 @@ import type { StateView } from '../api/types.js'; @customElement('hc-state-card') export class StateCard extends LitElement { + // `delegatesFocus` lets Tab key traversal from the light DOM reach the + // role="button" element inside this card's shadow root. Without it the + // user can only activate the card via mouse click or by JS-focusing the + // inner div; with it, the natural tab sequence flows through every card. + static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }; + @property({ type: Object }) state!: StateView; /** Optional: icon SVG string (use `iconSvg()` from lucide.ts) */ @property({ type: String }) iconSvg?: string; @@ -32,6 +38,9 @@ export class StateCard extends LitElement { border-color: hsl(185 80% 50% / 0.4); } + .card { cursor: pointer; } + .card:focus-visible { outline: 2px solid var(--hc-primary, #19d4e5); outline-offset: 2px; } + .header { display: flex; align-items: flex-start; @@ -108,7 +117,10 @@ export class StateCard extends LitElement { const badge = this.badgeClass(state); return html` -
+
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this._onClick(); } }} + aria-label="Edit ${entity_id}">
${this.iconSvg ? html`
` @@ -123,6 +135,12 @@ export class StateCard extends LitElement {
`; } + + private _onClick() { + this.dispatchEvent(new CustomEvent('hc-state-card-click', { + detail: { state: this.state }, bubbles: true, composed: true, + })); + } } declare global { diff --git a/frontend/src/pages/Dashboard.ts b/frontend/src/pages/Dashboard.ts index f93741a5..d0ace6f5 100644 --- a/frontend/src/pages/Dashboard.ts +++ b/frontend/src/pages/Dashboard.ts @@ -102,6 +102,7 @@ export class Dashboard extends LitElement { @state() private loading = true; @state() private modalOpen = false; @state() private submitToast: string | null = null; + @state() private editingState: StateView | null = null; // null = create mode @query('hc-entity-form') private _form?: EntityForm; @@ -135,8 +136,19 @@ export class Dashboard extends LitElement { } } + private _openCreate() { + this.editingState = null; + this.modalOpen = true; + } + + private _openEdit(e: CustomEvent<{ state: StateView }>) { + this.editingState = e.detail.state; + this.modalOpen = true; + } + private async _onSubmit(e: CustomEvent<{ entity_id: string; state: string; attributes: Record }>) { const { entity_id, state, attributes } = e.detail; + const wasEditing = this.editingState !== null; try { const resp = await fetch(`/api/states/${encodeURIComponent(entity_id)}`, { method: 'POST', @@ -148,11 +160,11 @@ export class Dashboard extends LitElement { }); if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`); this.modalOpen = false; - this.submitToast = `Created ${entity_id} = ${state}`; + this.editingState = null; + this.submitToast = `${wasEditing ? 'Updated' : 'Created'} ${entity_id} = ${state}`; window.setTimeout(() => (this.submitToast = null), 3000); await this.refresh(); } catch (err) { - // Form-level error stays in the form; surface at top too for visibility. this.error = err instanceof Error ? err.message : String(err); } } @@ -173,7 +185,7 @@ export class Dashboard extends LitElement { ${this.submitToast ? html`
${this.submitToast}
` : ''}
- +
${loc} @@ -187,19 +199,25 @@ export class Dashboard extends LitElement { or boot homecore-server without --no-seed-entities.
` - : html`
+ : html`
this._openEdit(e as CustomEvent)}> ${this.states.map( (s) => html`` )}
`} - (this.modalOpen = false)}> + { this.modalOpen = false; this.editingState = null; }}> this._onSubmit(e as CustomEvent)} - @hc-entity-cancel=${() => (this.modalOpen = false)}> + @hc-entity-cancel=${() => { this.modalOpen = false; this.editingState = null; }}> - + `; }