Compare commits

...
1 Commits
Author SHA1 Message Date
Matysh 6a9122f41f v1.59.2: make dialogs accessible 2026-08-07 07:47:37 +03:00
44 changed files with 1434 additions and 840 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.59.1"
VERSION = "1.59.2"
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.59.1"
"version": "1.59.2"
}
+2 -1
View File
@@ -46,7 +46,8 @@ await page.evaluate(async () => {
c.shadowRoot.querySelector('.compass').scrollIntoView({ block: 'center' });
});
await page.waitForTimeout(200);
const dlg = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.dialog'));
const dlg = await page.evaluateHandle(() => window.__card.shadowRoot
.querySelector('hp-dialog').shadowRoot.querySelector('.surface'));
await dlg.asElement().screenshot({ path: outDir + '/sun_compass.png' });
await browser.close();
console.log('shots written to ' + outDir);
+1 -1
View File
@@ -48,7 +48,7 @@ const res = await page.evaluate(async () => {
};
c._openSettingsDialog();
await c.updateComplete;
out.dialogHasBgRow = !!sr().querySelector('.dialog .colorrow input[type=color]');
out.dialogHasBgRow = !!sr().querySelector('hp-dialog .colorrow input[type=color]');
c._settingsDialog = { ...c._settingsDialog, bgColor: '#0a2a4a' };
out.dialogPreviews = (await stageBg()) === rgb('#0a2a4a');
await c._saveSettingsDialog();
+2 -2
View File
@@ -15,7 +15,7 @@ const res = await page.evaluate(async () => {
radios()[1].click(); await c.updateComplete;
out.dropShown = !!sr().querySelector('.dropbtn');
out.panelOpen = !!sr().querySelector('.droppanel');
const saveBtn = [...sr().querySelectorAll('.dialog .btn.on')].pop();
const saveBtn = [...sr().querySelectorAll('hp-dialog .btn.on')].pop();
out.saveDisabled = saveBtn?.disabled === true;
// без чекбокса в списке нет individual-сущностей устройств
const subs = () => [...sr().querySelectorAll('.cand .cs')].map((e) => e.textContent);
@@ -32,7 +32,7 @@ const res = await page.evaluate(async () => {
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;
out.saveEnabled = [...sr().querySelectorAll('hp-dialog .btn.on')].pop()?.disabled === false;
// радио назад на «Виртуальное» → binding=virtual
radios()[0].click(); await c.updateComplete;
out.backToVirtual = c._markerDialog.binding === 'virtual' && c._markerDialog.bindingMode === 'virtual';
+1 -1
View File
@@ -13,7 +13,7 @@ const res = await page.evaluate(async () => {
out.hasDev = !!dev;
c._infoCard = dev; await c.updateComplete;
// 1) блок сущностей идёт ПЕРВЫМ, до модели/ссылок
const body = sr().querySelector('.dialog .body');
const body = sr().querySelector('hp-dialog .body');
out.entListFirst = body.firstElementChild?.classList.contains('entlist')
|| body.querySelector('.entlist') === body.children[0];
const rows = [...sr().querySelectorAll('.entrow')];
+4 -4
View File
@@ -61,12 +61,12 @@ Object.assign(out, await page.evaluate(async () => {
// у обычного термометра чекбокса нет
const th = c._devices.find((d) => d.entities?.includes('sensor.living_temp'));
c._openMarkerDialog(th); await c.updateComplete;
o.noCheckboxForThermometer = !sr().querySelector('.dialog .climrow');
o.noCheckboxForThermometer = !sr().querySelector('hp-dialog .climrow');
c._markerDialog = null; await c.updateComplete;
// у кондиционера — есть и по умолчанию снят
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
c._openMarkerDialog(ac); await c.updateComplete;
const row = sr().querySelector('.dialog .climrow input');
const row = sr().querySelector('hp-dialog .climrow input');
o.checkboxForClimate = !!row;
o.uncheckedByDefault = !!row && !row.checked;
if (row) row.scrollIntoView({ block: 'center' });
@@ -77,7 +77,7 @@ Object.assign(out, await page.evaluate(async () => {
const box = await page.evaluate(() => {
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const r = sr.querySelector('.dialog .climrow input')?.getBoundingClientRect();
const r = sr.querySelector('hp-dialog .climrow input')?.getBoundingClientRect();
return r ? { x: r.x + r.width / 2, y: r.y + r.height / 2 } : null;
});
check('checkboxClickable', !!box);
@@ -138,7 +138,7 @@ Object.assign(out, await page.evaluate(async () => {
const ac = c._devices.find((d) => d.entities?.includes('climate.ac'));
c._openMarkerDialog(ac); await c.updateComplete;
o.survivesReopen = c._markerDialog?.useClimateTemp === true;
o.checkboxChecked = sr().querySelector('.dialog .climrow input')?.checked === true;
o.checkboxChecked = sr().querySelector('hp-dialog .climrow input')?.checked === true;
c._markerDialog = null;
return o;
}));
+1 -1
View File
@@ -83,7 +83,7 @@ const out = await page.evaluate(async () => {
// ---- the dialog offers the action and SAVES it -------------------------
c._setMode('devices'); await c.updateComplete;
c._openMarkerDialog(curtain()); await c.updateComplete;
const optionValues = () => [...sr().querySelectorAll('.dialog option')].map((e) => e.value);
const optionValues = () => [...sr().querySelectorAll('hp-dialog option')].map((e) => e.value);
o.dialogOffersCover = optionValues().includes('cover');
c._markerDialog = { ...c._markerDialog, tapAction: 'cover', display: 'icon_ripple' };
await c.updateComplete;
+3 -3
View File
@@ -74,13 +74,13 @@ const out = await page.evaluate(async () => {
// ---- the dialog offers the option only where it makes sense ------------
await setCover('closed', { device_class: 'curtain' });
c._setMode('devices'); await c.updateComplete;
const optionValues = () => [...sr().querySelectorAll('.dialog option')].map((e) => e.value);
const optionValues = () => [...sr().querySelectorAll('hp-dialog option')].map((e) => e.value);
c._openMarkerDialog(gate()); await c.updateComplete;
o.dialogOffersCoverForCurtain = optionValues().includes('cover');
// …and the confirmation checkbox follows the choice, like it does for run/toggle
const rowsBefore = sr().querySelectorAll('.dialog .srcrow').length;
const rowsBefore = sr().querySelectorAll('hp-dialog .srcrow').length;
c._markerDialog = { ...c._markerDialog, tapAction: 'cover' }; await c.updateComplete;
o.confirmCheckboxShownForCover = sr().querySelectorAll('.dialog .srcrow').length > rowsBefore;
o.confirmCheckboxShownForCover = sr().querySelectorAll('hp-dialog .srcrow').length > rowsBefore;
c._markerDialog = null; await c.updateComplete;
// a GARAGE door never gets the option (owner: «нет, только шторы/жалюзи»)
+1 -1
View File
@@ -180,7 +180,7 @@ const res = await page.evaluate(async () => {
}));
await c.updateComplete;
out.doubleClickOpensObjectDialog = c._decorShapeDialog?.id === 'dcprobe'
&& !!sr().querySelector('.dialog .dfill') === false;
&& !!sr().querySelector('hp-dialog .dfill') === false;
c._decorShapeDialog = { ...c._decorShapeDialog, color: '#123456', width: 6.5 };
c._decorSaveShape(); await c.updateComplete;
const editedProbe = c._decorList.find((x) => x.id === 'dcprobe');
+1 -1
View File
@@ -49,7 +49,7 @@ const res = await page.evaluate(async () => {
c._decorTextDialog = { x: 0.3, y: 0.3, text: '', color: '#223344' };
await c.updateComplete;
out.noSizePicker = !sr().querySelector('input[name="dtsize"]');
out.textareaNotInput = !!sr().querySelector('.dialog textarea.dtarea');
out.textareaNotInput = !!sr().querySelector('hp-dialog textarea.dtarea');
c._decorTextDialog = null; await c.updateComplete;
// старый `size` продолжает рисоваться ровно как раньше: 'l' = 30px
+39
View File
@@ -3,6 +3,45 @@ const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = c.shadowRoot || c.renderRoot;
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
const deepActive = () => {
let active = document.activeElement;
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
return active;
};
// A11Y-02: the shared native fallback carries the same modal/focus contract
// that ha-dialog supplies in Home Assistant.
const opener = document.createElement('button');
opener.textContent = 'open settings';
sr.append(opener);
opener.focus();
c._openSettingsDialog(); await c.updateComplete; await frame();
const hp = sr.querySelector('hp-dialog');
const native = hp?.shadowRoot?.querySelector('dialog');
const titleId = native?.getAttribute('aria-labelledby');
out.sharedShell = !!hp && !!native?.open;
out.modalSemantics = native?.getAttribute('role') === 'dialog'
&& native?.getAttribute('aria-modal') === 'true'
&& !!titleId && !!hp.shadowRoot.getElementById(titleId)?.textContent.trim();
let active = deepActive();
out.initialFocus = !!active && (hp.contains(active) || hp.shadowRoot.contains(active));
const focusable = [...hp.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled])')];
const last = focusable.at(-1);
last?.focus();
last?.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Tab', bubbles: true, composed: true, cancelable: true,
}));
active = deepActive();
out.focusTrap = active?.classList?.contains('close') === true;
active?.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape', bubbles: true, composed: true, cancelable: true,
}));
await c.updateComplete; await frame();
out.restoreFocus = !c._settingsDialog && deepActive() === opener;
opener.remove();
const esc = async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await c.updateComplete; };
// общие настройки
c._openSettingsDialog(); await c.updateComplete;
+1 -1
View File
@@ -50,7 +50,7 @@ const res = await page.evaluate(async () => {
// 5) превью есть и в диалоге пространства
c._openSpaceDialog('edit', spId); await c.updateComplete;
out.spacePreview = !!sr().querySelector('.cardpreview');
out.spaceSlider = [...sr().querySelectorAll('.dialog label')].some((l) => l.textContent === c._t('space.card_font'));
out.spaceSlider = [...sr().querySelectorAll('hp-dialog label')].some((l) => l.textContent === c._t('space.card_font'));
c._spaceDialog = null; await c.updateComplete;
return out;
});
+3 -3
View File
@@ -8,10 +8,10 @@ const res = await page.evaluate(async () => {
// 1) диалог общих настроек: инвентарь строк и групп (обновлён фичей «Солнце»)
c._openSettingsDialog(); await c.updateComplete;
out.rows = sr().querySelectorAll('.gsrow').length;
out.groups = [...sr().querySelectorAll('.dialog .dispsection')].map((l) => l.textContent.trim());
out.groups = [...sr().querySelectorAll('hp-dialog .dispsection')].map((l) => l.textContent.trim());
// 1b) блок About: версия + две внешние ссылки (target=_blank rel=noopener)
out.aboutVersion = sr().querySelector('.dialog .aboutver')?.textContent.trim() ?? null;
out.aboutLinks = [...sr().querySelectorAll('.dialog a.aboutlink')].map((a) => ({
out.aboutVersion = sr().querySelector('hp-dialog .aboutver')?.textContent.trim() ?? null;
out.aboutLinks = [...sr().querySelectorAll('hp-dialog a.aboutlink')].map((a) => ({
href: a.getAttribute('href'), target: a.getAttribute('target'), rel: a.getAttribute('rel') }));
// 2) сменить цвет light_on и сохранить
c._setFillColor('light_on', { c: '#ff00ff', a: 0.5 });
+5 -5
View File
@@ -171,11 +171,11 @@ const out = await page.evaluate(async () => {
c._openAlignDialog();
await c.updateComplete;
const dlg = c._alignDialog;
o.alignDialogOpens = !!dlg && !!sr().querySelector('.dialogwrap .alignmsg');
o.alignDialogOpens = !!dlg && !!sr().querySelector('hp-dialog .alignmsg');
o.alignCountsWhatMoves = !!dlg && dlg.report.moved > 0 && dlg.report.moved <= dlg.report.total;
// half a step of 5 cm cells ≈ 2.5 cm; a third of a step is well under that
o.alignPromisesASmallShift = !!dlg && dlg.cm > 0 && dlg.cm < 3;
o.alignExplainsSafeUndo = (sr().querySelector('.dialogwrap .rhint')?.textContent || '').length > 20;
o.alignExplainsSafeUndo = (sr().querySelector('hp-dialog .rhint')?.textContent || '').length > 20;
// capture what the network is told, then run it
const sent = [];
@@ -204,7 +204,7 @@ const out = await page.evaluate(async () => {
c._openAlignDialog();
await c.updateComplete;
o.secondRunMovesNothing = c._alignDialog.report.moved === 0;
o.secondRunOffersNoButton = !sr().querySelector('.dialogwrap .btn.on');
o.secondRunOffersNoButton = !sr().querySelector('hp-dialog .btn.on');
c._alignDialog = null;
await c.updateComplete;
@@ -228,7 +228,7 @@ const out = await page.evaluate(async () => {
const md = c._alignDialog;
o.alignPromiseUsesTheOwnScaleOfEachSpace = !!md && md.cm >= 50 && md.cm < 51;
o.alignPromiseNamesTheSpaceItBelongsTo = !!md && md.where === 'Attic'
&& (sr().querySelector('.dialogwrap .body')?.textContent || '').includes('Attic');
&& (sr().querySelector('hp-dialog .body')?.textContent || '').includes('Attic');
c._alignDialog = null; await c.updateComplete;
// ---- 2d) an angle-only opening fix is offerable (AUD-158B1-02) --------
@@ -246,7 +246,7 @@ const out = await page.evaluate(async () => {
c._openAlignDialog(); await c.updateComplete;
const ad = c._alignDialog;
o.angleOnlyOpeningCounts = !!ad && ad.report.moved === 1 && ad.report.rotated === 1;
o.angleOnlyOpeningOffersTheButton = !!sr().querySelector('.dialogwrap .btn.on');
o.angleOnlyOpeningOffersTheButton = !!sr().querySelector('hp-dialog .btn.on');
await c._runAlignToGrid();
await c.updateComplete;
o.angleOnlyOpeningIsActuallyFixed = c._serverCfg.spaces[0].openings[0].angle === 0;
+11 -11
View File
@@ -14,9 +14,9 @@ const res = await page.evaluate(async () => {
// --- 1) fallback branch: no ha-* registered -> classic inputs ----------
c._openMarkerDialog(c._devices[0]); await c.updateComplete;
out.fallbackCheckbox = !!sr().querySelector('.dialog .srcrow input[type=checkbox]');
out.fallbackRange = !!sr().querySelector('.dialog input[type=range]');
out.fallbackNoHaSwitch = !sr().querySelector('.dialog ha-switch');
out.fallbackCheckbox = !!sr().querySelector('hp-dialog .srcrow input[type=checkbox]');
out.fallbackRange = !!sr().querySelector('hp-dialog input[type=range]');
out.fallbackNoHaSwitch = !sr().querySelector('hp-dialog ha-switch');
c._markerDialog = null; await c.updateComplete;
// --- 2) register stubs mimicking the HA contract -----------------------
@@ -35,20 +35,20 @@ const res = await page.evaluate(async () => {
});
c._openMarkerDialog(c._devices[0]); await c.updateComplete;
const switches = [...sr().querySelectorAll('.dialog ha-switch')];
const switches = [...sr().querySelectorAll('hp-dialog ha-switch')];
out.haSwitchRendered = switches.length >= 2; // is_light + hide-from-plan at minimum
out.haNoPlainCheckbox = !sr().querySelector('.dialog .srcrow input[type=checkbox]');
const sliders = [...sr().querySelectorAll('.dialog ha-slider')];
out.haNoPlainCheckbox = !sr().querySelector('hp-dialog .srcrow input[type=checkbox]');
const sliders = [...sr().querySelectorAll('hp-dialog ha-slider')];
out.haSliderRendered = sliders.length >= 2; // size + angle
// --- 3) card -> control: state is pushed into the element --------------
c._markerDialog = { ...c._markerDialog, isLight: true, hideFromPlan: false };
await c.updateComplete;
out.downstreamChecked = [...sr().querySelectorAll('.dialog ha-switch')].some((el) => el.checked === true);
out.downstreamChecked = [...sr().querySelectorAll('hp-dialog ha-switch')].some((el) => el.checked === true);
// --- 4) control -> card: change with .checked lands in the dialog ------
const before = c._markerDialog.hideFromPlan;
const sw = [...sr().querySelectorAll('.dialog ha-switch')].find((el) => el.checked === before);
const sw = [...sr().querySelectorAll('hp-dialog ha-switch')].find((el) => el.checked === before);
sw.checked = !before;
sw.dispatchEvent(new Event('change', { bubbles: true }));
await c.updateComplete;
@@ -57,7 +57,7 @@ const res = await page.evaluate(async () => {
// --- 5) slider both ways ----------------------------------------------
const sizeBefore = c._markerDialog.size;
const sl = [...sr().querySelectorAll('.dialog ha-slider')].find((el) => Number(el.value) === Number(sizeBefore));
const sl = [...sr().querySelectorAll('hp-dialog ha-slider')].find((el) => Number(el.value) === Number(sizeBefore));
out.downstreamValue = !!sl;
sl.value = 2;
sl.dispatchEvent(new Event('input', { bubbles: true }));
@@ -73,8 +73,8 @@ const res = await page.evaluate(async () => {
// --- 6) general settings: opacity sliders take the ha branch too -------
c._openSettingsDialog(); await c.updateComplete;
out.gsHaSliders = sr().querySelectorAll('.dialog ha-slider').length >= 9;
const gsl = sr().querySelector('.dialog ha-slider');
out.gsHaSliders = sr().querySelectorAll('hp-dialog ha-slider').length >= 9;
const gsl = sr().querySelector('hp-dialog ha-slider');
gsl.value = 55;
gsl.dispatchEvent(new Event('input', { bubbles: true }));
await c.updateComplete;
+2 -2
View File
@@ -53,7 +53,7 @@ Object.assign(out, await page.evaluate(async () => {
const ghost = c._devices.find((x) => x.id === d.id);
c._openMarkerDialog(ghost); await c.updateComplete;
o.checkboxOn = c._markerDialog?.hideFromPlan === true;
o.noDeleteForAuto = !sr().querySelector('.dialog .btn.danger');
o.noDeleteForAuto = !sr().querySelector('hp-dialog .btn.danger');
// снимаем галку и сохраняем — маркер остаётся с hidden:false (анти-повтор)
c._markerDialog = { ...c._markerDialog, hideFromPlan: false };
await c._saveMarker(); await c.updateComplete;
@@ -68,7 +68,7 @@ Object.assign(out, await page.evaluate(async () => {
await c._saveMarker(); await c.updateComplete;
const virt = c._devices.find((x) => x.virtual);
c._openMarkerDialog(virt); await c.updateComplete;
o.deleteForVirtual = !!sr().querySelector('.dialog .btn.danger');
o.deleteForVirtual = !!sr().querySelector('hp-dialog .btn.danger');
c._markerDialog = null; c._setMode('view');
return o;
}));
+1 -1
View File
@@ -96,7 +96,7 @@ const res = await page.evaluate(async () => {
// ---- 5) the dialog carries both switches round trip --------------------
c._openSpaceDialog('edit', c._space);
await upd();
const rows = [...sr().querySelectorAll('.dialog .srcrow')].map((r) => r.textContent.trim());
const rows = [...sr().querySelectorAll('hp-dialog .srcrow')].map((r) => r.textContent.trim());
out.dialogOffersHideDecor = rows.some((t) => /декорат|decorative/i.test(t));
out.dialogOffersHideOpenings = rows.some((t) => /проём|проем|doors and windows/i.test(t));
out.dialogReadsOff = c._spaceDialog.hideDecor === false && c._spaceDialog.hideOpenings === false;
+1 -1
View File
@@ -17,7 +17,7 @@ const res = await page.evaluate(async () => {
out.hintIcon = hint?.querySelector('ha-icon')?.getAttribute('icon') === dev.icon;
// пикер (или фолбэк-инпут) получил placeholder
const picker = sr().querySelector('ha-icon-picker');
const inputs = [...sr().querySelectorAll('.dialog input')];
const inputs = [...sr().querySelectorAll('hp-dialog input')];
out.placeholderSet = picker
? picker.placeholder === dev.icon
: inputs.some((i) => i.placeholder === dev.icon);
+1 -1
View File
@@ -159,7 +159,7 @@ Object.assign(out, await page.evaluate(() => {
const sr = c.shadowRoot || c.renderRoot;
const hint = sr.querySelector('.farhint');
o.hintShown = !!hint && !!hint.querySelector('button');
o.hintIsNotAModal = !sr.querySelector('.dialogwrap');
o.hintIsNotAModal = !sr.querySelector('hp-dialog');
return o;
}));
+1 -1
View File
@@ -51,7 +51,7 @@ const res = await page.evaluate(async () => {
out.persisted = JSON.parse(localStorage.getItem('houseplan_card_kiosk_v1')).icon === 1;
// 7) попап настроек экрана открывается (прямой вызов; долгое нажатие проверено таймером)
c._kioskDialog = true; await c.updateComplete;
out.dialogRenders = !!sr().querySelector('.dialog input[type="range"]');
out.dialogRenders = !!sr().querySelector('hp-dialog input[type="range"]');
c._kioskDialog = false;
// 8) карусель: тик двигает пространство, пауза после касания работает
const cs = c._space;
+2 -2
View File
@@ -43,8 +43,8 @@ const res = await page.evaluate(async () => {
// 5) создание новой комнаты: в диалоге есть секция настроек
c._setMode('plan'); c._tool = 'draw'; await c.updateComplete;
c._resetRoomDialogFields(); c._roomDialog = true; c.requestUpdate(); await c.updateComplete;
out.createHasSection = [...sr().querySelectorAll('.dialog label')].some((l) => l.textContent === c._t('room.settings_section'));
out.createHasInherit = [...sr().querySelectorAll('.dialog .srcrow')].some((l) => l.textContent.trim() === c._t('fill.inherit'));
out.createHasSection = [...sr().querySelectorAll('hp-dialog label')].some((l) => l.textContent === c._t('room.settings_section'));
out.createHasInherit = [...sr().querySelectorAll('hp-dialog .srcrow')].some((l) => l.textContent.trim() === c._t('fill.inherit'));
c._roomDialogCancel(); await c.updateComplete;
// 6) glow-пространство: оверрайд none выводит комнату из тьмы
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
+2 -2
View File
@@ -35,7 +35,7 @@ const res = await page.evaluate(async () => {
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, source: 'file', planUrl: null, planFile: null };
await c.updateComplete;
out.saveBlockedWithoutPlan = !!sr().querySelector('.dialog .btn.on[disabled]');
out.saveBlockedWithoutPlan = !!sr().querySelector('hp-dialog .btn.on[disabled]');
// открываем список сохранённых
await c._toggleServerPlans();
@@ -54,7 +54,7 @@ const res = await page.evaluate(async () => {
await c.updateComplete;
out.picked = c._spaceDialog.planUrl === '/api/houseplan/content/plans/_/f1.aaa.png';
out.listClosed = !c._spaceDialog.pickSaved;
out.saveEnabledAfterPick = !sr().querySelector('.dialog .btn.on[disabled]');
out.saveEnabledAfterPick = !sr().querySelector('hp-dialog .btn.on[disabled]');
// выбранный в этом же диалоге удалить нельзя: сохранение записало бы ссылку
// на несуществующий файл (HP-1470-02)
+1 -1
View File
@@ -70,7 +70,7 @@ const out3 = await page.evaluate(async () => {
const dev = c._devices.find((d) => d.space === 'f1');
c._openMarkerDialog(dev); await c.updateComplete;
// строка «размер · угол»: два ползунка, второй — угол
const rows = [...sr().querySelectorAll('.dialog .colorrow')];
const rows = [...sr().querySelectorAll('hp-dialog .colorrow')];
const row = rows.find((r) => r.textContent.includes('°'));
const ctl = [...row.querySelectorAll('ha-slider, input[type=range]')];
const stepOf = (el) => Number(el.step ?? el.getAttribute('step'));
+1 -1
View File
@@ -39,7 +39,7 @@ const res = await page.evaluate(async () => {
c._openSpaceDialog('create'); await c.updateComplete;
c._spaceDialog = { ...c._spaceDialog, title: 'Attic', source: 'draw', orientation: 'square' };
await c.updateComplete;
out.saveEnabled = !sr().querySelector('.dialog .btn.on[disabled]');
out.saveEnabled = !sr().querySelector('hp-dialog .btn.on[disabled]');
await c._saveSpaceDialog(); await c.updateComplete;
const attic = c._serverCfg.spaces.find((s) => s.title === 'Attic');
out.atticSquare = attic?.aspect === undefined; // no per-space ratio any more
+2 -2
View File
@@ -183,8 +183,8 @@ const dlg = await page.evaluate(() => {
const sr = c.shadowRoot;
const out = {
compassDragSets180: c._settingsDialog?.northDeg === 180,
dialogHasModeSelect: !!sr.querySelector('.dialog select.areasel'),
dialogHasRaysToggle: [...sr.querySelectorAll('.dialog .srcrow input[type=checkbox]')].length > 0,
dialogHasModeSelect: !!sr.querySelector('hp-dialog select.areasel'),
dialogHasRaysToggle: [...sr.querySelectorAll('hp-dialog .srcrow input[type=checkbox]')].length > 0,
dialogHasWeatherList: sr.querySelectorAll('#hp-weather-list option').length > 0,
dialogNumberMatches: sr.querySelector('.suncol input[type=number]')?.value === '180',
};
+1 -1
View File
@@ -10,7 +10,7 @@ const res = await page.evaluate(async () => {
// берём заведомо не-световое устройство
const dev = c._devices.find((d) => !d.virtual && d.primary && !d.primary.startsWith('light.'));
c._openMarkerDialog(dev); await c.updateComplete;
const sel = [...sr().querySelectorAll('.dialog select')].find((s) =>
const sel = [...sr().querySelectorAll('hp-dialog select')].find((s) =>
[...s.options].some((o) => o.textContent === c._t('tap.toggle')));
out.threeOptions = sel && sel.options.length === 4; // + «Запустить…» (2026-07-29)
out.noAutoOption = sel && ![...sel.options].some((o) => o.value === '');
+4 -4
View File
@@ -22,13 +22,13 @@ const out = await page.evaluate(async () => {
// ВИДИМОСТЬ, не просто наличие в DOM: список — flex-item со скроллом и
// однажды схлопнулся в полоску 1px при 26 кандидатах внутри (репорт
// пользователя 2026-07-30, поймано только замером высоты)
const listEl = () => sr().querySelector('.dialog .candlist');
const listEl = () => sr().querySelector('hp-dialog .candlist');
o.pickerShown = !!listEl() && listEl().getBoundingClientRect().height > 30;
c._markerDialog = { ...c._markerDialog, runFilter: 'штор' }; await c.updateComplete;
const cands = [...sr().querySelectorAll('.dialog .cand')].map((x) => x.textContent);
const cands = [...sr().querySelectorAll('hp-dialog .cand')].map((x) => x.textContent);
o.searchWorks = cands.length >= 1 && cands.some((t) => t.includes('Шторы'));
// и найденная строка реально нарисована, а не сплющена
const row = sr().querySelector('.dialog .cand');
const row = sr().querySelector('hp-dialog .cand');
o.resultRowVisible = !!row && row.getBoundingClientRect().height > 10
&& row.getBoundingClientRect().bottom <= listEl().getBoundingClientRect().bottom + 1;
// сохранение без цели блокируется
@@ -37,7 +37,7 @@ const out = await page.evaluate(async () => {
// выбираем цель + подтверждение, сохраняем
c._markerDialog = { ...c._markerDialog, tapTarget: 'script.curtains', tapConfirm: true };
await c.updateComplete;
o.confirmCheckboxShown = [...sr().querySelectorAll('.dialog .srcrow')].length >= 2;
o.confirmCheckboxShown = [...sr().querySelectorAll('hp-dialog .srcrow')].length >= 2;
await c._saveMarker(); await c.updateComplete;
const saved = (c._serverCfg.markers || []).find((m) => m.binding === 'device:' + d.bindingRef);
o.markerSaved = saved?.tap_action === 'run' && saved?.tap_target === 'script.curtains' && saved?.tap_confirm === true;
+6 -4
View File
@@ -30,8 +30,9 @@ const res = await page.evaluate(async () => {
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
out.fillRadios = sr().querySelectorAll('input[name="fillmode"]').length;
out.tempInputs = sr().querySelectorAll('.temprange .tempin').length;
out.dialogWide = !!sr().querySelector('.dialog.wide .srcrow');
out.dialogWidth = Math.round(sr().querySelector('.dialog').getBoundingClientRect().width);
const hpDialog = sr().querySelector('hp-dialog');
out.dialogWide = !!hpDialog?.hasAttribute('wide') && !!hpDialog.querySelector('.srcrow');
out.dialogWidth = Math.round(hpDialog.shadowRoot.querySelector('.surface').getBoundingClientRect().width);
// NaN-защита: пустой ввод не ломает границы
const before = c._spaceDialog.tempMax;
const fakeEv = { target: { value: '' } };
@@ -44,13 +45,14 @@ const res = await page.evaluate(async () => {
// '/tmp/...' указывает в несуществующий C:\tmp и смоук падал, не дойдя до
// ассертов (портируемость, ревью 2026-07-27)
await page.screenshot({ path: join(tmpdir(), 'houseplan_ux_dialog.png') }).catch(() => {});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
// Geometry values were re-baselined for the shared border-box hp-dialog in
// v1.59.2; the usable wide-dialog width remains 500 px.
checkAll(res, {
"filledClass": 1,
"unfilled": 3,
"tipTemp": 22.4,
"fillRadios": 5,
"tempInputs": 2,
"dialogWidth": 502,
"dialogWidth": 500,
});
await finish(browser, res);
File diff suppressed because one or more lines are too long
+288 -207
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -9,6 +9,7 @@ that contains both the backend (`custom_components/houseplan`) and the Lovelace
houseplan-card/
├─ src/ # card sources (TypeScript + Lit 3)
│ ├─ houseplan-card.ts # the card: rendering, states, drag, tooltip, sticky header
│ ├─ hp-dialog.ts # shared HA/native modal shell and focus lifecycle
│ ├─ editor.ts # GUI config editor (ha-form + selectors)
│ ├─ rules.ts # icon rules (iconFor), filtering, groups, fallback order
│ └─ data/
@@ -40,6 +41,12 @@ houseplan-card/
`hass.devices/entities/areas` (registries). No direct WS connections.
4. **Reactivity.** Every state change in HA leads to set hass → re-render.
Temperatures/LQI/on-off are live by definition (verified by substituting state).
5. **One modal contract.** Card modals render through `hp-dialog`. In Home
Assistant it delegates surface semantics and trapping to `ha-dialog`; the
standalone demo falls back to native `<dialog>`. The wrapper owns the title,
initial focus, Escape close event and restore-focus session. Focus sessions
are scoped to a card shadow root so nested dialogs return to their parent
trigger and dialog replacement still returns to the original outside opener.
## Coordinate system
+9
View File
@@ -2,6 +2,15 @@
## Unreleased (dev)
## v1.59.2 — 2026-08-07
- **A11Y-02: one accessible dialog shell.** Every House Plan modal now uses
`hp-dialog`: Home Assistant supplies the modal surface and focus trap through
`ha-dialog`, while the standalone demo uses a native `<dialog>` fallback.
Dialogs have an announced title, deterministic initial focus, Escape
handling, contained Tab navigation and focus restoration to the control that
opened the modal, including nested and dialog-to-dialog flows.
## v1.59.1 — 2026-08-06
This maintenance release makes device status and every light-related view use
+10
View File
@@ -8,6 +8,16 @@
## Не выпущено (dev)
## v1.59.2 — 2026-08-07
- **A11Y-02: единая доступная оболочка диалогов.** Все модальные окна House Plan
переведены на `hp-dialog`: внутри Home Assistant используются штатная
модальная поверхность и focus trap `ha-dialog`, а автономный demo-режим
получает fallback на нативном `<dialog>`. У каждого окна теперь есть
объявляемый скринридеру заголовок, предсказуемый начальный фокус, закрытие по
`Esc`, удержание Tab-фокуса внутри и возврат фокуса на открывший элемент,
включая вложенные окна и переход из одного диалога в другой.
## v1.59.1 — 2026-08-06
Технический релиз сводит состояние устройств и все световые представления к
+1 -1
View File
@@ -87,7 +87,7 @@ House Plan уже вышел за рамки «карточки с картин
| ID | Приоритет | Наблюдение | Пользовательский эффект | Рекомендация |
|---|---|---|---|---|
| A11Y-01 | P1 | Настройка пространства и закрытие редактора — отдельные click-target внутри основной кнопки вкладки | Непредсказуемая семантика клавиатуры/скринридера, сложные маленькие цели | Делать соседние `<button>` с собственным `aria-label`, не click-handler на вложенном `ha-icon` |
| A11Y-02 | P1 | Диалоги — обычные `div` без явного `role=dialog`, `aria-modal`, focus trap и возврата фокуса | Клавиатура и скринридер не понимают границы диалога | Общий компонент `hp-dialog` на базе HA/MWC диалога с заголовком, trap, initial focus и restore focus |
| A11Y-02 | P1 · выполнено | Все модальные окна переведены на общий `hp-dialog` | Клавиатура и скринридер получают настоящую модальную границу и заголовок | HA `ha-dialog` + нативный fallback; реализованы trap, initial focus, `Esc` и restore focus, в том числе для вложенных и сменяющих друг друга окон |
| A11Y-03 | P1 | Цвет — основной канал для working/open/alarm/LQI | Пользователи с нарушением цветоощущения теряют смысл | Добавить формы/мини-бейджи: `!`, play, open; текстовые статусы в карточке и доступные `aria-label` |
| A11Y-04 | P2 | Часть toolbar-текста исчезает на узкой ширине, остаются title-tooltip и иконка | Title недоступен на touch и плохо работает с клавиатурой | `aria-label`, долгое нажатие/Help overlay, адаптивное overflow-меню вместо безымянных иконок |
| A11Y-05 | P2 | Киоск-настройка открывается скрытым удержанием 3 секунды | Функция почти не обнаруживается | Однократный onboarding/индикатор в углу и документируемая кнопка в конфигурации карточки |
+1 -1
View File
@@ -1,6 +1,6 @@
# Документация House Plan
Этот каталог — новая точка входа в русскоязычную документацию House Plan. Материалы ниже сверены с исходным кодом и интерфейсом версии **v1.59.1**.
Этот каталог — новая точка входа в русскоязычную документацию House Plan. Материалы ниже сверены с исходным кодом и интерфейсом версии **v1.59.2**.
| Документ | Для кого | Что внутри |
|---|---|---|
+7 -3
View File
@@ -11,15 +11,15 @@
> (versions, publication, infrastructure), DEVELOPMENT.md for new gotchas,
> ARCHITECTURE.md for design changes, ROADMAP.md when plans move.
## Snapshot (2026-08-06)
## Snapshot (2026-08-07)
| Item | State |
|---|---|
| Version | **v1.59.1** everywhere (manifest, const.py, package.json, CARD_VERSION) — stable maintenance release. Tag `v1.59.1`, `main` and `dev` point to the same tested release commit; GitHub Release uses `prerelease=false`. Previous pre-release: v1.59.0-rc.2; previous stable: v1.59.0 |
| Version | **v1.59.2** everywhere (manifest, const.py, package.json, CARD_VERSION) — stable accessibility maintenance release. Tag `v1.59.2`, `main` and `dev` point to the same tested release commit; GitHub Release uses `prerelease=false`. Previous pre-release: v1.59.0-rc.2; previous stable: v1.59.1 |
| Workflow | Owner's rule since 2026-08-05: ordinary fixes/features are made **locally, without tests and without commits**. A requested pre-release gets the full tests/build/smokes, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. A requested stable release gets the same full gate, then `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Nothing is copied to the home instance by hand |
| GitHub | https://github.com/Matysh/houseplan-card — `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| CI | v1.59.1 passes the full local release gate; exact counts are recorded by `npm run inventory` and the test logs rather than copied here. Native Windows cannot start the current HA pytest plugin (`fcntl` is Unix-only), so the exact-SHA Ubuntu Validate remains mandatory: `release.yml` withholds the asset until every matching run finishes green; GitHub Validate and the release asset are verified after the tag is pushed |
| CI | v1.59.2 passes the full local release gate; exact counts are recorded by `npm run inventory` and the test logs rather than copied here. Native Windows cannot start the current HA pytest plugin (`fcntl` is Unix-only), so the exact-SHA Ubuntu Validate remains mandatory: `release.yml` withholds the asset until every matching run finishes green; GitHub Validate and the release asset are verified after the tag is pushed |
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled, mergeable clean, never drafted. Queue: 1212 open, 835 older than ours. Merge rate COLLAPSED: 75 in July but almost all in the first decade, 0 in the last week (checked 2026-07-29) — maintainers process in rare bursts; ETA unknowable, months at best. Nothing actionable on our side |
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), last direct copy was **v1.57.0**; from v1.58.0 on it updates itself through HACS by tag (no scp) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
@@ -98,6 +98,10 @@
Glow, Light fill, room statistics, marker feedback and controls. Glow uses
0.7 opacity, and compacted real walls retain their correct inner face and
body at real/virtual T-junctions.
- **v1.59.2** (2026-08-07): every modal uses the shared `hp-dialog` shell,
backed by Home Assistant's `ha-dialog` with a native demo fallback. Titles,
modal semantics, initial focus, focus trapping, Escape and restore focus are
consistent across all editors, nested dialogs and dialog replacement flows.
## Recent milestones (details in CHANGELOG.md)
+6 -1
View File
@@ -1,6 +1,6 @@
# House Plan — полное руководство пользователя
Актуально для **v1.59.1**. Руководство заново составлено по исходному коду и фактическому интерфейсу этой версии. Старые README и тематические спецификации использовались только для перепроверки сценариев.
Актуально для **v1.59.2**. Руководство заново составлено по исходному коду и фактическому интерфейсу этой версии. Старые README и тематические спецификации использовались только для перепроверки сценариев.
House Plan — локальная интеграция и две Lovelace-карточки для Home Assistant:
@@ -185,6 +185,11 @@ title: План дома
Общий стек редактора плана хранит 50 операций. В панель также добавлены кнопки **Отменить** и **Повторить**; их подсказка показывает имя следующего шага. История живёт в текущей сессии карточки и безопасно сбрасывается, если с сервера приходит более новая внешняя ревизия плана.
Во всех диалогах `Tab` и `Shift+Tab` перемещают фокус только внутри открытого
окна, `Esc` закрывает верхнее окно, а после закрытия фокус возвращается на
кнопку или объект, которыми диалог был открыт. При открытии вложенного окна
возврат сначала происходит в родительский диалог.
## 7. Пространства
Пространство подходит не только для этажа: это может быть двор, гараж, баня или отдельное строение.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.59.1",
"version": "1.59.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.59.1",
"version": "1.59.2",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.59.1",
"version": "1.59.2",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+81 -102
View File
@@ -7,6 +7,7 @@
* The icon layout is stored on the server (houseplan/layout/*), fallback — localStorage.
*/
import { LitElement, html, svg, nothing, TemplateResult, PropertyValues } from 'lit';
import './hp-dialog';
import {
EXCLUDED_DOMAINS, DEFAULT_ICON_RULES, compileIconRules, isValidPattern, iconFor,
type IconRule, type CompiledIconRule,
@@ -97,7 +98,7 @@ import {
type DeviceActivity, type DeviceVisualState, type EntityVisualSample,
} from './device-visual';
const CARD_VERSION = '1.59.1';
const CARD_VERSION = '1.59.2';
/** HP-1552 boot-veil timing (AUD-1552-02). The veil holds for at least
* BOOT_MIN_MS; every stage-height change restarts a BOOT_QUIET_MS
* trailing-quiescence requirement (chrome still settling near the cap
@@ -5165,9 +5166,8 @@ class HouseplanCard extends LitElement {
const d = this._decorTextDialog!;
const ent = (d.pickerEntity || '').trim();
const st = ent ? this.hass?.states?.[ent] : null;
return html`<div class="menuwrap dialogwrap" @click=${() => (this._decorTextDialog = null)}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:format-text"></ha-icon>${this._t('decor.text_title')}</div>
return html`<hp-dialog .hass=${this.hass} .title=${this._t('decor.text_title')}
icon="mdi:format-text" dismiss-on-scrim @hp-close=${() => (this._decorTextDialog = null)}>
<div class="body">
<label>${this._t('decor.text_label')}</label>
${''/* a textarea, not an input: the user's own line breaks are kept
@@ -5215,22 +5215,21 @@ class HouseplanCard extends LitElement {
</select>
` : nothing}
</div>
<div class="row">
<div class="row" slot="footer">
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._decorTextDialog = null)}>${this._t('btn.cancel')}</button>
<button class="btn primary" ?disabled=${!d.text.trim()} @click=${() => this._decorSaveText()}>${this._t('btn.save')}</button>
</div>
</div>
</div>`;
</hp-dialog>`;
}
private _renderDecorShapeDialog(): TemplateResult {
const d = this._decorShapeDialog!;
const canFill = d.kind === 'rect' || d.kind === 'ellipse';
const kindLabel = this._t(('decor.' + d.kind) as any);
return html`<div class="menuwrap dialogwrap" @click=${() => (this._decorShapeDialog = null)}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:pencil-outline"></ha-icon>${this._t('decor.object_title', { kind: kindLabel })}</div>
return html`<hp-dialog .hass=${this.hass}
.title=${this._t('decor.object_title', { kind: kindLabel })} icon="mdi:pencil-outline"
dismiss-on-scrim @hp-close=${() => (this._decorShapeDialog = null)}>
<div class="body">
<label>${this._t('decor.color')}</label>
<input type="color" .value=${d.color}
@@ -5247,13 +5246,12 @@ class HouseplanCard extends LitElement {
...d, fill: (e.target as HTMLInputElement).checked,
})} />${this._t('decor.fill')}</label>` : nothing}
</div>
<div class="row">
<div class="row" slot="footer">
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._decorShapeDialog = null)}>${this._t('btn.cancel')}</button>
<button class="btn primary" @click=${() => this._decorSaveShape()}>${this._t('btn.save')}</button>
</div>
</div>
</div>`;
</hp-dialog>`;
}
/** Boundary under the cursor in the open/close-boundary tools. */
@@ -7291,9 +7289,8 @@ class HouseplanCard extends LitElement {
private _renderImportDialog(): TemplateResult {
const d = this._importDialog!;
const n = d.floors.filter((f) => f.checked).length;
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:home-floor-1"></ha-icon>${this._t('import.title')}</div>
return html`<hp-dialog .hass=${this.hass} .title=${this._t('import.title')} icon="mdi:home-floor-1"
@hp-close=${() => (this._importDialog = null)}>
<div class="body">
<div class="rhint">${this._t('import.hint')}</div>
${d.floors.map(
@@ -7309,7 +7306,7 @@ class HouseplanCard extends LitElement {
</label>`,
)}
</div>
<div class="row">
<div class="row" slot="footer">
<button class="btn ghost" @click=${() => { this._importDialog = null; this._openSpaceDialog('create'); }}>
${this._t('import.manual')}
</button>
@@ -7318,8 +7315,7 @@ class HouseplanCard extends LitElement {
<ha-icon icon="mdi:import"></ha-icon>${this._t('import.start', { n })}
</button>
</div>
</div>
</div>`;
</hp-dialog>`;
}
// ================= SUN ON THE PLAN (docs/SUN.md) =================
@@ -7969,9 +7965,8 @@ class HouseplanCard extends LitElement {
private _renderAlignDialog(): TemplateResult {
const d = this._alignDialog!;
const r = d.report;
return html`<div class="menuwrap dialogwrap" @click=${() => (this._alignDialog = null)}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:broom"></ha-icon>${this._t('gs.align_title')}</div>
return html`<hp-dialog .hass=${this.hass} .title=${this._t('gs.align_title')} icon="mdi:broom"
dismiss-on-scrim @hp-close=${() => (this._alignDialog = null)}>
<div class="body">
${!d.changed
? html`<p class="alignmsg">${this._t('gs.align_none')}</p>`
@@ -7991,7 +7986,7 @@ class HouseplanCard extends LitElement {
})}</p>
<div class="rhint">${this._t('gs.align_warn')}</div>`}
</div>
<div class="row">
<div class="row" slot="footer">
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._alignDialog = null)}>${this._t('btn.cancel')}</button>
${!d.changed ? nothing : html`
@@ -7999,14 +7994,12 @@ class HouseplanCard extends LitElement {
<ha-icon icon="mdi:check"></ha-icon>${d.busy ? '…' : this._t('gs.align_run')}
</button>`}
</div>
</div>
</div>`;
</hp-dialog>`;
}
private _renderSettingsDialog(): TemplateResult {
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog wide" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:cog-outline"></ha-icon>${this._t('gs.title')}</div>
return html`<hp-dialog .hass=${this.hass} .title=${this._t('gs.title')} icon="mdi:cog-outline" wide
@hp-close=${() => (this._settingsDialog = null)}>
<div class="body">
<div class="rhint">${this._t('gs.hint')}</div>
<label class="dispsection">${this._t('gs.light_group')}</label>
@@ -8125,7 +8118,7 @@ class HouseplanCard extends LitElement {
<a class="aboutlink" href="https://t.me/ha_houseplan" target="_blank" rel="noopener">
<ha-icon icon="mdi:send"></ha-icon>${this._t('gs.about_telegram')}</a>
</div>
<div class="row">
<div class="row" slot="footer">
<button class="btn ghost" @click=${() =>
(this._settingsDialog = { ...this._settingsDialog!, colors: JSON.parse(JSON.stringify(DEFAULT_FILL_COLORS)), glowRadius: this._imperial ? 9.8 : 3, bgColor: null, northDeg: null, bgMode: 'static', sunRays: false, weatherEntity: '' })}>
${this._t('gs.reset')}
@@ -8136,8 +8129,7 @@ class HouseplanCard extends LitElement {
<ha-icon icon="mdi:check"></ha-icon>${this._settingsDialog!.busy ? '…' : this._t('btn.save')}
</button>
</div>
</div>
</div>`;
</hp-dialog>`;
}
// ================= ICON RULES EDITOR =================
@@ -8191,9 +8183,8 @@ class HouseplanCard extends LitElement {
[r[i], r[j]] = [r[j], r[i]];
this._rulesSet(r);
};
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog wide" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:shape-plus-outline"></ha-icon>${this._t('rules.title')}</div>
return html`<hp-dialog .hass=${this.hass} .title=${this._t('rules.title')}
icon="mdi:shape-plus-outline" wide @hp-close=${() => (this._rulesDialog = null)}>
<div class="body">
<div class="rhint">${this._t('rules.hint')}</div>
<div class="rtest">
@@ -8234,7 +8225,7 @@ class HouseplanCard extends LitElement {
<ha-icon icon="mdi:plus"></ha-icon>${this._t('rules.add')}
</button>
</div>
<div class="row">
<div class="row" slot="footer">
<button class="btn ghost" @click=${() => this._rulesSet(DEFAULT_ICON_RULES.map((r) => ({ ...r })))}>
${this._t('rules.reset')}
</button>
@@ -8244,8 +8235,7 @@ class HouseplanCard extends LitElement {
<ha-icon icon="mdi:check"></ha-icon>${d.busy ? '…' : this._t('btn.save')}
</button>
</div>
</div>
</div>`;
</hp-dialog>`;
}
private _saveKioskScale(patch: Partial<{ icon: number; font: number }>): void {
@@ -8266,21 +8256,19 @@ class HouseplanCard extends LitElement {
${this._rangeInput(50, 300, 5, Math.round(k[key] * 100), (n) => this._saveKioskScale({ [key]: n / 100 }))}
<span class="opv">${Math.round(k[key] * 100)}%</span>
</div>`;
return html`<div class="menuwrap dialogwrap" @click=${() => (this._kioskDialog = false)}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:tablet"></ha-icon>${this._t('kiosk.title')}</div>
return html`<hp-dialog .hass=${this.hass} .title=${this._t('kiosk.title')} icon="mdi:tablet"
dismiss-on-scrim @hp-close=${() => (this._kioskDialog = false)}>
<div class="body">
<div class="rhint">${this._t('kiosk.hint')}</div>
${row('icon', this._t('kiosk.icon_scale'))}
${row('font', this._t('kiosk.font_scale'))}
</div>
<div class="row">
<div class="row" slot="footer">
<button class="btn ghost" @click=${() => this._saveKioskScale({ icon: 1, font: 1 })}>${this._t('gs.reset')}</button>
<span class="spacer"></span>
<button class="btn on" @click=${() => (this._kioskDialog = false)}>${this._t('btn.close')}</button>
</div>
</div>
</div>`;
</hp-dialog>`;
}
// ================= render =================
@@ -8731,18 +8719,17 @@ class HouseplanCard extends LitElement {
<button class="btn ghostbtn" @click=${() => { this._vacFit = null; }}>${this._t('btn.cancel')}</button>
</div>` : nothing}
${this._tapConfirm
? html`<div class="menuwrap dialogwrap" @click=${() => (this._tapConfirm = null)}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
? html`<hp-dialog .hass=${this.hass} .title=${this._t('btn.run')} icon="mdi:alert-outline"
dismiss-on-scrim @hp-close=${() => (this._tapConfirm = null)}>
<div class="body"><p>${this._tapConfirm.text}</p></div>
<div class="row">
<div class="row" slot="footer">
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._tapConfirm = null)}>${this._t('btn.cancel')}</button>
<button class="btn on" @click=${() => { const c = this._tapConfirm!; this._tapConfirm = null; c.exec(); }}>
<ha-icon icon="mdi:check"></ha-icon>${this._t('btn.run')}
</button>
</div>
</div>
</div>`
</hp-dialog>`
: nothing}
${this._toast ? html`<div class="toast">${this._toast}</div>` : nothing}
</ha-card>
@@ -10108,10 +10095,10 @@ class HouseplanCard extends LitElement {
const lSt = o.lock ? this.hass.states[o.lock]?.state : null;
const row = (icon: string, label: string, value: string, cls = '') =>
html`<div class="oprow ${cls}"><ha-icon icon=${icon}></ha-icon><span>${label}</span><b>${value}</b></div>`;
return html`<div class="menuwrap dialogwrap" @click=${() => (this._openingInfo = null)}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon=${o.type === 'door' ? 'mdi:door' : 'mdi:window-closed-variant'}></ha-icon>
${this._t(o.type === 'door' ? 'opening.door' : 'opening.window')}</div>
return html`<hp-dialog .hass=${this.hass}
.title=${this._t(o.type === 'door' ? 'opening.door' : 'opening.window')}
icon=${o.type === 'door' ? 'mdi:door' : 'mdi:window-closed-variant'} dismiss-on-scrim
@hp-close=${() => (this._openingInfo = null)}>
<div class="body">
${o.contact
? row(amt > 0 ? 'mdi:door-open' : 'mdi:door-closed',
@@ -10143,12 +10130,11 @@ class HouseplanCard extends LitElement {
: nothing}
${!o.contact && !o.lock ? html`<p class="muted">${this._t('opening.no_entities')}</p>` : nothing}
</div>
<div class="row">
<div class="row" slot="footer">
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._openingInfo = null)}>${this._t('btn.close')}</button>
</div>
</div>
</div>`;
</hp-dialog>`;
}
private _renderOpeningDialog(): TemplateResult {
@@ -10158,10 +10144,9 @@ class HouseplanCard extends LitElement {
<option value="" ?selected=${!cur}>${this._t('opening.none')}</option>
${list.map((c) => html`<option value=${c.value} ?selected=${c.value === cur}>${c.label}</option>`)}
</select>`;
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:door"></ha-icon>
${d.id ? this._t('opening.edit') : this._t('opening.new')}</div>
return html`<hp-dialog .hass=${this.hass}
.title=${d.id ? this._t('opening.edit') : this._t('opening.new')} icon="mdi:door"
@hp-close=${() => (this._openingDialog = null)}>
<div class="body">
<label>${this._t('opening.type_label')}</label>
<label class="srcrow"><input type="radio" name="optype" .checked=${d.type === 'door'}
@@ -10195,7 +10180,7 @@ class HouseplanCard extends LitElement {
<label class="srcrow">${this._boolInput(d.flipV, (v) => (this._openingDialog = { ...d, flipV: v }))}
<span>${this._t('opening.flip_v')}</span></label>
</div>
<div class="row">
<div class="row" slot="footer">
${d.id
? html`<button class="btn danger" @click=${this._deleteOpening}>
<ha-icon icon="mdi:delete-outline"></ha-icon>${this._t('btn.delete')}
@@ -10207,8 +10192,7 @@ class HouseplanCard extends LitElement {
<ha-icon icon="mdi:check"></ha-icon>${this._t('btn.save')}
</button>
</div>
</div>
</div>`;
</hp-dialog>`;
}
/** Adaptive grid density for the current view (docs/CANVAS.md §7):
@@ -10459,9 +10443,8 @@ class HouseplanCard extends LitElement {
const st = d.primary ? this.hass.states[d.primary] : undefined;
const stateTxt = st ? hassValue(this.hass, d.primary)?.text ?? st.state : null;
const controls = (d.marker?.controls || []).filter(isControllable);
return html`<div class="menuwrap dialogwrap" @click=${() => (this._infoCard = null)}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="${d.icon}"></ha-icon>${d.name}</div>
return html`<hp-dialog .hass=${this.hass} .title=${d.name} .icon=${d.icon}
dismiss-on-scrim @hp-close=${() => (this._infoCard = null)}>
<div class="body">
${(() => {
// Field feedback: on a wall tablet this card is for CONTROLLING the
@@ -10523,7 +10506,7 @@ class HouseplanCard extends LitElement {
? html`<div class="infodesc muted">${this._t('info.none')}</div>`
: nothing}
</div>
<div class="row">
<div class="row" slot="footer">
<button class="btn" @click=${() => { const dd = d; this._infoCard = null; this._openMarkerDialog(dd); }}>
<ha-icon icon="mdi:pencil"></ha-icon>${this._t('btn.edit')}
</button>
@@ -10535,8 +10518,7 @@ class HouseplanCard extends LitElement {
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._infoCard = null)}>${this._t('btn.close')}</button>
</div>
</div>
</div>`;
</hp-dialog>`;
}
private _renderMarkerDialog(): TemplateResult {
@@ -10551,10 +10533,9 @@ class HouseplanCard extends LitElement {
if (k === 'device') return this.hass.devices[ref]?.name_by_user || this.hass.devices[ref]?.name || ref;
return this.hass.states[ref]?.attributes?.friendly_name || ref;
})();
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog wide" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:shape-plus"></ha-icon>
${d.devId ? this._t('info.device_header') : this._t('marker.new_device')}</div>
return html`<hp-dialog .hass=${this.hass}
.title=${d.devId ? this._t('info.device_header') : this._t('marker.new_device')}
icon="mdi:shape-plus" wide @hp-close=${() => (this._markerDialog = null)}>
<div class="body">
<label>${this._t('marker.name_label')}</label>
<input class="namein" type="text" placeholder=${this._t('marker.name_ph')}
@@ -10793,7 +10774,7 @@ class HouseplanCard extends LitElement {
</label>
</div>
</div>
<div class="row markerfooter">
<div class="row markerfooter" slot="footer">
<div class="markeractions">
${d.devId
? html`<button class="btn" type="button"
@@ -10819,22 +10800,25 @@ class HouseplanCard extends LitElement {
</button>
</div>
</div>
</div>
</div>`;
</hp-dialog>`;
}
private _renderSpaceDialog(): TemplateResult {
const d = this._spaceDialog!;
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog wide" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:floor-plan"></ha-icon>
${d.mode === 'create' ? this._t('space.new') : this._t('space.header')}
${this._importTotal > 0 && d.mode === 'create'
? html`<span class="importprog">${this._t('import.progress', {
i: this._importTotal - this._importQueue.length,
n: this._importTotal,
})}</span>`
: nothing}</div>
const progress = this._importTotal > 0 && d.mode === 'create'
? this._t('import.progress', {
i: this._importTotal - this._importQueue.length,
n: this._importTotal,
})
: '';
const close = () => {
this._spaceDialog = null;
this._importQueue = [];
this._importTotal = 0;
};
return html`<hp-dialog .hass=${this.hass}
.title=${`${d.mode === 'create' ? this._t('space.new') : this._t('space.header')}${progress ? ` · ${progress}` : ''}`}
icon="mdi:floor-plan" wide @hp-close=${close}>
<div class="body">
<label>${this._t('space.title_label')}</label>
<input class="namein" type="text" placeholder=${this._t('space.title_ph')}
@@ -11002,7 +10986,7 @@ class HouseplanCard extends LitElement {
</label>`,
)}
</div>
<div class="row">
<div class="row" slot="footer">
${d.mode === 'edit'
? html`<button class="btn danger" @click=${this._deleteSpace}>
<ha-icon icon="mdi:delete-outline"></ha-icon>${this._t('btn.delete')}
@@ -11012,15 +10996,14 @@ class HouseplanCard extends LitElement {
${this._importTotal > 0 && d.mode === 'create'
? html`<button class="btn ghost" @click=${() => this._skipImport()}>${this._t('btn.skip')}</button>`
: nothing}
<button class="btn ghost" @click=${() => { this._spaceDialog = null; this._importQueue = []; this._importTotal = 0; }}>${this._t('btn.cancel')}</button>
<button class="btn ghost" @click=${close}>${this._t('btn.cancel')}</button>
<button class="btn on" @click=${this._saveSpaceDialog}
?disabled=${!d.title.trim() || (d.source === 'file' && !(d.planFile || d.planUrl)) || d.busy}
title=${d.source === 'file' && !(d.planFile || d.planUrl) ? this._t('title.need_plan') : ''}>
<ha-icon icon="mdi:check"></ha-icon>${d.busy ? '…' : this._t('btn.save')}
</button>
</div>
</div>
</div>`;
</hp-dialog>`;
}
private _renderMergeDialog(): TemplateResult {
@@ -11035,24 +11018,22 @@ class HouseplanCard extends LitElement {
<span>${r?.name || ''} <span class="muted">· ${area || this._t('merge.no_area')}</span></span>
</label>`;
};
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="mdi:vector-union"></ha-icon>${this._t('merge.header')}</div>
return html`<hp-dialog .hass=${this.hass} .title=${this._t('merge.header')} icon="mdi:vector-union"
@hp-close=${() => (this._mergeDialog = null)}>
<div class="body">
<p class="muted">${this._t('merge.hint')}</p>
<label>${this._t('merge.keep')}</label>
${opt(d.aId, 'a')}
${opt(d.bId, 'b')}
</div>
<div class="row">
<div class="row" slot="footer">
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._mergeDialog = null)}>${this._t('btn.cancel')}</button>
<button class="btn on" @click=${this._commitMerge}>
<ha-icon icon="mdi:check"></ha-icon>${this._t('btn.save')}
</button>
</div>
</div>
</div>`;
</hp-dialog>`;
}
/** Live sample of a room card at the given multipliers (dialog preview). */
@@ -11124,10 +11105,9 @@ class HouseplanCard extends LitElement {
const cur = this.hass.areas[this._areaSel];
if (cur) areas.unshift(cur);
}
return html`<div class="menuwrap dialogwrap" @click=${(e: Event) => e.stopPropagation()}>
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon=${edit ? 'mdi:cog-outline' : 'mdi:floor-plan'}></ha-icon>
${edit ? this._t('room.settings_title') : this._t('room.new')}</div>
return html`<hp-dialog .hass=${this.hass}
.title=${edit ? this._t('room.settings_title') : this._t('room.new')}
icon=${edit ? 'mdi:cog-outline' : 'mdi:floor-plan'} @hp-close=${this._roomDialogCancel}>
<div class="body">
<label>${this._t('room.name_label')}</label>
<input class="namein" type="text" placeholder=${this._t('room.name_ph')}
@@ -11176,7 +11156,7 @@ class HouseplanCard extends LitElement {
this._roomLabelScale,
)}
</div>
<div class="row">
<div class="row" slot="footer">
<button class="btn ghost" @click=${this._roomDialogCancel}>${this._t('btn.cancel')}</button>
<span class="spacer"></span>
${edit
@@ -11192,8 +11172,7 @@ class HouseplanCard extends LitElement {
<ha-icon icon="mdi:check"></ha-icon>${this._t('btn.save')}
</button>`}
</div>
</div>
</div>`;
</hp-dialog>`;
}
static styles = cardStyles;
+330
View File
@@ -0,0 +1,330 @@
import { LitElement, css, html, nothing, type PropertyValues } from 'lit';
type FocusSession = {
dialogs: Set<HpDialog>;
opener: HTMLElement | null;
};
// A card can replace one dialog with another in the same Lit update (for
// example, device info -> device editor) or open a child dialog above a parent.
// Keep the original opener per render root so those transitions do not lose it.
const focusSessions = new WeakMap<Node, FocusSession>();
let dialogSequence = 0;
/**
* Shared modal shell for Houseplan.
*
* Home Assistant provides the visual surface and focus trap through ha-dialog.
* The native dialog branch keeps the standalone demo usable without mocking HA
* frontend internals. Both branches expose the same `hp-close` contract and
* restore focus to the control that opened the dialog.
*/
export class HpDialog extends LitElement {
static properties = {
title: { type: String },
icon: { type: String },
wide: { type: Boolean, reflect: true },
dismissOnScrim: { type: Boolean, attribute: 'dismiss-on-scrim' },
hass: { attribute: false },
};
static styles = css`
:host {
display: contents;
color: var(--primary-text-color, #e6e7eb);
font: inherit;
}
ha-dialog {
--dialog-content-padding: 0;
--dialog-surface-background: var(--card-background-color, var(--hp-bg, #202126));
--ha-dialog-border-radius: var(--rad-l, 18px);
color: inherit;
}
ha-dialog::part(dialog) {
border: 1px solid var(--hp-accent, #d89300);
box-shadow: var(--shadow-3, 0 18px 48px rgb(0 0 0 / 0.34));
overflow: hidden;
}
.title {
display: inline-flex;
align-items: center;
gap: var(--sp-4, 12px);
min-width: 0;
font-weight: 600;
}
.title ha-icon {
flex: none;
color: var(--hp-accent, #d89300);
}
.footer {
display: contents;
}
dialog {
width: auto;
max-width: none;
max-height: none;
margin: auto;
padding: 0;
border: 0;
overflow: visible;
color: inherit;
background: transparent;
}
dialog::backdrop {
background: rgb(0 0 0 / 0.45);
}
.surface {
width: min(360px, 92vw);
max-height: 92vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-sizing: border-box;
color: inherit;
background: var(--card-background-color, var(--hp-bg, #202126));
border: 1px solid var(--hp-accent, #d89300);
border-radius: var(--rad-l, 18px);
box-shadow: var(--shadow-3, 0 18px 48px rgb(0 0 0 / 0.34));
}
:host([wide]) .surface {
width: min(500px, 94vw);
}
.header {
min-height: 56px;
display: flex;
align-items: center;
gap: var(--sp-4, 12px);
padding: var(--sp-4, 12px) var(--sp-5, 16px);
box-sizing: border-box;
border-bottom: 1px solid var(--hp-line, rgb(255 255 255 / 0.12));
}
.header .title {
flex: 1;
}
.close {
width: 40px;
height: 40px;
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
color: inherit;
background: transparent;
border: 0;
border-radius: 50%;
cursor: pointer;
}
.close:hover,
.close:focus-visible {
background: rgb(127 127 127 / 0.16);
}
.content {
min-height: 0;
display: flex;
flex-direction: column;
}
`;
title = '';
icon = '';
wide = false;
dismissOnScrim = false;
hass: any = null;
private _opener: HTMLElement | null = null;
private _focusRoot: Node | null = null;
private _useHaDialog = false;
private _closing = false;
private readonly _titleId = `hp-dialog-title-${++dialogSequence}`;
connectedCallback(): void {
super.connectedCallback();
this._opener = this._deepActiveElement();
const root = this.getRootNode();
this._focusRoot = root;
const current = focusSessions.get(root);
const session = current || { dialogs: new Set<HpDialog>(), opener: this._opener };
session.dialogs.add(this);
focusSessions.set(root, session);
this._useHaDialog = !!customElements.get('ha-dialog');
this.addEventListener('keydown', this._onKeyDown, true);
}
disconnectedCallback(): void {
this.removeEventListener('keydown', this._onKeyDown, true);
const root = this._focusRoot;
const opener = this._opener;
this._opener = null;
this._focusRoot = null;
const session = root ? focusSessions.get(root) : undefined;
session?.dialogs.delete(this);
super.disconnectedCallback();
if (!root || !session) return;
requestAnimationFrame(() => {
const latest = focusSessions.get(root);
if (!latest) return;
if (!latest.dialogs.size) {
if (latest.opener?.isConnected) latest.opener.focus({ preventScroll: true });
focusSessions.delete(root);
return;
}
// Closing a child dialog returns to its trigger inside the parent. A
// dialog-to-dialog replacement intentionally waits for the last dialog,
// then restores the original trigger outside the whole modal session.
const owner = opener?.closest('hp-dialog') as HpDialog | null;
if (opener?.isConnected && owner && latest.dialogs.has(owner)) {
opener.focus({ preventScroll: true });
}
});
}
protected firstUpdated(changed: PropertyValues): void {
super.firstUpdated(changed);
if (!this._useHaDialog) {
const dialog = this.renderRoot.querySelector('dialog');
if (dialog && !dialog.open) dialog.showModal();
}
queueMicrotask(() => this._focusInitial());
}
private _deepActiveElement(): HTMLElement | null {
let active = document.activeElement as HTMLElement | null;
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement as HTMLElement;
return active && active !== document.body ? active : null;
}
private _focusableElements(): HTMLElement[] {
const selector = [
'[autofocus]',
'a[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[contenteditable="true"]',
'[tabindex]:not([tabindex="-1"])',
].join(',');
return Array.from(this.querySelectorAll<HTMLElement>(selector)).filter((el) => {
const style = getComputedStyle(el);
return !el.hidden && style.display !== 'none' && style.visibility !== 'hidden';
});
}
private _focusInitial = (): void => {
const autofocus = this.querySelector<HTMLElement>('[autofocus]');
const target = autofocus || this._focusableElements()[0]
|| (!this._useHaDialog ? this.renderRoot.querySelector<HTMLElement>('.close') : null)
|| this.renderRoot.querySelector<HTMLElement>('.surface')
|| this.renderRoot.querySelector<HTMLElement>('ha-dialog');
target?.focus({ preventScroll: true });
};
private _requestClose = (): void => {
if (this._closing) return;
this._closing = true;
this.dispatchEvent(new CustomEvent('hp-close', { bubbles: true, composed: true }));
};
private _onKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopImmediatePropagation();
this._requestClose();
return;
}
if (event.key !== 'Tab' || this._useHaDialog) return;
const close = this.renderRoot.querySelector<HTMLElement>('.close');
const focusable = close ? [close, ...this._focusableElements()] : this._focusableElements();
if (!focusable.length) {
event.preventDefault();
this.renderRoot.querySelector<HTMLElement>('.surface')?.focus({ preventScroll: true });
return;
}
const active = this._deepActiveElement();
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && (active === first || !focusable.includes(active!))) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
};
private _onFallbackCancel = (event: Event): void => {
event.preventDefault();
this._requestClose();
};
private _onFallbackClick = (event: MouseEvent): void => {
if (this.dismissOnScrim && event.target === event.currentTarget) this._requestClose();
};
protected render() {
const title = html`<span class="title" id=${this._titleId}>
${this.icon ? html`<ha-icon icon=${this.icon}></ha-icon>` : nothing}
<span>${this.title}</span>
</span>`;
if (this._useHaDialog) {
return html`<ha-dialog
.hass=${this.hass}
.open=${true}
width=${this.wide ? 'medium' : 'small'}
.preventScrimClose=${!this.dismissOnScrim}
.ariaLabelledBy=${this._titleId}
@opened=${this._focusInitial}
@closed=${this._requestClose}
>
<span slot="headerTitle">${title}</span>
<slot></slot>
<span class="footer" slot="footer"><slot name="footer"></slot></span>
</ha-dialog>`;
}
return html`<dialog
role="dialog"
aria-modal="true"
aria-labelledby=${this._titleId}
@cancel=${this._onFallbackCancel}
@click=${this._onFallbackClick}
>
<section class="surface" tabindex="-1">
<header class="header">
${title}
<button class="close" type="button"
aria-label=${this.hass?.localize?.('ui.common.close') || 'Close'}
@click=${this._requestClose}>
<ha-icon icon="mdi:close"></ha-icon>
</button>
</header>
<div class="content"><slot></slot></div>
<div class="footer"><slot name="footer"></slot></div>
</section>
</dialog>`;
}
}
if (!customElements.get('hp-dialog')) customElements.define('hp-dialog', HpDialog);
declare global {
interface HTMLElementTagNameMap {
'hp-dialog': HpDialog;
}
}
+13 -48
View File
@@ -1077,7 +1077,7 @@ export const cardStyles = css`
.decorbar .dfill {
display: inline-flex; align-items: center; gap: var(--sp-2); font-size: var(--fs-s); cursor: pointer;
}
.dialog .dfill {
hp-dialog .dfill {
display: inline-flex;
align-items: center;
gap: var(--sp-3);
@@ -1652,9 +1652,9 @@ export const cardStyles = css`
color: var(--hp-muted);
font-size: var(--fs-s);
}
/* beat the generic .dialog .body .namein { width:100% } rule */
.dialog .body .temprange .tempin { width: 56px; flex: none; padding: var(--sp-2) var(--sp-3); }
.dialog .body .colorrow .tempin { width: 72px; flex: none; }
/* beat the generic hp-dialog .body .namein { width:100% } rule */
hp-dialog .body .temprange .tempin { width: 56px; flex: none; padding: var(--sp-2) var(--sp-3); }
hp-dialog .body .colorrow .tempin { width: 72px; flex: none; }
.srcrow { flex-wrap: nowrap; }
/* native HA controls (rendered only when the HA frontend defines them;
old HA and the smoke env keep the plain inputs). ha-switch is taller
@@ -1684,7 +1684,7 @@ export const cardStyles = css`
background: var(--hp-bg2, rgba(255, 255, 255, 0.03));
/* The same collapse that ate .candlist (v1.53.1): a scroll box is a
flex item whose automatic minimum size is ZERO (overflow != visible),
so inside .dialog .body — a flex column taller than its 66vh cap —
so inside hp-dialog .body — a flex column taller than its 66vh cap —
it shrank to a 14px sliver: the rows were in the DOM, the owner saw
a thin rounded stripe under the "Already uploaded" button. Don't
shrink, and keep a floor even when the box is empty or loading. */
@@ -1735,13 +1735,10 @@ export const cardStyles = css`
border-color: #b3402a;
color: #ff7a5c;
}
.dialog .row .spacer {
hp-dialog .row .spacer {
flex: 1;
}
.dialog.wide {
width: min(500px, 94vw);
}
.dialog .body {
hp-dialog .body {
max-height: 66vh;
overflow-y: auto;
}
@@ -2236,12 +2233,6 @@ export const cardStyles = css`
border-radius: var(--rad-s);
padding: 0 var(--sp-3);
}
.importprog {
margin-left: auto;
color: var(--hp-muted);
font-size: var(--fs-s);
font-weight: 400;
}
.rhint {
font-size: var(--fs-s);
color: var(--hp-muted);
@@ -2298,56 +2289,30 @@ export const cardStyles = css`
}
.aboutlink:hover { text-decoration: underline; }
.aboutlink ha-icon { --mdc-icon-size: 18px; line-height: 1; }
.dialogwrap {
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 90;
}
.dialog {
background: var(--card-background-color, var(--hp-bg));
border: 1px solid var(--hp-accent);
border-radius: var(--rad-l);
box-shadow: var(--shadow-3);
width: min(360px, 92vw);
overflow: hidden;
}
.dialog .hd {
padding: var(--sp-5) var(--sp-6);
font-weight: 600;
border-bottom: 1px solid var(--hp-line);
display: flex;
align-items: center;
gap: var(--sp-4);
}
.dialog .hd ha-icon {
color: var(--hp-accent);
}
.dialog .body {
hp-dialog .body {
padding: var(--sp-5) var(--sp-6);
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
.dialog .body label {
hp-dialog .body label {
font-size: var(--fs-s);
color: var(--hp-muted);
margin-top: var(--sp-3);
}
.dialog .body .namein,
.dialog .body .areasel {
hp-dialog .body .namein,
hp-dialog .body .areasel {
width: 100%;
box-sizing: border-box;
}
.dialog .row {
hp-dialog .row {
display: flex;
justify-content: flex-end;
gap: var(--sp-4);
padding: var(--sp-5) var(--sp-6);
border-top: 1px solid var(--hp-line);
}
.dialog .row.markerfooter {
hp-dialog .row.markerfooter {
justify-content: space-between;
align-items: center;
flex-wrap: wrap;