Skip to content

Commit 89190b6

Browse files
committed
feat(homecore-ui iter 2): Edit Entity modal + shadow-DOM focus delegation
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/<id> (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 <entity_id>" 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 <entity_id>" - 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 <ruv@ruv.net>
1 parent e7215a1 commit 89190b6

2 files changed

Lines changed: 45 additions & 9 deletions

File tree

frontend/src/components/StateCard.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ import type { StateView } from '../api/types.js';
99

1010
@customElement('hc-state-card')
1111
export class StateCard extends LitElement {
12+
// `delegatesFocus` lets Tab key traversal from the light DOM reach the
13+
// role="button" element inside this card's shadow root. Without it the
14+
// user can only activate the card via mouse click or by JS-focusing the
15+
// inner div; with it, the natural tab sequence flows through every card.
16+
static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true };
17+
1218
@property({ type: Object }) state!: StateView;
1319
/** Optional: icon SVG string (use `iconSvg()` from lucide.ts) */
1420
@property({ type: String }) iconSvg?: string;
@@ -32,6 +38,9 @@ export class StateCard extends LitElement {
3238
border-color: hsl(185 80% 50% / 0.4);
3339
}
3440
41+
.card { cursor: pointer; }
42+
.card:focus-visible { outline: 2px solid var(--hc-primary, #19d4e5); outline-offset: 2px; }
43+
3544
.header {
3645
display: flex;
3746
align-items: flex-start;
@@ -108,7 +117,10 @@ export class StateCard extends LitElement {
108117
const badge = this.badgeClass(state);
109118

110119
return html`
111-
<div class="card" part="card">
120+
<div class="card" part="card" role="button" tabindex="0"
121+
@click=${this._onClick}
122+
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this._onClick(); } }}
123+
aria-label="Edit ${entity_id}">
112124
<div class="header">
113125
${this.iconSvg
114126
? html`<div class="icon-wrap" .innerHTML=${this.iconSvg}></div>`
@@ -123,6 +135,12 @@ export class StateCard extends LitElement {
123135
</div>
124136
`;
125137
}
138+
139+
private _onClick() {
140+
this.dispatchEvent(new CustomEvent('hc-state-card-click', {
141+
detail: { state: this.state }, bubbles: true, composed: true,
142+
}));
143+
}
126144
}
127145

128146
declare global {

frontend/src/pages/Dashboard.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export class Dashboard extends LitElement {
102102
@state() private loading = true;
103103
@state() private modalOpen = false;
104104
@state() private submitToast: string | null = null;
105+
@state() private editingState: StateView | null = null; // null = create mode
105106

106107
@query('hc-entity-form') private _form?: EntityForm;
107108

@@ -135,8 +136,19 @@ export class Dashboard extends LitElement {
135136
}
136137
}
137138

139+
private _openCreate() {
140+
this.editingState = null;
141+
this.modalOpen = true;
142+
}
143+
144+
private _openEdit(e: CustomEvent<{ state: StateView }>) {
145+
this.editingState = e.detail.state;
146+
this.modalOpen = true;
147+
}
148+
138149
private async _onSubmit(e: CustomEvent<{ entity_id: string; state: string; attributes: Record<string, unknown> }>) {
139150
const { entity_id, state, attributes } = e.detail;
151+
const wasEditing = this.editingState !== null;
140152
try {
141153
const resp = await fetch(`/api/states/${encodeURIComponent(entity_id)}`, {
142154
method: 'POST',
@@ -148,11 +160,11 @@ export class Dashboard extends LitElement {
148160
});
149161
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
150162
this.modalOpen = false;
151-
this.submitToast = `Created ${entity_id} = ${state}`;
163+
this.editingState = null;
164+
this.submitToast = `${wasEditing ? 'Updated' : 'Created'} ${entity_id} = ${state}`;
152165
window.setTimeout(() => (this.submitToast = null), 3000);
153166
await this.refresh();
154167
} catch (err) {
155-
// Form-level error stays in the form; surface at top too for visibility.
156168
this.error = err instanceof Error ? err.message : String(err);
157169
}
158170
}
@@ -173,7 +185,7 @@ export class Dashboard extends LitElement {
173185
${this.submitToast ? html`<div class="toast">${this.submitToast}</div>` : ''}
174186
<div class="toolbar">
175187
<span class="grow"></span>
176-
<button class="add" @click=${() => (this.modalOpen = true)}>+ Add entity</button>
188+
<button class="add" @click=${this._openCreate}>+ Add entity</button>
177189
</div>
178190
<div class="meta">
179191
<span><strong>${loc}</strong></span>
@@ -187,19 +199,25 @@ export class Dashboard extends LitElement {
187199
or boot <code>homecore-server</code> without
188200
<code>--no-seed-entities</code>.
189201
</div>`
190-
: html`<div class="grid">
202+
: html`<div class="grid"
203+
@hc-state-card-click=${(e: Event) => this._openEdit(e as CustomEvent)}>
191204
${this.states.map(
192205
(s) => html`<hc-state-card .state=${s}></hc-state-card>`
193206
)}
194207
</div>`}
195208
196-
<hc-modal .open=${this.modalOpen} heading="Add entity"
197-
@hc-modal-close=${() => (this.modalOpen = false)}>
209+
<hc-modal .open=${this.modalOpen}
210+
heading=${this.editingState ? `Edit ${this.editingState.entity_id}` : 'Add entity'}
211+
@hc-modal-close=${() => { this.modalOpen = false; this.editingState = null; }}>
198212
<hc-entity-form
213+
.entityId=${this.editingState?.entity_id ?? ''}
214+
.state=${this.editingState?.state ?? ''}
215+
.entityAttrs=${this.editingState?.attributes ?? {}}
216+
.editing=${this.editingState !== null}
199217
@hc-entity-submit=${(e: Event) => this._onSubmit(e as CustomEvent)}
200-
@hc-entity-cancel=${() => (this.modalOpen = false)}></hc-entity-form>
218+
@hc-entity-cancel=${() => { this.modalOpen = false; this.editingState = null; }}></hc-entity-form>
201219
<button slot="footer" class="btn" @click=${() => this._form?.requestCancel()}>Cancel</button>
202-
<button slot="footer" class="btn primary" @click=${() => this._form?.requestSubmit()}>Create</button>
220+
<button slot="footer" class="btn primary" @click=${() => this._form?.requestSubmit()}>${this.editingState ? 'Save' : 'Create'}</button>
203221
</hc-modal>
204222
`;
205223
}

0 commit comments

Comments
 (0)