mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
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:
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
Vendored
+73
-19
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": "Выберите устройство…"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user