feat(room): «Настройки комнаты» по референсу — серия 3 из четырёх (#600)

§6 SPEC.md референса. Четыре карточки #594 остались, менялось содержимое:

- Basics — Display name и Home Assistant area в одну строку из двух полей
  (#room-name, #room-area — контролы набора); подстановка имени из зоны при
  пустом имени прежняя;
- Fill — «Как у пространства» стала строкой-тумблером (#room-fill-inherit,
  решение владельца Q4): включена — подсказка «Следует за пространством:
  режим»; выключена — сегмент из пяти режимов, который стартует с
  действующего режима пространства, а не с «None». «Свой цвет» — плашка
  вокруг прежнего hp-color-opacity (Q5): свотч, hex, число прозрачности,
  Reset у своего цвета. Границы комфорта при эффективной «Температуре» —
  два поля с единицей °C (#room-temp-min/-max) с плейсхолдерами границ
  пространства, легендой Cold / Comfort / Hot, ошибкой под полями и ссылкой
  «Как у пространства». Модуль room-temperature-controls.ts удалён — его
  разметку заменили поля набора;
- Sensor sources — подзаголовки, прежний сегмент «среднее / конкретный», при
  конкретном — кнопка выбора в стиле селекта и панель с поиском в потоке
  карточки (sourcePicker набора, роль listbox/option; общая с диалогом
  устройства в серии 4). Кандидаты и запись прежние;
- Font sizes — две строки «слайдер + число % + Reset to 100%» с концами шкалы,
  подсказка про размер карточек пространства, образец карточки сохранён
  внутри .hpf-tint (решение владельца Q2).

Футер: Cancel | статус | Save; в create — Keep as walls слева. К10: снимок
черновика при _openRoomEdit (room-form-state.ts поверх dialog-baseline),
в edit Save только при изменениях, закрытие с изменениями спрашивает через
_confirmDanger; в create Save по прежнему условию (имя или зона), отмена без
вопроса — она и раньше значила «не создавать» и возвращала контур на план.
Ошибки черновика: имя (edit) / имя-или-зона (create), нечисло в границах —
подпись под полем, «Review N fields» в футере ведёт к первому полю.

Из eager-листа dialogs.styles.ts удалены .roomtemprange*, .roomdialog .srcrow
и .dropbtn; `overflow-x: hidden` у .body убран — скроллер один (К3).

Смоки переписаны осознанно, с пометкой #600: smoke_room_settings (тумблер
вместо радио, #room-name/#room-area, .hpf-panel .hpf-cand, старт сегмента с
режима пространства), smoke_room_temperature_thresholds (.hpf-temprange,
ссылка сброса). Golden-harness room-temperature — те же селекторы. Новый
фокусный свидетель demo/smoke_room_settings_form.mjs: четыре карточки без
старой разметки, имя и зона в одну строку, оболочка 560 и один скроллер,
тумблер/сегмент/плашка/границы/источники/размеры пишут в свои ключи, Save и
вопрос при закрытии, create-режим, семь полос экранов.

Мутанты: room-save-enabled-without-changes, room-discard-without-asking,
room-fill-segment-starts-at-none, room-temp-min-writes-max. Якорь
form-kit-writes-to-a-neighbour-key прежний. smoke-links: символ
roomTemperatureControls заменён на renderRoomSettingsDialog.

Словарь i18n/settings: девять ключей room.* на четыре локали; в основном
каталоге room.area_label без «(unassigned)». Фикстуры листа диалогов набора
пересняты намеренно (панель — байт в байт, AC11).

Бюджет: стартовый граф 290 575 — потолок ОПУЩЕН 292 200 → 291 600 (ушли CSS
диалога комнаты и модуль границ); ленивый редакторский 239 391 — потолок
238 600 → 240 100 с разбором в bundle-budget.mjs.

Гейты: typecheck, build, bundle:sync (три копии байт в байт), npm test
2790+ pass по четырём частям, mutation-gate --check, смоки: room_settings,
room_settings_form, room_temperature_thresholds, editor_tabs, font_scales,
resize_labels, room_autoclose, binding_ui, color_picker_consumers,
lazy_editor_chunk, space_settings_form, general_settings_form, ux_fixes,
settings_dialog_cards, dialog_footer_width.

Issue: #600
User-Visible: no
This commit is contained in:
Claude
2026-09-20 11:39:46 +03:00
parent de71bd7967
commit e2c0c7ed81
72 changed files with 1893 additions and 1483 deletions
+64 -2
View File
@@ -426,14 +426,16 @@ export interface RangeLineOptions {
/** Слайдер от вызывающего (`_rangeInput`: ha-slider или range) — компонент не подменяется. */
slider: TemplateResult;
onInput: (value: number) => void;
/** `id` числового поля — цель `<label for>` строки. */
id?: string;
}
/** Слайдер + числовое поле с единицей в одну строку (§3.1 «range-line»). */
export function rangeLine({ min, max, value, unit, ariaLabel, disabled, slider, onInput }: RangeLineOptions): TemplateResult {
export function rangeLine({ min, max, value, unit, ariaLabel, disabled, slider, onInput, id }: RangeLineOptions): TemplateResult {
return html`<div class="hpf-range">
${slider}
${unitInput({
value: String(value), unit, min, max, step: 1, ariaLabel, disabled,
id, value: String(value), unit, min, max, step: 1, ariaLabel, disabled,
onInput: (raw) => {
const n = Number(raw);
if (Number.isFinite(n)) onInput(Math.min(max, Math.max(min, n)));
@@ -521,3 +523,63 @@ export function tintBlock({ title, tag, body }: { title: string; tag?: string; b
/** Единственный разрешённый разделитель внутри карточки (§3.2). */
export const divider = (): TemplateResult => html`<hr class="hpf-divider" />`;
export interface SourceCandidate {
value: string;
label: string;
sub: string;
}
export interface SourcePickerOptions {
/** Панель раскрыта. */
open: boolean;
/** Выбранный кандидат (подпись + идентификатор) или `null` — плейсхолдер. */
current: { label: string; sub: string } | null;
placeholder: string;
ariaLabel: string;
filter: string;
filterPlaceholder: string;
candidates: readonly SourceCandidate[];
/** Значение, отмеченное в списке. */
selected: string;
emptyText: string;
onToggle: () => void;
onFilter: (value: string) => void;
onPick: (value: string) => void;
/** `id` кнопки — для `<label for>` и смоков. */
id?: string;
}
/**
* Кнопка выбора источника с панелью в потоке (§3.1 «Кнопка выбора источника»):
* кнопка в стиле селекта, под ней поиск и список кандидатов. Панель не
* всплывает поверх — раздвигает карточку, скроллер диалога один (К3).
*/
export function sourcePicker({
open, current, placeholder, ariaLabel, filter, filterPlaceholder, candidates, selected, emptyText,
onToggle, onFilter, onPick, id,
}: SourcePickerOptions): TemplateResult {
return html`<div class="hpf-picker ${open ? 'open' : ''}">
<button id=${id ?? nothing} type="button" class="hpf-select hpf-pick" aria-haspopup="listbox"
aria-expanded=${open ? 'true' : 'false'} aria-label=${ariaLabel} @click=${onToggle}>
${current
? html`<b>${current.label}</b><span class="hpf-pick-ref">${current.sub}</span>`
: html`<span class="hpf-pick-ph">${placeholder}</span>`}
</button>
${open
? html`<div class="hpf-panel">
<input class="hpf-input" type="text" autocomplete="off" placeholder=${filterPlaceholder}
aria-label=${filterPlaceholder} .value=${filter}
@input=${(e: Event) => onFilter((e.target as HTMLInputElement).value)} />
<div class="hpf-list" role="listbox" aria-label=${ariaLabel}>
${candidates.length
? candidates.map((c) => html`<button type="button" role="option" class="hpf-cand ${c.value === selected ? 'sel' : ''}"
aria-selected=${c.value === selected ? 'true' : 'false'} @click=${() => onPick(c.value)}>
<span>${c.label}</span><small>${c.sub}</small>
</button>`)
: html`<p class="hpf-hint">${emptyText}</p>`}
</div>
</div>`
: nothing}
</div>`;
}
+55
View File
@@ -0,0 +1,55 @@
/**
* #600 К10 для «Настроек комнаты»: снимок при открытии, dirty, валидация.
*
* Черновик комнаты живёт не объектом, а полями хоста (`_nameSel`, `_roomFill`,
* …) — снимок собирает их в один отпечаток. Поля хоста не получают новых
* ключей.
*/
import { roomTempThresholdDraft } from '../space-dialog';
import type { HouseplanEditorHostPort } from '../houseplan-editor-runtime';
import { dialogDirty, forgetDialogBaseline, rememberDialogBaseline, stableKey } from './dialog-baseline';
type RoomDraftHost = Pick<HouseplanEditorHostPort,
'_nameSel' | '_areaSel' | '_roomFill' | '_roomCustomFill' | '_roomTempMin' | '_roomTempMax'
| '_roomTempSrc' | '_roomHumSrc' | '_roomNameScale' | '_roomLabelScale' | '_roomEditId'>;
export function roomDraftKey(h: RoomDraftHost): string {
return stableKey({
name: h._nameSel, area: h._areaSel, fill: h._roomFill, customFill: h._roomCustomFill,
tempMin: h._roomTempMin, tempMax: h._roomTempMax, tempSrc: h._roomTempSrc, humSrc: h._roomHumSrc,
nameScale: h._roomNameScale, labelScale: h._roomLabelScale,
});
}
export function rememberRoomBaseline(host: object & RoomDraftHost): void {
rememberDialogBaseline(host, 'room', roomDraftKey(host));
}
export function forgetRoomBaseline(host: object): void {
forgetDialogBaseline(host, 'room');
}
export function roomDirty(host: object & RoomDraftHost): boolean {
return dialogDirty(host, 'room', roomDraftKey(host));
}
export interface RoomProblem {
field: string;
message: 'room.error_name' | 'room.error_temp_range';
}
/**
* Ошибки черновика (§6.2): в edit имя обязательно; в create — имя или зона;
* границы комфорта — числа либо пусто (наследование).
*/
export function roomProblems(h: RoomDraftHost): RoomProblem[] {
const problems: RoomProblem[] = [];
const edit = !!h._roomEditId;
if (edit ? !h._nameSel.trim() : !(h._areaSel || h._nameSel.trim())) {
problems.push({ field: 'room-name', message: 'room.error_name' });
}
if (!roomTempThresholdDraft(h._roomTempMin, h._roomTempMax).valid) {
problems.push({ field: 'room-temp-min', message: 'room.error_temp_range' });
}
return problems;
}
+241 -155
View File
@@ -1,39 +1,33 @@
/**
* Диалог «Настройки комнаты».
* Диалог «Настройки комнаты» по референсу `docs/design/600-settings-dialogs/`
* (#600, серия 3; §6 SPEC.md).
*
* #592: тело вынесено из `houseplan-editor-runtime.ts` побайтово.
* #594: форма собрана в карточки-группы общим набором контролов
* (`./form-kit`); записи остались прежними — те же ключи черновика на хосте,
* те же условия сохранения, тот же `hp-color-opacity`.
* #592 вынес тело, #594 собрал четыре карточки набором, #600 переложил их
* содержимое: имя и зона в две колонки, «Как у пространства» строкой-тумблером
* (Q4) с сегментом режимов под ней, плашка цвета вокруг прежнего пикера (Q5),
* границы комфорта полями с единицей, источники сегментами с кнопкой выбора и
* панелью в потоке, размеры шрифта слайдерами с числом и сбросом, образец
* карточки сохранён (Q2). Черновик — те же поля хоста, что и раньше (К1).
*/
import { html, nothing, type TemplateResult } from 'lit';
import type { I18nKey } from '../i18n';
import { langOf, type I18nKey } from '../i18n';
import { settingsT, type SettingsI18nKey } from '../i18n/settings';
import { ROOM_FILL_MODES, spaceDisplayOf } from '../logic';
import { roomTemperatureControls } from '../room-temperature-controls';
import { roomTempThresholdDraft } from '../space-dialog';
import { roomTempThresholdDraft, strictNumber } from '../space-dialog';
import type { HouseplanEditorRuntime } from '../houseplan-editor-runtime';
import { colorRow, ensureFormKitStyles, formCard, formRow, segmented } from './form-kit';
import {
colorField, colorRow, ensureFormKitStyles, field, fieldGrid, footerStatus, formCard, rangeEnds,
rangeLine, segmented, sourcePicker, subsection, textLink, toggleRow, unitInput,
} from './form-kit';
import { forgetRoomBaseline, roomDirty, roomProblems } from './room-form-state';
type RoomFillMode = typeof ROOM_FILL_MODES[number];
/**
* Варианты заливки комнаты: «как у пространства» плюс режимы пространства.
*
* #594: раньше пара «значение, ключ» собиралась кортежами, и ключ приходилось
* приводить к `any` — ключ получался из конкатенации и терял литеральный тип.
* Явная форма сохраняет и то и другое: режим остаётся значением `_roomFill`, а
* ключ — настоящим `I18nKey`, который проверяет компилятор.
*/
type RoomFillChoice = { value: '' | typeof ROOM_FILL_MODES[number]; key: I18nKey };
const FILL_CHOICES: readonly RoomFillChoice[] = [
{ value: '', key: 'fill.inherit' },
...ROOM_FILL_MODES.map((value) => ({ value, key: `fill.${value}` as const })),
];
/**
* Источник измерения: среднее по комнате или выбранный датчик.
*
* #594: два радиоряда заменены сегментированным переключателем — той же группой
* радиокнопок под капотом. Список кандидатов и запись не изменились.
* Источник измерения: сегмент «среднее / конкретный датчик»; при выборе
* конкретного — кнопка выбора и панель с поиском в потоке карточки. Кандидаты
* (`_roomSrcCandidates`) и запись (`_roomTempSrc` / `_roomHumSrc`) прежние.
*/
function renderRoomSource(runtime: HouseplanEditorRuntime, kind: 'temp' | 'hum'): TemplateResult {
const host = runtime.host;
@@ -45,9 +39,8 @@ function renderRoomSource(runtime: HouseplanEditorRuntime, kind: 'temp' | 'hum')
};
const open = host._roomSrcOpen === kind;
const label = host._t(kind === 'temp' ? 'room.temp_src_label' : 'room.hum_src_label');
return html`${formRow({
label,
control: segmented<'avg' | 'pick'>({
return html`${subsection({ title: label })}
${segmented<'avg' | 'pick'>({
name: `rsrc-${kind}`,
value: val ? 'pick' : 'avg',
ariaLabel: label,
@@ -61,161 +54,254 @@ function renderRoomSource(runtime: HouseplanEditorRuntime, kind: 'temp' | 'hum')
host._roomSrcFilter = '';
host.requestUpdate();
},
}),
})}
})}
${val || open
? html`<button class="dropbtn ${open ? 'open' : ''}"
@click=${() => { host._roomSrcOpen = open ? null : kind; host._roomSrcFilter = ''; }}>
${val
? html`<b>${runtime._roomSrcLabel(val)}</b><span class="ref">${val}</span>`
: html`<span class="muted">${host._t('room.src_ph')}</span>`}
<ha-icon icon=${open ? 'mdi:chevron-up' : 'mdi:chevron-down'}></ha-icon>
</button>
${open
? html`<div class="droppanel">
<input class="namein" type="text" placeholder=${host._t('marker.search_ph')}
.value=${host._roomSrcFilter}
@input=${(e: Event) => { host._roomSrcFilter = (e.target as HTMLInputElement).value; host.requestUpdate(); }} />
<div class="candlist">
${runtime._roomSrcCandidates().map(
(c) => html`<div class="cand ${c.value === val ? 'sel' : ''}"
@click=${() => { setVal(c.value); host._roomSrcOpen = null; }}>
<span class="cl">${c.label}</span><span class="cs">${c.sub}</span>
</div>`,
)}
</div>
</div>`
: nothing}`
? sourcePicker({
id: `room-${kind}-source`,
open,
current: val ? { label: runtime._roomSrcLabel(val), sub: val } : null,
placeholder: host._t('room.src_ph'),
ariaLabel: label,
filter: host._roomSrcFilter,
filterPlaceholder: host._t('marker.search_ph'),
candidates: open ? runtime._roomSrcCandidates() : [],
selected: val,
emptyText: host._t('marker.nothing_found'),
onToggle: () => { host._roomSrcOpen = open ? null : kind; host._roomSrcFilter = ''; host.requestUpdate(); },
onFilter: (v) => { host._roomSrcFilter = v; host.requestUpdate(); },
onPick: (v) => { setVal(v); host._roomSrcOpen = null; },
})
: nothing}`;
}
export function renderRoomSettingsDialog(this: HouseplanEditorRuntime): TemplateResult {
ensureFormKitStyles(this.host);
const edit = !!this.host._roomEditId;
const faceBatch = !edit ? this.host._wallFaceBatch : null;
const host = this.host;
const t = host._t.bind(host);
const lang = langOf(host.hass, host._config?.language);
const st = (key: SettingsI18nKey, vars?: Record<string, string | number>) => settingsT(lang, key, vars);
const edit = !!host._roomEditId;
const faceBatch = !edit ? host._wallFaceBatch : null;
const batchProgress = faceBatch && faceBatch.candidates.length > 1
? this.host._t('room.queue_progress', {
current: faceBatch.index + 1, total: faceBatch.candidates.length,
})
? t('room.queue_progress', { current: faceBatch.index + 1, total: faceBatch.candidates.length })
: '';
const spaceDisplay = spaceDisplayOf(this.host._curSpaceCfg);
const effectiveFill = this.host._roomFill || spaceDisplay.fill;
const customFill = this.host._roomCustomFill || spaceDisplay.customFill;
const tempValid = roomTempThresholdDraft(this.host._roomTempMin, this.host._roomTempMax).valid;
const canSaveNew = (!!this.host._areaSel || !!this.host._nameSel.trim()) && tempValid;
const spaceDisplay = spaceDisplayOf(host._curSpaceCfg);
const inherit = host._roomFill === '';
const effectiveFill = host._roomFill || spaceDisplay.fill;
const customFill = host._roomCustomFill || spaceDisplay.customFill;
const tempValid = roomTempThresholdDraft(host._roomTempMin, host._roomTempMax).valid;
const problems = roomProblems(host);
const problemFor = (fieldId: string) => problems.find((p) => p.field === fieldId);
const dirty = roomDirty(host);
/* К10 (Q1): в edit «Сохранить» активен только при изменениях; в create черновик
* новой комнаты сохранять есть всегда — условие прежнее (имя или зона, границы). */
const canSave = problems.length === 0 && tempValid && (edit ? dirty : true);
// the free-areas list must include the edited room's CURRENT area
const areas = [...this.host._freeAreas];
if (edit && this.host._areaSel && !areas.some((a) => a.area_id === this.host._areaSel)) {
const cur = this.host.hass.areas[this.host._areaSel];
const areas = [...host._freeAreas];
if (edit && host._areaSel && !areas.some((a) => a.area_id === host._areaSel)) {
const cur = host.hass.areas[host._areaSel];
if (cur) areas.unshift(cur);
}
return html`<hp-dialog class="roomdialog" .hass=${this.host.hass} data-kind="room" form-shell wide
.title=${edit ? this.host._t('room.settings_title')
: batchProgress || this.host._t('room.new')}
icon=${edit ? 'mdi:cog-outline' : 'mdi:floor-plan'} @hp-close=${() => this._roomDialogCancel()}>
const close = () => { forgetRoomBaseline(host); this._roomDialogCancel(); };
/* К10: закрытие с изменениями в edit спрашивает. В create отмена и раньше
* значила «не создавать» и возвращала контур на план (`_roomDialogCancel`) —
* вопрос там мешал бы очереди «Room N of M», а терять нечего. */
const requestClose = async (event: Event) => {
if (!edit || !dirty) { close(); return; }
const dialog = event.currentTarget as { rejectClose?: () => void } | null;
const discard = await host._confirmDanger({
key: 'discard-room-dialog', kind: 'warning',
title: st('dialog.discard_title'), message: st('dialog.discard_message'),
objectName: host._nameSel.trim() || undefined,
confirmLabel: st('dialog.discard_confirm'), cancelLabel: st('dialog.discard_keep'),
});
if (discard) close(); else dialog?.rejectClose?.();
};
const reviewFirst = () => {
const first = problems[0];
if (!first) return;
const node = (host.renderRoot as ParentNode).querySelector<HTMLElement>(`#${first.field}`);
node?.scrollIntoView({ block: 'center' });
node?.focus();
};
const nameProblem = problemFor('room-name');
const tempProblem = problemFor('room-temp-min');
/* Q4: при выключении «Как у пространства» сегмент стартует с эффективного
* режима пространства — человек видит то, что и так действует, и меняет дальше. */
const startMode = (): RoomFillMode => (ROOM_FILL_MODES as readonly string[]).includes(spaceDisplay.fill)
? spaceDisplay.fill as RoomFillMode : 'custom';
// #581: уход со «Своего цвета» забывает цвет комнаты
const setFill = (value: '' | RoomFillMode) => {
host._roomFill = value;
if (value !== 'custom') host._roomCustomFill = null;
host.requestUpdate();
};
const pct = (v: number) => Math.round(v * 100);
const customLabel = host._roomCustomFill ? t('room.custom_fill_own') : t('room.custom_fill_space');
const sizeRows: readonly [key: 'name' | 'label', labelKey: I18nKey, value: number, write: (v: number) => void][] = [
['name', 'room.name_scale', host._roomNameScale, (v) => { host._roomNameScale = v; }],
['label', 'room.label_scale', host._roomLabelScale, (v) => { host._roomLabelScale = v; }],
];
return html`<hp-dialog class="roomdialog" .hass=${host.hass} data-kind="room" form-shell wide
.title=${edit ? t('room.settings_title') : batchProgress || t('room.new')}
.badge=${host._spaceModel()?.title ?? ''}
icon=${edit ? 'mdi:cog-outline' : 'mdi:floor-plan'} @hp-close=${requestClose}>
<div class="body hpf-form">
${batchProgress ? html`<p class="muted" role="status" aria-live="polite">
${batchProgress}
</p>` : nothing}
${batchProgress ? html`<p class="hpf-hint" role="status" aria-live="polite">${batchProgress}</p>` : nothing}
${formCard({
title: this.host._t('room.group_basics'),
id: 'basics',
title: t('room.group_basics'),
help: this._help('room.group_basics.help'),
body: html`
<label>${this.host._t('room.name_label')}</label>
<input class="namein" type="text" placeholder=${this.host._t('room.name_ph')}
.value=${this.host._nameSel}
@input=${(e: Event) => (this.host._nameSel = (e.target as HTMLInputElement).value)} />
<label>${this.host._t('room.area_label')}</label>
<select class="areasel"
@change=${(e: Event) => {
this.host._areaSel = (e.target as HTMLSelectElement).value;
if (!this.host._nameSel && this.host._areaSel)
this.host._nameSel = this.host.hass.areas[this.host._areaSel]?.name || '';
this.host.requestUpdate();
}}>
<option value="">${this.host._t('room.no_area_option')}</option>
${areas.map(
(a) => html`<option value=${a.area_id} ?selected=${a.area_id === this.host._areaSel}>${a.name}</option>`,
)}
</select>`,
body: fieldGrid([
field({
label: t('room.name_label'), htmlFor: 'room-name',
error: nameProblem ? st(nameProblem.message) : undefined,
control: html`<input id="room-name" class="hpf-input" type="text" placeholder=${t('room.name_ph')}
.value=${host._nameSel} aria-invalid=${nameProblem ? 'true' : nothing}
@input=${(e: Event) => { host._nameSel = (e.target as HTMLInputElement).value; host.requestUpdate(); }} />`,
}),
field({
label: t('room.area_label'), htmlFor: 'room-area',
control: html`<select id="room-area" class="hpf-select"
@change=${(e: Event) => {
host._areaSel = (e.target as HTMLSelectElement).value;
if (!host._nameSel && host._areaSel) host._nameSel = host.hass.areas[host._areaSel]?.name || '';
host.requestUpdate();
}}>
<option value="">${t('room.no_area_option')}</option>
${areas.map((a) => html`<option value=${a.area_id} ?selected=${a.area_id === host._areaSel}>${a.name}</option>`)}
</select>`,
}),
]),
})}
${formCard({
title: this.host._t('room.group_fill'),
id: 'fill',
title: t('room.group_fill'),
help: this._help('room.group_fill.help'),
body: html`
<label>${this.host._t('room.fill_label')}</label>
${FILL_CHOICES.map(
({ value, key }) => html`<label class="srcrow inline">
<input type="radio" name="rfill" .checked=${this.host._roomFill === value}
@change=${() => { this.host._roomFill = value; if (value !== 'custom') this.host._roomCustomFill = null; this.host.requestUpdate(); }} />
<span>${this.host._t(key)}</span>
</label>`,
)}
${this.host._roomFill === 'custom'
${toggleRow({
id: 'room-fill-inherit', icon: 'mdi:layers-outline',
title: t('fill.inherit'), caption: st('room.fill_inherit_hint'),
checked: inherit,
onChange: (v) => setFill(v ? '' : startMode()),
})}
${inherit
? html`<p class="hpf-hint">${st('room.fill_following', { mode: t(`fill.${spaceDisplay.fill}` as I18nKey) })}</p>`
: field({
label: st('room.fill_mode_label'),
control: segmented<RoomFillMode>({
name: 'rfill',
value: host._roomFill as RoomFillMode,
ariaLabel: t('room.fill_label'),
options: ROOM_FILL_MODES.map((value) => ({ value, label: t(`fill.${value}` as I18nKey) })),
onChange: (value) => setFill(value),
}),
})}
${host._roomFill === 'custom'
? colorRow({
label: this.host._roomCustomFill
? this.host._t('room.custom_fill_own') : this.host._t('room.custom_fill_space'),
picker: html`<hp-color-opacity
.label=${this.host._roomCustomFill
? this.host._t('room.custom_fill_own') : this.host._t('room.custom_fill_space')}
.opacityLabel=${this.host._t('space.opacity')}
.pickerLabels=${this.host._colorPickerLabels}
.color=${customFill.c}
.opacity=${customFill.a}
@hp-color-opacity-change=${(e: CustomEvent<{ color: string; opacity: number }>) => {
this.host._roomCustomFill = { c: e.detail.color, a: e.detail.opacity };
}}></hp-color-opacity>`,
action: this.host._roomCustomFill
? html`<button class="btn ghost" type="button" @click=${() => {
this.host._roomCustomFill = null;
}}>${this.host._t('btn.reset')}</button>`
: nothing,
label: customLabel,
picker: colorField({
hex: customFill.c, opacity: customFill.a, opacityLabel: t('space.opacity'),
onOpacity: (a) => { host._roomCustomFill = { c: customFill.c, a }; host.requestUpdate(); },
resetLabel: host._roomCustomFill ? t('btn.reset') : undefined,
onReset: () => { host._roomCustomFill = null; host.requestUpdate(); },
picker: html`<hp-color-opacity .label=${customLabel} hide-label
.opacityLabel=${t('space.opacity')}
.pickerLabels=${host._colorPickerLabels}
.color=${customFill.c}
.opacity=${customFill.a}
@hp-color-opacity-change=${(e: CustomEvent<{ color: string; opacity: number }>) => {
host._roomCustomFill = { c: e.detail.color, a: e.detail.opacity };
}}></hp-color-opacity>`,
}),
})
: nothing}
<div class="roomtemprange-host">${roomTemperatureControls(this.host, effectiveFill, spaceDisplay.tempMin, spaceDisplay.tempMax, this._help('room.temp_range.help')).template}</div>`,
${effectiveFill === 'temp'
? html`<div class="hpf-field hpf-temprange">
<span class="hpf-label hpf-labelrow">${t('room.temp_range_label')}${this._help('room.temp_range.help')}</span>
${fieldGrid([
field({
label: st('room.temp_min_label'), htmlFor: 'room-temp-min',
control: unitInput({
id: 'room-temp-min', wide: true, unit: '°C', step: 0.5, value: host._roomTempMin,
placeholder: String(spaceDisplay.tempMin), ariaLabel: t('room.temp_range_min'),
invalid: host._roomTempMin.trim() !== '' && strictNumber(host._roomTempMin) === null,
onInput: (raw) => { host._roomTempMin = raw; host.requestUpdate(); },
}),
}),
field({
label: st('room.temp_max_label'), htmlFor: 'room-temp-max',
control: unitInput({
id: 'room-temp-max', wide: true, unit: '°C', step: 0.5, value: host._roomTempMax,
placeholder: String(spaceDisplay.tempMax), ariaLabel: t('room.temp_range_max'),
invalid: host._roomTempMax.trim() !== '' && strictNumber(host._roomTempMax) === null,
onInput: (raw) => { host._roomTempMax = raw; host.requestUpdate(); },
}),
}),
])}
${tempProblem ? html`<p class="hpf-error" role="alert">${st(tempProblem.message)}</p>` : nothing}
<div class="hpf-templegend" aria-hidden="true">
<span>${t('gs.temp_cold')}</span><span>${t('gs.temp_ok')}</span><span>${t('gs.temp_hot')}</span>
</div>
${host._roomTempMin.trim() || host._roomTempMax.trim()
? textLink(t('room.temp_range_reset'), () => { host._roomTempMin = ''; host._roomTempMax = ''; host.requestUpdate(); })
: nothing}
</div>`
: nothing}`,
})}
${formCard({
title: this.host._t('room.group_sources'),
id: 'sources',
title: t('room.group_sources'),
help: this._help('room.group_sources.help'),
body: html`
${renderRoomSource(this, 'temp')}
${renderRoomSource(this, 'hum')}`,
body: html`${renderRoomSource(this, 'temp')}${renderRoomSource(this, 'hum')}`,
})}
${formCard({
title: this.host._t('room.sizes_section'),
id: 'sizes',
title: t('room.sizes_section'),
help: this._help('room.sizes_section.help'),
body: html`
${formRow({
label: this.host._t('room.name_scale'),
control: html`<span class="hpf-scale">${this._rangeInput(50, 300, 5, Math.round(this.host._roomNameScale * 100), (n) => { this.host._roomNameScale = n / 100; this.host.requestUpdate(); })}
<span class="opv">${Math.round(this.host._roomNameScale * 100)}%</span></span>`,
})}
${formRow({
label: this.host._t('room.label_scale'),
control: html`<span class="hpf-scale">${this._rangeInput(50, 300, 5, Math.round(this.host._roomLabelScale * 100), (n) => { this.host._roomLabelScale = n / 100; this.host.requestUpdate(); })}
<span class="opv">${Math.round(this.host._roomLabelScale * 100)}%</span></span>`,
})}
${this.host._renderCardPreview(
spaceDisplayOf(this.host._curSpaceCfg).cardFontScale,
this.host._roomNameScale,
this.host._roomLabelScale,
)}`,
${sizeRows.map(([key, labelKey, value, write]) => html`<div class="hpf-field">
<div class="hpf-headline">
<span class="hpf-label hpf-labelrow"><label for=${`room-${key}-scale`}>${t(labelKey)}</label></span>
${textLink(st('btn.reset_100'), () => { write(1); host.requestUpdate(); }, { disabled: pct(value) === 100 })}
</div>
${rangeLine({
id: `room-${key}-scale`, min: 50, max: 300, step: 5, value: pct(value), unit: '%', ariaLabel: t(labelKey),
slider: this._rangeInput(50, 300, 5, pct(value), (n) => { write(n / 100); host.requestUpdate(); }, false, t(labelKey)),
onInput: (n) => { write(n / 100); host.requestUpdate(); },
})}
${rangeEnds('50%', '300%')}
</div>`)}
<p class="hpf-hint">${st('room.sizes_hint', { v: String(pct(spaceDisplay.cardFontScale)) })}</p>
<div class="hpf-tint hpf-preview">${host._renderCardPreview(spaceDisplay.cardFontScale, host._roomNameScale, host._roomLabelScale)}</div>`,
})}
</div>
<div class="row roomfooter" slot="footer">
<button class="btn ghost" data-hp="dialog-cancel" @click=${() => this._roomDialogCancel()}>${this.host._t('btn.cancel')}</button>
<span class="spacer"></span>
${edit
? html`<button class="btn on" data-hp="dialog-confirm" @click=${() => this._saveRoomEdit()} ?disabled=${!this.host._nameSel.trim() || !tempValid}>
<ha-icon icon="mdi:check"></ha-icon>${this.host._t('btn.save')}
</button>`
: html`${!this.host._pendingSplit ? html`<button class="btn ghost" data-hp="dialog-confirm"
@click=${() => this._keepClosedAsPartitions()}>
<ha-icon icon="mdi:wall"></ha-icon>${this.host._t('btn.keep_as_walls')}
</button>` : nothing}
<button class="btn on room-save" data-hp="dialog-confirm" @click=${() => this._saveRoom()} ?disabled=${!canSaveNew}>
<ha-icon icon="mdi:check"></ha-icon>${this.host._t('btn.save')}
</button>`}
<div class="row dialog-action-footer hpf-footer roomfooter" slot="footer">
${!edit && !host._pendingSplit
? html`<div class="dialog-action-group">
<button class="btn ghost" data-hp="dialog-confirm" @click=${() => this._keepClosedAsPartitions()}>
<ha-icon icon="mdi:wall"></ha-icon>${t('btn.keep_as_walls')}
</button>
</div>`
: nothing}
${footerStatus(problems.length
? { action: textLink(st('dialog.review_fields', { n: String(problems.length) }), reviewFirst) }
: { text: edit && dirty ? st('dialog.unsaved') : '' })}
<div class="dialog-action-group dialog-action-commit">
<button class="btn ghost" data-hp="dialog-cancel" @click=${requestClose}>${t('btn.cancel')}</button>
${edit
? html`<button class="btn on" data-hp="dialog-confirm" ?disabled=${!canSave}
title=${problems[0] ? st(problems[0].message) : nothing}
@click=${() => { forgetRoomBaseline(host); this._saveRoomEdit(); }}>
<ha-icon icon="mdi:check"></ha-icon>${t('btn.save')}
</button>`
: html`<button class="btn on room-save" data-hp="dialog-confirm" ?disabled=${!canSave}
title=${problems[0] ? st(problems[0].message) : nothing}
@click=${() => { forgetRoomBaseline(host); this._saveRoom(); }}>
<ha-icon icon="mdi:check"></ha-icon>${t('btn.save')}
</button>`}
</div>
</div>
</hp-dialog>`;
}
+2 -1
View File
@@ -140,8 +140,8 @@ import {
} from './space-dialog';
import { rememberSpaceDialogBaseline } from './editors/space-form-state';
import { rememberGeneralBaseline } from './editors/general-form-state';
import { rememberRoomBaseline } from './editors/room-form-state';
import { commitPlanOptimization } from './plan-optimize-write';
import { roomTemperatureControls } from './room-temperature-controls';
import { openSpaceCopyDialog, renderSpaceCopyDialog, saveSpaceCopy } from './space-copy-runtime';
import { mdiHomeCityOutline } from '@mdi/js';
import {
@@ -10842,6 +10842,7 @@ public _openRoomEdit(r: RoomCfg): void {
this.host._roomSrcOpen = null;
this.host._roomSrcFilter = '';
this.host._roomDialog = true;
rememberRoomBaseline(this.host); // #600 К10: Save только при изменениях
}
public _roomSettingsFromDialog(): RoomCfg['settings'] {
+1 -1
View File
@@ -296,7 +296,7 @@
"room.new": "Neuer Raum",
"room.name_label": "Anzeigename",
"room.name_ph": "z.B. Terrasse",
"room.area_label": "Home Assistant Bereich (nicht zugewiesen)",
"room.area_label": "Home-Assistant-Bereich",
"room.no_area_option": "— kein Bereich —",
"room.default_name": "Raum",
"device.unnamed": "unbenannt",
+1 -1
View File
@@ -296,7 +296,7 @@
"room.new": "New room",
"room.name_label": "Display name",
"room.name_ph": "e.g. Terrace",
"room.area_label": "Home Assistant area (unassigned)",
"room.area_label": "Home Assistant area",
"room.no_area_option": "— no area —",
"room.default_name": "Room",
"device.unnamed": "unnamed",
+1 -1
View File
@@ -296,7 +296,7 @@
"room.new": "Nouvelle pièce",
"room.name_label": "Nom affiché",
"room.name_ph": "ex. Terrasse",
"room.area_label": "Zone Home Assistant (non attribuée)",
"room.area_label": "Zone Home Assistant",
"room.no_area_option": "— aucune zone —",
"room.default_name": "Pièce",
"device.unnamed": "sans nom",
+1 -1
View File
@@ -296,7 +296,7 @@
"room.new": "Новая комната",
"room.name_label": "Отображаемое имя",
"room.name_ph": "Например: Терраса",
"room.area_label": "Зона Home Assistant (свободные)",
"room.area_label": "Зона Home Assistant",
"room.no_area_option": "— без зоны —",
"room.default_name": "Комната",
"device.unnamed": "без имени",
+9 -1
View File
@@ -57,5 +57,13 @@
"gs.sun_rays_hint": "Sonnenstrahlen durch Fensteröffnungen zeichnen.",
"gs.error_glow_radius": "Geben Sie einen Radius größer als null ein.",
"gs.error_north": "Geben Sie eine ganze Zahl zwischen 0° und 359° ein.",
"gs.opacity_of": "Deckkraft: {name}"
"gs.opacity_of": "Deckkraft: {name}",
"room.fill_inherit_hint": "Den Füllmodus des gesamten Bereichs verwenden.",
"room.fill_following": "Wie der Bereich: {mode}.",
"room.fill_mode_label": "Füllmodus",
"room.temp_min_label": "Minimum",
"room.temp_max_label": "Maximum",
"room.sizes_hint": "Zusätzlich zur bereichsweiten Kartengröße ({v} %).",
"room.error_temp_range": "Geben Sie Zahlen für die Komfortgrenzen ein.",
"room.error_name": "Geben Sie einen Namen ein oder wählen Sie einen Bereich."
}
+9 -1
View File
@@ -57,5 +57,13 @@
"gs.sun_rays_hint": "Draw sun rays through window openings.",
"gs.error_glow_radius": "Enter a radius greater than zero.",
"gs.error_north": "Enter a whole number between 0° and 359°.",
"gs.opacity_of": "{name} opacity"
"gs.opacity_of": "{name} opacity",
"room.fill_inherit_hint": "Use the fill mode set for the whole space.",
"room.fill_following": "Following the space: {mode}.",
"room.fill_mode_label": "Fill mode",
"room.temp_min_label": "Minimum",
"room.temp_max_label": "Maximum",
"room.sizes_hint": "On top of the space-wide card size ({v}%).",
"room.error_temp_range": "Enter numbers for the comfort bounds.",
"room.error_name": "Enter a name or choose an area."
}
+9 -1
View File
@@ -57,5 +57,13 @@
"gs.sun_rays_hint": "Tracer les rayons du soleil à travers les ouvertures des fenêtres.",
"gs.error_glow_radius": "Saisissez un rayon supérieur à zéro.",
"gs.error_north": "Saisissez un nombre entier entre 0° et 359°.",
"gs.opacity_of": "Opacité : {name}"
"gs.opacity_of": "Opacité : {name}",
"room.fill_inherit_hint": "Utiliser le mode de remplissage de tout l’espace.",
"room.fill_following": "Comme l’espace : {mode}.",
"room.fill_mode_label": "Mode de remplissage",
"room.temp_min_label": "Minimum",
"room.temp_max_label": "Maximum",
"room.sizes_hint": "En plus de la taille des cartes de l’espace ({v} %).",
"room.error_temp_range": "Saisissez des nombres pour les bornes de confort.",
"room.error_name": "Saisissez un nom ou choisissez une zone."
}
+9 -1
View File
@@ -57,5 +57,13 @@
"gs.sun_rays_hint": "Рисовать солнечные лучи через оконные проёмы.",
"gs.error_glow_radius": "Введите радиус больше нуля.",
"gs.error_north": "Введите целое число от 0° до 359°.",
"gs.opacity_of": "Прозрачность: {name}"
"gs.opacity_of": "Прозрачность: {name}",
"room.fill_inherit_hint": "Использовать режим заливки всего пространства.",
"room.fill_following": "Как у пространства: {mode}.",
"room.fill_mode_label": "Режим заливки",
"room.temp_min_label": "Минимум",
"room.temp_max_label": "Максимум",
"room.sizes_hint": "Поверх размера карточек всего пространства ({v}%).",
"room.error_temp_range": "Введите числа для границ комфорта.",
"room.error_name": "Введите название или выберите зону."
}
-65
View File
@@ -1,65 +0,0 @@
import { html, nothing, type TemplateResult } from 'lit';
import type { I18nKey } from './i18n';
import { roomTempThresholdDraft, strictNumber } from './space-dialog';
interface RoomTemperatureControlsHost {
_roomTempMin: string;
_roomTempMax: string;
_t: (key: I18nKey) => string;
requestUpdate: () => unknown;
}
export interface RoomTemperatureControls {
valid: boolean;
template: TemplateResult | typeof nothing;
}
/** Lazy-editor UI for the optional per-room comfort bounds. */
export function roomTemperatureControls(
host: RoomTemperatureControlsHost,
effectiveFill: string,
spaceMin: number,
spaceMax: number,
help: TemplateResult | typeof nothing,
): RoomTemperatureControls {
const draft = roomTempThresholdDraft(host._roomTempMin, host._roomTempMax);
if (effectiveFill !== 'temp') return { valid: draft.valid, template: nothing };
const minInvalid = host._roomTempMin.trim() !== '' && strictNumber(host._roomTempMin) === null;
const maxInvalid = host._roomTempMax.trim() !== '' && strictNumber(host._roomTempMax) === null;
return {
valid: draft.valid,
template: html`<div class="roomtemprange">
<div class="roomtemprange-head">
<label>${host._t('room.temp_range_label')}</label>
${help}
</div>
<div class="roomtemprange-fields">
<input class="namein" type="number" step="0.5"
aria-label=${host._t('room.temp_range_min')}
aria-invalid=${minInvalid ? 'true' : 'false'}
placeholder=${String(spaceMin)} .value=${host._roomTempMin}
@input=${(e: Event) => {
host._roomTempMin = (e.target as HTMLInputElement).value;
host.requestUpdate();
}} />
<span aria-hidden="true">—</span>
<input class="namein" type="number" step="0.5"
aria-label=${host._t('room.temp_range_max')}
aria-invalid=${maxInvalid ? 'true' : 'false'}
placeholder=${String(spaceMax)} .value=${host._roomTempMax}
@input=${(e: Event) => {
host._roomTempMax = (e.target as HTMLInputElement).value;
host.requestUpdate();
}} />
<span>°C</span>
${host._roomTempMin.trim() || host._roomTempMax.trim()
? html`<button class="btn ghost" type="button" @click=${() => {
host._roomTempMin = '';
host._roomTempMax = '';
host.requestUpdate();
}}>${host._t('room.temp_range_reset')}</button>`
: nothing}
</div>
</div>`,
};
}
-56
View File
@@ -390,31 +390,6 @@ export const dialogsStyles = css`
display: flex;
align-items: center;
gap: var(--sp-4);
}
.roomtemprange {
display: grid;
gap: 8px;
min-width: 0;
}
.roomtemprange-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.roomtemprange-fields {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-width: 0;
}
.roomtemprange-fields input {
flex: 0 1 104px;
min-width: 72px;
}
.roomtemprange-fields input[aria-invalid='true'] {
border-color: var(--error-color, #db4437);
}
.gsrow > hp-color-opacity {
min-width: min(100%, 210px);
@@ -1474,37 +1449,6 @@ export const dialogsStyles = css`
width: 100%;
box-sizing: border-box;
}
/* Room settings contains long radio labels, two source pickers and live
previews, so it deliberately uses hp-dialog's medium width. Keep its
content shrinkable as well: the generic srcrow rule is nowrap because
compact switch rows need it, but here that created a horizontal scroll
box even on a wide desktop viewport. */
hp-dialog.roomdialog .body {
min-width: 0;
overflow-x: hidden;
}
hp-dialog.roomdialog .body > * {
min-width: 0;
max-width: 100%;
box-sizing: border-box;
}
hp-dialog.roomdialog .srcrow {
min-width: 0;
align-items: flex-start;
}
hp-dialog.roomdialog .srcrow > span:first-of-type {
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
line-height: 1.35;
}
hp-dialog.roomdialog .dropbtn > b,
hp-dialog.roomdialog .dropbtn > .ref {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
hp-dialog .row {
display: flex;
justify-content: flex-end;
+14
View File
@@ -437,6 +437,20 @@ function extrasCss(options: FormKitCssOptions): string {
.${p}-status { display: flex; align-items: center; gap: 6px; min-width: 0; margin-left: 8px; font-size: .875rem; color: var(${tokens.muted}); }
.${p}-status > .${p}-link { color: var(--warning-color, #b87940); }
.${p}-divider { border: 0; border-top: 1px solid var(${tokens.line}); margin: 4px 0; }
.${p}-picker { display: grid; gap: 6px; }
${form} .${p}-pick { display: flex; align-items: center; gap: 8px; text-align: left; }
${form} .${p}-pick > b { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.${p}-pick-ref { min-width: 0; font-size: .8125rem; color: var(${tokens.muted}); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.${p}-pick-ph { color: var(${tokens.muted}); }
.${p}-panel { padding: 8px; border: 1px solid var(${tokens.line}); border-radius: 9px; background: var(${tokens.canvas}); }
.${p}-list { display: flex; flex-direction: column; gap: 2px; max-height: 220px; margin-top: 6px; overflow: auto; }
.${p}-cand { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; width: 100%; min-height: 44px; padding: 8px 10px; border: 0; border-radius: 7px; background: none; color: var(--primary-text-color); font: inherit; font-size: .875rem; text-align: left; cursor: pointer; }
.${p}-cand > small { font-size: .8125rem; color: var(${tokens.muted}); }
.${p}-cand:hover { background: var(${tokens.surface}); }
.${p}-cand.sel { background: var(${tokens.tint}); color: var(${tokens.accent}); }
.${p}-cand:focus-visible { outline: 2px solid var(${tokens.accent}); outline-offset: -2px; }
.${p}-templegend { display: flex; justify-content: space-between; margin-top: -6px; font-size: .8125rem; color: var(${tokens.muted}); }
.${p}-preview { display: flex; justify-content: center; }
@media (max-width: 480px) {
.${p}-body { padding: 0 14px 14px; }
.${p}-head { padding: 14px 14px 12px; }