ux v1.38.0: binding section — radios + entities checkbox + dropdown

- Virtual / Pick-from-HA radios; Show-entities checkbox with tooltip
  gates device entities (groups/helpers always listed); searchable
  dropdown only in HA mode, auto-opens when empty, closes on pick;
  Save guarded until a binding is chosen; logic untouched
- smoke_binding_ui.mjs (16 checks); marker_stay/controls smokes green;
  TESTING/CHANGELOG same-commit
This commit is contained in:
Matysh
2026-07-23 14:52:01 +03:00
parent 05f162434b
commit ea41bec86b
13 changed files with 422 additions and 120 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
FILES_URL = "/houseplan_files/files"
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.37.3"
VERSION = "1.38.0"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.37.3"
"version": "1.38.0"
}
+50
View File
@@ -0,0 +1,50 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
c._setMode('devices'); await c.updateComplete;
// новый маркер: радио «Виртуальное» по умолчанию, списка нет
c._openMarkerDialog(); await c.updateComplete;
const radios = () => [...sr().querySelectorAll('.bindsel input[type="radio"]')];
out.twoRadios = radios().length === 2;
out.virtDefault = radios()[0].checked && !radios()[1].checked;
out.noDropWhenVirtual = !sr().querySelector('.dropbtn');
// переключить на «Из списка HA» → появляется дропдаун, открыт (нет выбора), сейв заблокирован
radios()[1].click(); await c.updateComplete;
out.dropShown = !!sr().querySelector('.dropbtn');
out.panelOpen = !!sr().querySelector('.droppanel');
const saveBtn = [...sr().querySelectorAll('.dialog .btn.on')].pop();
out.saveDisabled = saveBtn?.disabled === true;
// без чекбокса в списке нет individual-сущностей устройств
const subs = () => [...sr().querySelectorAll('.cand .cs')].map((e) => e.textContent);
const entLabel = c._t('marker.sub_entity');
out.noDeviceEntities = !subs().some((s) => s.includes(entLabel));
// (группы/хелперы в демо все уже размещены и потому скрыты как занятые —
// их «всегда в списке» проверяется кодом: блок вне чекбокса)
// включить чекбокс → сущности появились
const cb = sr().querySelector('.entcheck input');
cb.click(); await c.updateComplete;
out.entitiesShown = [...sr().querySelectorAll('.cand .cs')].some((s) => s.textContent.includes(entLabel));
out.tooltip = sr().querySelector('.entcheck').getAttribute('title') === c._t('marker.show_entities_tip');
// выбрать первый кандидат → панель закрылась, сейв разблокирован
sr().querySelector('.cand').click(); await c.updateComplete;
out.picked = c._markerDialog.binding !== '' && c._markerDialog.binding !== 'virtual';
out.panelClosed = !sr().querySelector('.droppanel');
out.saveEnabled = [...sr().querySelectorAll('.dialog .btn.on')].pop()?.disabled === false;
// радио назад на «Виртуальное» → binding=virtual
radios()[0].click(); await c.updateComplete;
out.backToVirtual = c._markerDialog.binding === 'virtual' && c._markerDialog.bindingMode === 'virtual';
c._markerDialog = null; await c.updateComplete;
// редактирование существующего устройства: радио «Из списка», выбранная привязка в кнопке
const dev = c._devices.find((d) => !d.virtual && d.bindingKind === 'device');
c._openMarkerDialog(dev); await c.updateComplete;
out.editHaMode = c._markerDialog.bindingMode === 'ha';
out.editShowsCur = sr().querySelector('.dropbtn b') !== null;
out.editPanelClosed = !sr().querySelector('.droppanel');
c._markerDialog = null;
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
File diff suppressed because one or more lines are too long
+73 -19
View File
File diff suppressed because one or more lines are too long
+8
View File
@@ -1,5 +1,13 @@
# Changelog
## v1.38.0 — 2026-07-23 (binding section redesign)
- The device dialog's binding section is compact now: two radio buttons —
**Virtual device** and **Pick from the HA list** — with a **Show entities**
checkbox (adds every entity of the devices to the list; groups and helpers
are always listed). The searchable dropdown appears only in HA mode and
collapses once you pick. Save is disabled until a binding is chosen.
The binding logic itself is unchanged.
## v1.37.3 — 2026-07-23
- Open boundaries now render as a **true dash**: the rooms' solid outlines are
trimmed under the open stretch (outlineWithout) instead of dashes being
+6
View File
@@ -140,6 +140,12 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
(explicit ripple color still wins); off/white lights unchanged [auto]
- [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any
display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static
- [ ] Binding section redesign (v1.38.0): two radios — Virtual / Pick from
the HA list — with a "Show entities" checkbox (tooltip) next to the
second; the dropdown (search inside) appears only in HA mode, opens
itself when nothing is chosen, closes on pick; Save is blocked until a
binding is chosen in HA mode; groups/helpers listed always, device
entities only with the checkbox; editing pre-selects everything [auto]
- [ ] True dashed boundary (v1.37.3): the open stretch is a REAL dash — the
rooms' solid strokes are trimmed out beneath it (hover doesn't bring
them back), walls elsewhere stay solid; the dashes render ABOVE the
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.37.3",
"version": "1.38.0",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+55 -20
View File
@@ -32,7 +32,7 @@ import './space-card';
import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.37.3';
const CARD_VERSION = '1.38.0';
const LS_KEY = 'houseplan_card_layout_v1';
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -156,7 +156,10 @@ class HouseplanCard extends LitElement {
private _markerDialog: {
devId?: string; // the icon being edited (if any)
name: string;
binding: string; // 'device:<id>' | 'entity:<eid>' | 'virtual'
binding: string; // 'device:<id>' | 'entity:<eid>' | 'virtual' | '' (not chosen yet)
bindingMode: 'virtual' | 'ha';
bindingOpen: boolean; // the HA-list dropdown is expanded
showEntities: boolean; // list entities of devices too
bindingFilter: string;
icon: string; // '' = auto
autoIcon: string; // the icon the rules would give — picker placeholder
@@ -2096,6 +2099,10 @@ class HouseplanCard extends LitElement {
devId: d.id,
name: d.name,
binding: d.bindingKind === 'virtual' ? 'virtual' : d.bindingKind + ':' + d.bindingRef,
bindingMode: d.bindingKind === 'virtual' ? 'virtual' : 'ha',
bindingOpen: false,
// a marker bound to an ENTITY of a device only shows up with the box on
showEntities: d.bindingKind === 'entity' && !!this.hass.entities[d.bindingRef || '']?.device_id,
bindingFilter: '',
icon: d.marker?.icon || '',
autoIcon: d.icon || '',
@@ -2123,7 +2130,8 @@ class HouseplanCard extends LitElement {
};
} else {
this._markerDialog = {
name: '', binding: 'virtual', bindingFilter: '', icon: '', autoIcon: '',
name: '', binding: 'virtual', bindingMode: 'virtual', bindingOpen: false,
showEntities: false, bindingFilter: '', icon: '', autoIcon: '',
display: 'badge', rippleColor: '', rippleSize: 3, size: 1, angle: 0,
tapAction: '', controls: [], controlsFilter: '', glowRadius: '', model: '',
link: '', description: '', pdfs: [], room: '', busy: false,
@@ -2174,11 +2182,9 @@ class HouseplanCard extends LitElement {
sub: eid.split('.')[0] + ' · ' + (reg.platform === 'group' ? this._t('marker.sub_group') : this._t('marker.sub_helper')),
});
}
// Individual entities — surfaced only while searching (avoids a huge default list); lets you
// place a single entity of a multi-entity device (e.g. the humidity of a climate sensor) as
// its own icon. Uses the same entity: binding as helpers/groups.
const q = (this._markerDialog?.bindingFilter || '').toLowerCase().trim();
if (q) {
// Individual entities of devices — behind the "show entities" checkbox
// (groups/helpers above are ALWAYS listed: they are standalone objects).
if (this._markerDialog?.showEntities) {
const seen = new Set(list.map((o) => o.value));
for (const [eid, reg] of Object.entries<any>(h.entities)) {
const v = 'entity:' + eid;
@@ -2187,7 +2193,6 @@ class HouseplanCard extends LitElement {
const label = reg.name || stt?.attributes?.friendly_name || eid;
const dev = reg.device_id ? h.devices[reg.device_id] : null;
const devName = dev ? (dev.name_by_user || dev.name || '') : '';
if (!(label + ' ' + eid + ' ' + devName).toLowerCase().includes(q)) continue;
list.push({ value: v, label, sub: eid.split('.')[0] + ' · ' + this._t('marker.sub_entity') + (devName ? ' · ' + devName : '') });
}
}
@@ -2290,6 +2295,7 @@ class HouseplanCard extends LitElement {
private async _saveMarker(): Promise<void> {
const dlg = this._markerDialog;
if (!dlg || dlg.busy) return;
if (dlg.bindingMode === 'ha' && (!dlg.binding || dlg.binding === 'virtual')) return;
if (dlg.binding === 'virtual' && !dlg.name.trim()) {
this._showToast(this._t('toast.virtual_name_required'));
return;
@@ -3888,7 +3894,7 @@ class HouseplanCard extends LitElement {
private _renderMarkerDialog(): TemplateResult {
const d = this._markerDialog!;
const isVirtual = d.binding === 'virtual';
const isVirtual = d.bindingMode === 'virtual';
const cands = this._bindingCandidates();
const curLabel = (() => {
if (isVirtual) return null;
@@ -3909,27 +3915,54 @@ class HouseplanCard extends LitElement {
@input=${(e: Event) => (this._markerDialog = { ...d, name: (e.target as HTMLInputElement).value })} />
<label>${this._t('marker.binding_label')}</label>
<div class="bindsel ${isVirtual ? 'virt' : ''}">
<button class="opt ${isVirtual ? 'on' : ''}"
@click=${() => (this._markerDialog = { ...d, binding: 'virtual' })}>
<ha-icon icon="mdi:map-marker-outline"></ha-icon>${this._t('marker.virtual_option')}
<div class="bindsel">
<label class="srcrow">
<input type="radio" name="bmode" .checked=${d.bindingMode === 'virtual'}
@change=${() => (this._markerDialog = { ...d, bindingMode: 'virtual', binding: 'virtual', bindingOpen: false })} />
<span>${this._t('marker.virtual_option')}</span>
</label>
<div class="bindharow">
<label class="srcrow">
<input type="radio" name="bmode" .checked=${d.bindingMode === 'ha'}
@change=${() => (this._markerDialog = {
...d, bindingMode: 'ha',
binding: d.binding === 'virtual' ? '' : d.binding,
bindingOpen: d.binding === 'virtual' || !d.binding,
})} />
<span>${this._t('marker.from_ha_option')}</span>
</label>
<label class="srcrow inline entcheck" title=${this._t('marker.show_entities_tip')}>
<input type="checkbox" .checked=${d.showEntities}
?disabled=${d.bindingMode !== 'ha'}
@change=${(e: Event) => (this._markerDialog = { ...d, showEntities: (e.target as HTMLInputElement).checked })} />
<span>${this._t('marker.show_entities')}</span>
</label>
</div>
${d.bindingMode === 'ha'
? html`<button class="dropbtn ${d.bindingOpen ? 'open' : ''}"
@click=${() => (this._markerDialog = { ...d, bindingOpen: !d.bindingOpen })}>
${curLabel
? html`<b>${curLabel}</b><span class="ref">${d.binding}</span>`
: html`<span class="muted">${this._t('marker.pick_ph')}</span>`}
<ha-icon icon=${d.bindingOpen ? 'mdi:chevron-up' : 'mdi:chevron-down'}></ha-icon>
</button>
${!isVirtual
? html`<div class="curbind"><ha-icon icon="mdi:link-variant"></ha-icon>
<b>${curLabel}</b><span class="ref">${d.binding}</span></div>`
: nothing}
${d.bindingOpen
? html`<div class="droppanel">
<input class="namein" type="text" placeholder=${this._t('marker.search_ph')}
.value=${d.bindingFilter}
@input=${(e: Event) => (this._markerDialog = { ...d, bindingFilter: (e.target as HTMLInputElement).value })} />
<div class="candlist">
${cands.map(
(c) => html`<div class="cand ${c.value === d.binding ? 'sel' : ''}"
@click=${() => (this._markerDialog = { ...d, binding: c.value })}>
@click=${() => (this._markerDialog = { ...d, binding: c.value, bindingOpen: false })}>
<span class="cl">${c.label}</span><span class="cs">${c.sub}</span>
</div>`,
)}
${!cands.length ? html`<div class="cand muted">${this._t('marker.nothing_found')}</div>` : nothing}
</div>
</div>`
: nothing}`
: nothing}
</div>
<label>${this._t('marker.room_label')}${isVirtual ? '' : this._t('marker.room_override')}</label>
@@ -4073,7 +4106,9 @@ class HouseplanCard extends LitElement {
: nothing}
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._markerDialog = null)}>${this._t('btn.cancel')}</button>
<button class="btn on" @click=${this._saveMarker} ?disabled=${d.busy}>
<button class="btn on" @click=${this._saveMarker}
?disabled=${d.busy || (d.bindingMode === 'ha' && (!d.binding || d.binding === 'virtual'))}
title=${d.bindingMode === 'ha' && (!d.binding || d.binding === 'virtual') ? this._t('marker.pick_ph') : ''}>
<ha-icon icon="mdi:check"></ha-icon>${d.busy ? '' : this._t('btn.save')}
</button>
</div>
+5 -1
View File
@@ -298,5 +298,9 @@
"title.markup_openwall": "Open boundary — click a wall shared by two rooms to make it virtual: light flows through, the line turns dashed. Click again to close it.",
"toast.openwall_pick": "Click a wall shared by two rooms",
"toast.openwall_opened": "Boundary “{a}” ↔ “{b}” is now open",
"toast.openwall_closed": "Boundary “{a}” ↔ “{b}” is closed again"
"toast.openwall_closed": "Boundary “{a}” ↔ “{b}” is closed again",
"marker.from_ha_option": "Pick from the HA list",
"marker.show_entities": "Show entities",
"marker.show_entities_tip": "Adds not only devices to the list, but all their entities too",
"marker.pick_ph": "Choose a device…"
}
+5 -1
View File
@@ -298,5 +298,9 @@
"title.markup_openwall": "Открытая граница — клик по общей стене двух комнат делает её условной: свет проходит насквозь, линия становится пунктирной. Повторный клик закрывает.",
"toast.openwall_pick": "Кликните по стене, разделяющей две комнаты",
"toast.openwall_opened": "Граница «{a}» ↔ «{b}» теперь открыта",
"toast.openwall_closed": "Граница «{a}» ↔ «{b}» снова закрыта"
"toast.openwall_closed": "Граница «{a}» ↔ «{b}» снова закрыта",
"marker.from_ha_option": "Выбрать из списка HA",
"marker.show_entities": "Отображать сущности",
"marker.show_entities_tip": "Добавляет в список не только устройства, но и все их сущности",
"marker.pick_ph": "Выберите устройство…"
}
+33
View File
@@ -396,6 +396,39 @@ export const cardStyles = css`
display: inline-flex;
}
.roomlabel .rlm.lit { opacity: 1; }
.bindharow {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.bindharow .entcheck { opacity: 0.9; }
.dropbtn {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
text-align: left;
border: 1px solid var(--hp-muted);
border-radius: 8px;
background: transparent;
color: var(--hp-txt);
padding: 8px 10px;
cursor: pointer;
font-family: inherit;
font-size: 13px;
margin-top: 6px;
}
.dropbtn .ref { color: var(--hp-muted); font-size: 11px; margin-left: auto; }
.dropbtn ha-icon { --mdc-icon-size: 18px; margin-left: 4px; }
.dropbtn.open { border-color: var(--hp-accent); }
.droppanel {
border: 1px solid var(--hp-accent);
border-top: none;
border-radius: 0 0 8px 8px;
padding: 6px;
margin-top: -4px;
}
.ctrlchips { display: flex; flex-wrap: wrap; gap: 5px; margin: 4px 0; }
.ctrlchip {
display: inline-flex; align-items: center; gap: 4px;