mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 06:59:46 +00:00
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:
+64
-2
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": "без имени",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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": "Введите название или выберите зону."
|
||||
}
|
||||
|
||||
@@ -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>`,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user