mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 14:39:22 +00:00
feat(form-kit): набор контролов по референсу и оболочка диалогов настроек
Серия 0 из пяти по #600. Ничего из четырёх диалогов ещё не переложено — эта серия даёт им инструменты и оболочку. Набор (`form-kit.ts` + `form-kit.styles.ts`), числа из §3.1 SPEC.md референса, цвета через переменные темы HA: - полная строка настройки `toggleRow` — сетка 28/1fr/44: иконка, название + «?», подпись, тумблер справа; тумблер — нативный чекбокс, нарисованный CSS, как у редактора панели: один вид в HA, на стенде и в golden, и всё тот же `input[type=checkbox]` для смоков; компактный вариант для однотипных списков; - `field`, `fieldGrid` (две колонки и 1fr/175), `subsection`, `unitInput`, `rangeLine`, `rangeEnds`, `radioRow`, `choiceCards`; - `valueTiles` (4 колонки), плитки цвета в CSS; `colorField` — компактная плашка вокруг существующего `hp-color-opacity` (Q5): свотч открывает ту же панель, рядом hex, число прозрачности и Reset; нативного пикера нет; - `callout` (К5: сообщения о состоянии остаются на виду), `compass` 44 px, `footerStatus`, `chip`, `tintBlock`, `textLink`, `divider`; - `segmented` получил образцы линии, иконки и `disabled`; вид — как в референсе: контейнер на канве, активный сегмент на тинте. Оболочка (`hp-dialog.ts`, атрибут `form-shell` у четырёх диалогов настроек): ширина 560 через `--hp-dialog-wide-width`, которую `:host([wide])` уже читал; высота min(940px, 100vh − 48); единственный скроллер — `.content`, тело формы высотой не владеет (К6, дефект 7 из issue); «✕» 44 px; бейдж имени пространства в заголовке (`badge`). Generic `wide` и остальные диалоги не тронуты (Q7). `hp-dialog[form-shell] .body` — без max-height и overflow, канва 16 px. Фикстуры листа диалогов `test/fixtures/form-kit-card-dialog*.css` пересняты намеренно; лист панели совпал с замороженным побайтово (К9, AC11). `editor.loading` → «Загрузка…» в четырёх локалях (дефект 9); aria-текст прежний. Потолок ленивого редакторского графа 222 900 → 225 900 с доводом в `bundle-budget.mjs`: рост — CSS-строки набора, не код; значение промежуточное, серии 1–4 выкидывают старые правила, итог замеряется в конце. Гейты: typecheck, build, bundle:sync, `npm test` (2791 pass; красными были только потолок — поднят — и неотслеженные файлы манифеста — добавлены), `smoke_settings_dialog_cards`, `smoke_room_settings`. Issue: #600 User-Visible: no
This commit is contained in:
+350
-14
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* Разметка общего набора контролов формы (#594, шаг 1 эпика #591).
|
||||
* Разметка общего набора контролов формы (#594, шаг 1 эпика #591; расширен в
|
||||
* #600 до полного набора референса `docs/design/600-settings-dialogs/`).
|
||||
*
|
||||
* Контролы ничего не знают о конкретном диалоге: на вход подпись, значение,
|
||||
* обработчик и опции — на выход `TemplateResult`. Состояние остаётся на хосте,
|
||||
* как и у вынесенных диалогов (#592 К2).
|
||||
*
|
||||
* Стили — в `src/styles/form-kit.styles.ts`; имена классов там и здесь связаны
|
||||
* префиксом `hpf`.
|
||||
* префиксом `hpf`. Каждый примитив соответствует поверхности из §3.1 `SPEC.md`
|
||||
* референса; расхождение с ним — находка, а не вкус.
|
||||
*/
|
||||
import { html, nothing, type TemplateResult } from 'lit';
|
||||
|
||||
@@ -62,18 +64,98 @@ export interface FormCardOptions {
|
||||
help?: TemplateResult | typeof nothing;
|
||||
/** Содержимое карточки. */
|
||||
body: TemplateResult | readonly (TemplateResult | typeof nothing)[];
|
||||
/** Машинное имя карточки для смоков и парных кадров (`data-card`). */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/** Карточка-группа: белая подложка, заголовок и справка рядом с ним. */
|
||||
export function formCard({ title, help, body }: FormCardOptions): TemplateResult {
|
||||
return html`<section class="hpf-card">
|
||||
/**
|
||||
* Карточка-группа: подложка, заголовок и справка рядом с ним (§3.1 «Тело»).
|
||||
* Заголовок — `h3`, подзаголовки внутри — `h4` (`subsection`): уровни
|
||||
* вложены, как и в референсе (h2 → h3), только на ступень ниже, потому что
|
||||
* заголовок диалога уже занимает верхний.
|
||||
*/
|
||||
export function formCard({ title, help, body, id }: FormCardOptions): TemplateResult {
|
||||
return html`<section class="hpf-card" data-card=${id ?? nothing}>
|
||||
${title
|
||||
? html`<div class="hpf-head"><h3>${title}</h3>${help ?? nothing}</div>`
|
||||
: nothing}
|
||||
${body}
|
||||
<div class="hpf-body">${body}</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
/** Подзаголовок группы внутри карточки (§3.1 «Подзаголовки»): 16/600 + «?». */
|
||||
export function subsection({ title, help }: { title: string; help?: TemplateResult | typeof nothing }): TemplateResult {
|
||||
return html`<div class="hpf-sub"><h4>${title}</h4>${help ?? nothing}</div>`;
|
||||
}
|
||||
|
||||
export interface FieldOptions {
|
||||
label: string;
|
||||
/** `id` контрола — подпись становится `<label for>`. */
|
||||
htmlFor?: string;
|
||||
help?: TemplateResult | typeof nothing;
|
||||
control: TemplateResult | typeof nothing;
|
||||
/** Подсказка под контролом (§3.1 «Поля»: 14 px, приглушённая). */
|
||||
hint?: string | TemplateResult | typeof nothing;
|
||||
/** Текст ошибки; при наличии подпись и контрол помечаются. */
|
||||
error?: string | typeof nothing;
|
||||
}
|
||||
|
||||
/** Поле: подпись сверху, контрол, подсказка или ошибка снизу. */
|
||||
export function field({ label, htmlFor, help, control, hint, error }: FieldOptions): TemplateResult {
|
||||
return html`<div class="hpf-field">
|
||||
<span class="hpf-label hpf-labelrow">${htmlFor ? html`<label for=${htmlFor}>${label}</label>` : label}${help ?? nothing}</span>
|
||||
${control}
|
||||
${error ? html`<p class="hpf-error" role="alert">${error}</p>` : hint ? html`<p class="hpf-hint">${hint}</p>` : nothing}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Сетка полей: две равные колонки или `1fr / 175px` (`narrow`). */
|
||||
export function fieldGrid(
|
||||
children: readonly (TemplateResult | typeof nothing)[], { narrow = false }: { narrow?: boolean } = {},
|
||||
): TemplateResult {
|
||||
return html`<div class="hpf-grid ${narrow ? 'hpf-grid-narrow' : ''}">${children}</div>`;
|
||||
}
|
||||
|
||||
export interface ToggleRowOptions {
|
||||
/** mdi-иконка слева (§3.2 «Иконки строк»); без неё строка узкая. */
|
||||
icon?: string;
|
||||
title: string;
|
||||
caption?: string | TemplateResult;
|
||||
help?: TemplateResult | typeof nothing;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
/** Компактная строка для однотипных списков (§3.1 «Компактная строка»). */
|
||||
compact?: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
/** `id` чекбокса — для смоков и `aria-describedby`. */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Полная строка настройки (§3.1): иконка · название + «?» · подпись · тумблер
|
||||
* справа. Тумблер — нативный чекбокс, нарисованный CSS, как у редактора
|
||||
* панели: один и тот же вид в HA, на стенде и в golden, и `input[type=checkbox]`
|
||||
* остаётся тем, что смоки умеют переключать.
|
||||
*/
|
||||
export function toggleRow({
|
||||
icon, title, caption, help, checked, disabled = false, compact = false, onChange, id,
|
||||
}: ToggleRowOptions): TemplateResult {
|
||||
const captionId = id && caption ? `${id}-caption` : undefined;
|
||||
return html`<label class="hpf-toggle ${icon ? '' : 'hpf-noicon'} ${compact ? 'hpf-compact' : ''}">
|
||||
${icon ? html`<span class="hpf-toggle-icon" aria-hidden="true"><ha-icon icon=${icon}></ha-icon></span>` : nothing}
|
||||
<span class="hpf-toggle-title"><span>${title}</span>${help ?? nothing}</span>
|
||||
${caption && !compact ? html`<span class="hpf-toggle-caption" id=${captionId ?? nothing}>${caption}</span>` : nothing}
|
||||
<input type="checkbox" id=${id ?? nothing} .checked=${checked} ?disabled=${disabled}
|
||||
aria-label=${title} aria-describedby=${captionId ?? nothing}
|
||||
@change=${(e: Event) => onChange((e.target as HTMLInputElement).checked)} />
|
||||
</label>`;
|
||||
}
|
||||
|
||||
/** Список компактных строк без разделителей. */
|
||||
export function compactList(rows: readonly TemplateResult[]): TemplateResult {
|
||||
return html`<div class="hpf-compact-list">${rows}</div>`;
|
||||
}
|
||||
|
||||
export interface FormRowOptions {
|
||||
label: string;
|
||||
control: TemplateResult | typeof nothing;
|
||||
@@ -92,6 +174,10 @@ export function formRow({ label, control, action }: FormRowOptions): TemplateRes
|
||||
export interface SegmentedOption<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
/** Образец линии слева от подписи (стиль нулевых стен). */
|
||||
sample?: 'dashed' | 'solid';
|
||||
/** mdi-иконка слева от подписи. */
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface SegmentedOptions<T extends string> {
|
||||
@@ -102,6 +188,7 @@ export interface SegmentedOptions<T extends string> {
|
||||
onChange: (value: T) => void;
|
||||
/** Доступное имя группы для скринридера. */
|
||||
ariaLabel?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,28 +200,277 @@ export interface SegmentedOptions<T extends string> {
|
||||
* CSS, семантика — в разметке.
|
||||
*/
|
||||
export function segmented<T extends string>({
|
||||
name, value, options, onChange, ariaLabel,
|
||||
name, value, options, onChange, ariaLabel, disabled = false,
|
||||
}: SegmentedOptions<T>): TemplateResult {
|
||||
return html`<div class="hpf-seg" role="radiogroup" aria-label=${ariaLabel ?? nothing}>
|
||||
return html`<div class="hpf-seg" role="radiogroup" aria-label=${ariaLabel ?? nothing}
|
||||
aria-disabled=${disabled ? 'true' : nothing}>
|
||||
${options.map((option) => html`<label>
|
||||
<input type="radio" name=${name} .checked=${option.value === value}
|
||||
<input type="radio" name=${name} .checked=${option.value === value} ?disabled=${disabled}
|
||||
@change=${() => onChange(option.value)} />
|
||||
${option.sample ? html`<span class="hpf-line ${option.sample === 'dashed' ? 'hpf-line-dashed' : ''}" aria-hidden="true"></span>` : nothing}
|
||||
${option.icon ? html`<ha-icon icon=${option.icon} aria-hidden="true"></ha-icon>` : nothing}
|
||||
<span>${option.label}</span>
|
||||
</label>`)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export interface ValueTile {
|
||||
label: string;
|
||||
icon: string;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
/** Полное имя для `title` и `aria-label`, если подпись сокращена. */
|
||||
title?: string;
|
||||
onChange: (checked: boolean) => void;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Плитки значений (§3.1): четыре колонки, иконка сверху, подпись снизу;
|
||||
* каждая — `label` со скрытым чекбоксом, так что клавиатура и скринридер
|
||||
* работают как у списка чекбоксов, который плитки заменили.
|
||||
*/
|
||||
export function valueTiles(tiles: readonly ValueTile[], ariaLabel?: string): TemplateResult {
|
||||
return html`<div class="hpf-tiles" role="group" aria-label=${ariaLabel ?? nothing}>
|
||||
${tiles.map((tile) => html`<label class="hpf-tile" title=${tile.title ?? nothing}>
|
||||
<input type="checkbox" id=${tile.id ?? nothing} .checked=${tile.checked} ?disabled=${tile.disabled}
|
||||
aria-label=${tile.title ?? tile.label}
|
||||
@change=${(e: Event) => tile.onChange((e.target as HTMLInputElement).checked)} />
|
||||
<ha-icon icon=${tile.icon} aria-hidden="true"></ha-icon>
|
||||
<span>${tile.label}</span>
|
||||
</label>`)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export interface ColorFieldOptions {
|
||||
/** Готовый `hp-color-opacity` от вызывающего: компонент не подменяется (Q5). */
|
||||
picker: TemplateResult;
|
||||
/** Текущий цвет `#rrggbb` — печатается рядом со свотчем. */
|
||||
hex: string;
|
||||
/** Прозрачность 0–1; `undefined` — поле без прозрачности. */
|
||||
opacity?: number;
|
||||
opacityLabel?: string;
|
||||
onOpacity?: (opacity: number) => void;
|
||||
/** Текстовая ссылка «Сбросить»; `undefined` — без сброса. */
|
||||
resetLabel?: string;
|
||||
onReset?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Компактная плашка цвета (§3.1 «Поле цвета», решение владельца Q5): свотч —
|
||||
* это trigger существующего `hp-color-opacity` и открывает его панель; рядом
|
||||
* hex, число прозрачности и Reset. Нативного `input[type=color]` здесь нет и
|
||||
* не будет: `nativeColors() === 0` — контракт со свидетелем.
|
||||
*/
|
||||
export function colorField({
|
||||
picker, hex, opacity, opacityLabel, onOpacity, resetLabel, onReset, disabled = false,
|
||||
}: ColorFieldOptions): TemplateResult {
|
||||
const pct = opacity === undefined ? null : Math.round(Math.min(1, Math.max(0, opacity)) * 100);
|
||||
return html`<div class="hpf-colorfield">
|
||||
${picker}
|
||||
<code class="hpf-hex">${hex}</code>
|
||||
${pct === null ? nothing : html`<span class="hpf-opacity">${opacityLabel ?? ''}
|
||||
<span class="hpf-unit"><input type="number" inputmode="numeric" min="0" max="100" step="1"
|
||||
.value=${String(pct)} ?disabled=${disabled} aria-label=${opacityLabel ?? 'Opacity'}
|
||||
@input=${(e: Event) => {
|
||||
const n = Number((e.target as HTMLInputElement).value);
|
||||
if (Number.isFinite(n) && onOpacity) onOpacity(Math.min(1, Math.max(0, n / 100)));
|
||||
}} /><span>%</span></span></span>`}
|
||||
${resetLabel && onReset ? html`<button class="hpf-link" type="button" ?disabled=${disabled} @click=${onReset}>${resetLabel}</button>` : nothing}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export interface ColorRowOptions {
|
||||
label: string;
|
||||
/** Готовый `hp-color-opacity` от вызывающего: компонент не подменяется. */
|
||||
help?: TemplateResult | typeof nothing;
|
||||
/** Готовая плашка `colorField` (или сам `hp-color-opacity`). */
|
||||
picker: TemplateResult;
|
||||
action?: TemplateResult | typeof nothing;
|
||||
}
|
||||
|
||||
/** Строка цвета: подпись, плашка пикера и необязательное действие рядом. */
|
||||
export function colorRow({ label, picker, action }: ColorRowOptions): TemplateResult {
|
||||
return html`<div class="hpf-row hpf-color">
|
||||
<span class="hpf-label">${label}</span>
|
||||
/** Строка цвета (§3.1 «gs-color-row»): подпись слева, плашка справа. */
|
||||
export function colorRow({ label, help, picker, action }: ColorRowOptions): TemplateResult {
|
||||
return html`<div class="hpf-colorrow">
|
||||
<span class="hpf-labelrow">${label}${help ?? nothing}</span>
|
||||
${picker}${action ?? nothing}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export interface RadioRowOptions<T extends string> {
|
||||
label: string;
|
||||
help?: TemplateResult | typeof nothing;
|
||||
name: string;
|
||||
value: T;
|
||||
options: readonly SegmentedOption<T>[];
|
||||
onChange: (value: T) => void;
|
||||
}
|
||||
|
||||
/** Радио в строку (§3.1): подпись слева, варианты справа. */
|
||||
export function radioRow<T extends string>({ label, help, name, value, options, onChange }: RadioRowOptions<T>): TemplateResult {
|
||||
return html`<div class="hpf-inline hpf-wrap">
|
||||
<span class="hpf-labelrow">${label}${help ?? nothing}</span>
|
||||
<div class="hpf-radios" role="radiogroup" aria-label=${label}>
|
||||
${options.map((o) => html`<label><input type="radio" name=${name} .checked=${o.value === value}
|
||||
@change=${() => onChange(o.value)} /><span>${o.label}</span></label>`)}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export interface CalloutOptions {
|
||||
text: string | TemplateResult;
|
||||
kind?: 'info' | 'warning';
|
||||
icon?: string;
|
||||
/** Ссылка-действие в конце текста. */
|
||||
action?: TemplateResult | typeof nothing;
|
||||
role?: 'status' | 'alert';
|
||||
}
|
||||
|
||||
/**
|
||||
* Сообщение о состоянии (§3.1 «callout», К5): остаётся на виду, под «?» не
|
||||
* уезжает. Это не пояснение к настройке, а сигнал, что прямо сейчас что-то
|
||||
* сломано или изменится при сохранении.
|
||||
*/
|
||||
export function callout({ text, kind = 'info', icon, action, role }: CalloutOptions): TemplateResult {
|
||||
return html`<div class="hpf-callout ${kind === 'warning' ? 'hpf-warning' : ''}" role=${role ?? nothing}>
|
||||
<ha-icon icon=${icon ?? (kind === 'warning' ? 'mdi:alert-outline' : 'mdi:information-outline')}></ha-icon>
|
||||
<p>${text}${action ? html` ${action}` : nothing}</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export interface UnitInputOptions {
|
||||
id?: string;
|
||||
value: string;
|
||||
unit: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number | 'any';
|
||||
placeholder?: string;
|
||||
ariaLabel?: string;
|
||||
invalid?: boolean;
|
||||
disabled?: boolean;
|
||||
/** Растянуть на всю ширину контейнера. */
|
||||
wide?: boolean;
|
||||
onInput: (raw: string) => void;
|
||||
}
|
||||
|
||||
/** Числовое поле с единицей внутри рамки (§3.1 «unit-inside»). */
|
||||
export function unitInput({
|
||||
id, value, unit, min, max, step, placeholder, ariaLabel, invalid, disabled, wide, onInput,
|
||||
}: UnitInputOptions): TemplateResult {
|
||||
return html`<span class="hpf-unit ${wide ? 'hpf-unit-wide' : ''}">
|
||||
<input id=${id ?? nothing} type="number" inputmode="decimal" .value=${value}
|
||||
min=${min ?? nothing} max=${max ?? nothing} step=${step ?? nothing}
|
||||
placeholder=${placeholder ?? nothing} aria-label=${ariaLabel ?? nothing}
|
||||
aria-invalid=${invalid ? 'true' : nothing} ?disabled=${disabled}
|
||||
@input=${(e: Event) => onInput((e.target as HTMLInputElement).value)} />
|
||||
<span>${unit}</span>
|
||||
</span>`;
|
||||
}
|
||||
|
||||
export interface RangeLineOptions {
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
value: number;
|
||||
unit: string;
|
||||
ariaLabel: string;
|
||||
disabled?: boolean;
|
||||
/** Слайдер от вызывающего (`_rangeInput`: ha-slider или range) — компонент не подменяется. */
|
||||
slider: TemplateResult;
|
||||
onInput: (value: number) => void;
|
||||
}
|
||||
|
||||
/** Слайдер + числовое поле с единицей в одну строку (§3.1 «range-line»). */
|
||||
export function rangeLine({ min, max, value, unit, ariaLabel, disabled, slider, onInput }: RangeLineOptions): TemplateResult {
|
||||
return html`<div class="hpf-range">
|
||||
${slider}
|
||||
${unitInput({
|
||||
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)));
|
||||
},
|
||||
})}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Подписи концов шкалы под слайдером. */
|
||||
export function rangeEnds(from: string, to: string): TemplateResult {
|
||||
return html`<div class="hpf-range-ends" aria-hidden="true"><span>${from}</span><span>${to}</span></div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Компас 44 px (§3.1): «N» на уровне подписи, стрелка поворачивается на
|
||||
* итоговый угол; `null` — направление не задано, стрелка приглушена.
|
||||
*/
|
||||
export function compass(deg: number | null, label: string): TemplateResult {
|
||||
const angle = deg === null ? 0 : ((deg % 360) + 360) % 360;
|
||||
return html`<span class="hpf-compass ${deg === null ? 'hpf-unset' : ''}" role="img" aria-label=${label}>
|
||||
<svg viewBox="0 0 24 24" style=${`transform: rotate(${angle}deg)`} aria-hidden="true">
|
||||
<path d="M12 3v18M12 3l-4 7h8z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</span>`;
|
||||
}
|
||||
|
||||
/** Текстовая ссылка-действие (Reset, Clear, «Use general settings»). */
|
||||
export function textLink(label: string, onClick: () => void, { disabled = false }: { disabled?: boolean } = {}): TemplateResult {
|
||||
return html`<button class="hpf-link" type="button" ?disabled=${disabled} @click=${onClick}>${label}</button>`;
|
||||
}
|
||||
|
||||
export interface FooterStatusOptions {
|
||||
/** Текст статуса: «Unsaved changes» или пусто. */
|
||||
text?: string;
|
||||
/** Ссылка «Review N fields» — ведёт к первому ошибочному полю. */
|
||||
action?: TemplateResult | typeof nothing;
|
||||
}
|
||||
|
||||
/** Статус в футере (§3.3): между группами действий, `aria-live`. */
|
||||
export function footerStatus({ text, action }: FooterStatusOptions): TemplateResult {
|
||||
return html`<span class="hpf-status" role="status" aria-live="polite">${text ?? ''}${action ?? nothing}</span>`;
|
||||
}
|
||||
|
||||
export interface ChipOptions {
|
||||
icon?: string;
|
||||
label: string;
|
||||
removeLabel: string;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
/** Чип выбранного элемента с кнопкой удаления (§3.1 «Чипы»). */
|
||||
export function chip({ icon, label, removeLabel, onRemove }: ChipOptions): TemplateResult {
|
||||
return html`<span class="hpf-chip">
|
||||
${icon ? html`<ha-icon icon=${icon} aria-hidden="true"></ha-icon>` : nothing}
|
||||
<span>${label}</span>
|
||||
<button type="button" aria-label=${removeLabel} title=${removeLabel} @click=${onRemove}><ha-icon icon="mdi:close"></ha-icon></button>
|
||||
</span>`;
|
||||
}
|
||||
|
||||
export interface ChoiceCard<T extends string> {
|
||||
value: T;
|
||||
title: string;
|
||||
caption: string;
|
||||
}
|
||||
|
||||
/** Две choice-карточки в ряд (§4.2 «Floor plan»): радио с заголовком и подписью. */
|
||||
export function choiceCards<T extends string>(
|
||||
name: string, value: T, options: readonly ChoiceCard<T>[], onChange: (value: T) => void, ariaLabel?: string,
|
||||
): TemplateResult {
|
||||
return html`<div class="hpf-choices" role="radiogroup" aria-label=${ariaLabel ?? nothing}>
|
||||
${options.map((o) => html`<label class="hpf-choice">
|
||||
<input type="radio" name=${name} .checked=${o.value === value} @change=${() => onChange(o.value)} />
|
||||
<strong>${o.title}</strong><small>${o.caption}</small>
|
||||
</label>`)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Блок на тинте с заголовком и тегом (Display preview). */
|
||||
export function tintBlock({ title, tag, body }: { title: string; tag?: string; body: TemplateResult | typeof nothing }): TemplateResult {
|
||||
return html`<div class="hpf-tint">
|
||||
<div class="hpf-tint-head"><h4>${title}</h4>${tag ? html`<span class="hpf-tag">${tag}</span>` : nothing}</div>
|
||||
${body}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Единственный разрешённый разделитель внутри карточки (§3.2). */
|
||||
export const divider = (): TemplateResult => html`<hr class="hpf-divider" />`;
|
||||
|
||||
@@ -24,7 +24,7 @@ import type { HouseplanEditorRuntime } from '../houseplan-editor-runtime';
|
||||
|
||||
export function renderGeneralSettingsDialog(this: HouseplanEditorRuntime): TemplateResult {
|
||||
ensureFormKitStyles(this.host);
|
||||
return html`<hp-dialog .hass=${this.host.hass} data-kind="settings" .title=${this.host._t('gs.title')} icon="mdi:cog-outline" wide
|
||||
return html`<hp-dialog .hass=${this.host.hass} data-kind="settings" form-shell .title=${this.host._t('gs.title')} icon="mdi:cog-outline" wide
|
||||
@hp-close=${() => (this.host._settingsDialog = null)}>
|
||||
<div class="body hpf-form">
|
||||
${formCard({
|
||||
|
||||
@@ -131,7 +131,7 @@ export function renderMarkerDialog(this: HouseplanEditorRuntime): TemplateResult
|
||||
if (k === 'device') return this.host._fullRegistryHass.devices[ref]?.name_by_user || this.host._fullRegistryHass.devices[ref]?.name || ref;
|
||||
return this.host._fullRegistryHass.entities[ref]?.name || this.host.hass.states[ref]?.attributes?.friendly_name || ref;
|
||||
})();
|
||||
return html`<hp-dialog id="marker-dialog" .hass=${this.host.hass} data-kind="marker"
|
||||
return html`<hp-dialog id="marker-dialog" .hass=${this.host.hass} data-kind="marker" form-shell
|
||||
.title=${d.devId ? this.host._t('info.device_header') : this.host._t('marker.new_device')}
|
||||
icon="mdi:shape-plus" wide @hp-close=${() => this._closeMarkerDialog()}>
|
||||
<div class="body hpf-form">
|
||||
|
||||
@@ -109,7 +109,7 @@ export function renderRoomSettingsDialog(this: HouseplanEditorRuntime): Template
|
||||
const cur = this.host.hass.areas[this.host._areaSel];
|
||||
if (cur) areas.unshift(cur);
|
||||
}
|
||||
return html`<hp-dialog class="roomdialog" .hass=${this.host.hass} data-kind="room" wide
|
||||
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()}>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function renderSpaceSettingsDialog(this: HouseplanEditorRuntime): Templat
|
||||
this.host._importQueue = [];
|
||||
this.host._importTotal = 0;
|
||||
};
|
||||
return html`<hp-dialog .hass=${this.host.hass} data-kind="space"
|
||||
return html`<hp-dialog .hass=${this.host.hass} data-kind="space" form-shell
|
||||
.title=${`${d.mode === 'create' ? this.host._t('space.new') : this.host._t('space.header')}${progress ? ` · ${progress}` : ''}`}
|
||||
icon="mdi:floor-plan" wide @hp-close=${close}>
|
||||
<div class="body hpf-form">
|
||||
|
||||
@@ -44,6 +44,10 @@ export class HpDialog extends LitElement {
|
||||
// at the never-scrolling child and neither wheel nor touch reaches HA's
|
||||
// scroller. `flex-content` forwards ha-dialog's public `flexcontent`.
|
||||
flexContent: { type: Boolean, reflect: true, attribute: 'flex-content' },
|
||||
// #600 §3.1 «Шапка»: бейдж с именем пространства справа от заголовка и
|
||||
// признак «диалог настроек» — оболочка референса (560, один скроллер).
|
||||
badge: { type: String },
|
||||
formShell: { type: Boolean, reflect: true, attribute: 'form-shell' },
|
||||
hass: { attribute: false },
|
||||
};
|
||||
|
||||
@@ -116,6 +120,42 @@ export class HpDialog extends LitElement {
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
/* #600 §3.1: бейдж имени пространства — 14 px, радиус 7, обрезка 180 px. */
|
||||
.badge {
|
||||
flex: none;
|
||||
max-width: 180px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 7px;
|
||||
background: rgb(127 127 127 / 0.16);
|
||||
color: var(--secondary-text-color, inherit);
|
||||
font-size: .875rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* #600: диалоги настроек (атрибут form-shell) — оболочка референса. Ширина 560
|
||||
через переменную, которую уже читает :host([wide]); высота min(940,
|
||||
окно − 48); единственный скроллер — .content, тело формы высотой не
|
||||
владеет (К6). Generic wide и остальные диалоги не трогаются (Q7). */
|
||||
:host([form-shell]) { --hp-dialog-wide-width: 560px; }
|
||||
:host([form-shell]) .surface { max-height: min(940px, calc(100vh - 48px)); }
|
||||
:host([form-shell]) .content {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: contain;
|
||||
background: var(--secondary-background-color, color-mix(in srgb, var(--card-background-color, var(--hp-bg, #202126)) 90%, var(--primary-text-color, #000)));
|
||||
}
|
||||
:host([form-shell]) .close { width: 44px; height: 44px; }
|
||||
:host([form-shell]) .header { min-height: 70px; }
|
||||
@media (max-width: 480px) {
|
||||
:host([form-shell]) .surface { width: 100vw; max-height: 100vh; border: 0; border-radius: 0; }
|
||||
:host([form-shell]) .badge { max-width: 105px; }
|
||||
}
|
||||
|
||||
.footer {
|
||||
/* ha-dialog lays out its footer slot as a flex row. display: contents
|
||||
exposed the consumer's action row as a shrink-to-fit flex item, so a
|
||||
@@ -228,6 +268,8 @@ export class HpDialog extends LitElement {
|
||||
|
||||
title = '';
|
||||
icon = '';
|
||||
badge = '';
|
||||
formShell = false;
|
||||
wide = false;
|
||||
alert = false;
|
||||
describedBy = '';
|
||||
@@ -504,6 +546,7 @@ export class HpDialog extends LitElement {
|
||||
const title = html`<span class="title" id=${this._titleId}>
|
||||
${this.icon ? html`<ha-icon icon=${this.icon}></ha-icon>` : nothing}
|
||||
<span class="title-text">${this.title}</span>
|
||||
${this.badge ? html`<span class="badge" title=${this.badge}>${this.badge}</span>` : nothing}
|
||||
</span>`;
|
||||
|
||||
if (this._usesHaDialog()) {
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"editor.open_group": "Werkzeuggruppe: {group}",
|
||||
"editor.group_active": "{group} — aktiv: {item}",
|
||||
"editor.disabled_action": "{action} ist nicht verfügbar: {reason}",
|
||||
"editor.loading": "Editor wird geladen…",
|
||||
"editor.loading": "Wird geladen…",
|
||||
"editor.loading_aria": "Der Plan-Editor wird geladen",
|
||||
"editor.load_failed": "Der Editor konnte nicht geladen werden.",
|
||||
"editor.refresh_advice": "Seite aktualisieren und erneut versuchen.",
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"editor.open_group": "Tool group: {group}",
|
||||
"editor.group_active": "{group} — active: {item}",
|
||||
"editor.disabled_action": "{action} is unavailable: {reason}",
|
||||
"editor.loading": "Loading editor…",
|
||||
"editor.loading": "Loading…",
|
||||
"editor.loading_aria": "The plan editor is loading",
|
||||
"editor.load_failed": "Could not load the editor.",
|
||||
"editor.refresh_advice": "Refresh the page and try again.",
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"editor.open_group": "Groupe d’outils : {group}",
|
||||
"editor.group_active": "{group} — actif : {item}",
|
||||
"editor.disabled_action": "{action} est indisponible : {reason}",
|
||||
"editor.loading": "Chargement de l'éditeur…",
|
||||
"editor.loading": "Chargement…",
|
||||
"editor.loading_aria": "L'éditeur de plan est en cours de chargement",
|
||||
"editor.load_failed": "Impossible de charger l'éditeur.",
|
||||
"editor.refresh_advice": "Actualisez la page et réessayez.",
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"editor.open_group": "Группа инструментов: {group}",
|
||||
"editor.group_active": "{group} — активно: {item}",
|
||||
"editor.disabled_action": "{action} недоступно: {reason}",
|
||||
"editor.loading": "Загружаем редактор…",
|
||||
"editor.loading": "Загрузка…",
|
||||
"editor.loading_aria": "Редактор плана загружается",
|
||||
"editor.load_failed": "Не удалось загрузить редактор.",
|
||||
"editor.refresh_advice": "Обновите страницу и повторите попытку.",
|
||||
|
||||
@@ -704,6 +704,19 @@ export const dialogsStyles = css`
|
||||
max-height: 66vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
/* #600 К6: у диалогов настроек (атрибут form-shell) скроллит оболочка (.content в
|
||||
hp-dialog), тело формы высотой не владеет — иначе на классических полосах
|
||||
Windows видны две. Канва — --secondary-background-color, карточки на ней
|
||||
белые (§3.1 «Тело»). */
|
||||
hp-dialog[form-shell] .body {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
hp-dialog[form-shell] .body { padding: 12px; }
|
||||
}
|
||||
hp-confirm {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
+319
-33
@@ -22,8 +22,12 @@ export interface FormKitCssOptions {
|
||||
cards: readonly string[];
|
||||
/** Класс футера — его кнопки получают ту же обводку фокуса. */
|
||||
footer: string;
|
||||
/** Имена переменных темы: поверхность, линия, акцент. */
|
||||
tokens: { surface: string; line: string; accent: string };
|
||||
/** Имена переменных темы: поверхность, линия, акцент, а для диалогов ещё
|
||||
* канва, приглушённый текст, тинт активного состояния и опасность. */
|
||||
tokens: {
|
||||
surface: string; line: string; accent: string;
|
||||
canvas: string; muted: string; tint: string; tintLine: string; danger: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Параметры панели: с ними генератор обязан вернуть её нынешние правила. */
|
||||
@@ -36,12 +40,22 @@ export const SUMMARY_PANEL_FORM_KIT: FormKitCssOptions = {
|
||||
surface: '--summary-editor-surface',
|
||||
line: '--summary-editor-line',
|
||||
accent: '--summary-editor-accent',
|
||||
// Панель дополнительных токенов не использует: её лист собирается из общих
|
||||
// фрагментов, а extras она не получает. Имена стоят, чтобы тип был полным.
|
||||
canvas: '--summary-editor-surface',
|
||||
muted: '--secondary-text-color',
|
||||
tint: '--summary-editor-surface',
|
||||
tintLine: '--summary-editor-line',
|
||||
danger: '--error-color',
|
||||
},
|
||||
};
|
||||
|
||||
/** Параметры диалогов карточки. */
|
||||
export const CARD_DIALOG_FORM_KIT: FormKitCssOptions = {
|
||||
form: '.hpf-form',
|
||||
// `hp-dialog .hpf-form`, а не `.hpf-form`: generic-правила диалогов
|
||||
// (`hp-dialog .body { display: flex }`, `hp-dialog .body label { … }`) имеют
|
||||
// ту же специфичность, и без тега набор проигрывал бы им на display и подписях.
|
||||
form: 'hp-dialog .hpf-form',
|
||||
prefix: 'hpf',
|
||||
cards: ['.hpf-card'],
|
||||
footer: '.hpf-footer',
|
||||
@@ -49,6 +63,11 @@ export const CARD_DIALOG_FORM_KIT: FormKitCssOptions = {
|
||||
surface: '--hpf-surface',
|
||||
line: '--hpf-line',
|
||||
accent: '--hpf-accent',
|
||||
canvas: '--hpf-canvas',
|
||||
muted: '--hpf-muted',
|
||||
tint: '--hpf-tint',
|
||||
tintLine: '--hpf-tint-line',
|
||||
danger: '--hpf-danger',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -119,23 +138,74 @@ function sharedCss(options: FormKitCssOptions, withSwitch: boolean): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Контролы, которых у панели нет: заголовок группы со справкой, сегментированный
|
||||
* переключатель и строка цвета. Сегмент — настоящая группа радиокнопок: стрелки
|
||||
* и Tab обязаны работать так же, как в списке, который он заменил (#594 К7).
|
||||
* Контролы, которых у панели нет (#594, расширено в #600 по референсу
|
||||
* `docs/design/600-settings-dialogs/reference/styles.css`).
|
||||
*
|
||||
* Числа — из §3.1 `SPEC.md` референса; цвета — через переменные темы HA, а не
|
||||
* hex прототипа: тёмная тема обязательна. Всё здесь видит только ленивый
|
||||
* редакторский граф — панель эти правила не получает (#597 К9).
|
||||
*/
|
||||
function extrasCss(options: FormKitCssOptions): string {
|
||||
const { form, prefix, tokens } = options;
|
||||
return ` ${form} { display: grid; gap: 12px; }
|
||||
.${prefix}-head {
|
||||
const p = prefix;
|
||||
return ` ${form} { display: grid; gap: 16px; }
|
||||
${form} label { margin: 0; font-size: inherit; color: inherit; }
|
||||
${form} .${p}-card { padding: 0; overflow: hidden; }
|
||||
.${p}-head {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin: 0 0 10px;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding: 16px 16px 12px;
|
||||
}
|
||||
.${prefix}-head h3 { margin: 0; font-size: 1rem; line-height: 1.3; font-weight: 600; }
|
||||
.${prefix}-row {
|
||||
.${p}-head h3 { margin: 0; font-size: 1.25rem; line-height: 1.3; font-weight: 600; min-width: 0; overflow-wrap: anywhere; }
|
||||
.${p}-body { display: grid; gap: 15px; padding: 0 16px 16px; }
|
||||
.${p}-body:first-child { padding-top: 16px; }
|
||||
.${p}-sub { display: flex; align-items: center; gap: 4px; min-height: 28px; margin: 7px 0 -3px; }
|
||||
.${p}-sub h4 { margin: 0; font-size: 1rem; line-height: 1.4; font-weight: 600; }
|
||||
.${p}-field { display: grid; gap: 7px; min-width: 0; }
|
||||
.${p}-field > .${p}-label,
|
||||
.${p}-labelrow { display: flex; align-items: center; gap: 4px; min-height: 28px; font-size: .875rem; font-weight: 600; color: var(${tokens.muted}); }
|
||||
.${p}-labelrow > label { cursor: pointer; }
|
||||
.${p}-hint { margin: 0; font-size: .875rem; line-height: 1.5; color: var(${tokens.muted}); }
|
||||
.${p}-error { margin: 0; font-size: .875rem; line-height: 1.5; color: var(${tokens.danger}); }
|
||||
.${p}-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
.${p}-grid.${p}-grid-narrow { grid-template-columns: minmax(0, 1fr) 175px; }
|
||||
${form} .${p}-input,
|
||||
${form} .${p}-select {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(${tokens.line});
|
||||
border-radius: 7px;
|
||||
background: var(${tokens.surface});
|
||||
color: var(--primary-text-color);
|
||||
font: inherit;
|
||||
font-size: .875rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
${form} .${p}-select {
|
||||
appearance: none;
|
||||
padding-right: 32px;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='7' viewBox='0 0 12 7'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%23888' stroke-width='1.7' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-position: right 10px center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 12px 7px;
|
||||
cursor: pointer;
|
||||
}
|
||||
${form} .${p}-input:hover, ${form} .${p}-select:hover { border-color: var(${tokens.muted}); }
|
||||
${form} .${p}-input:focus, ${form} .${p}-select:focus { border-color: var(${tokens.accent}); outline: none; }
|
||||
${form} [aria-invalid="true"] { border-color: var(${tokens.danger}) !important; }
|
||||
.${p}-unit { display: inline-flex; align-items: center; height: 44px; border: 1px solid var(${tokens.line}); border-radius: 7px; background: var(${tokens.surface}); }
|
||||
.${p}-unit > input { box-sizing: border-box; width: 5.5em; min-width: 5.5em; height: 100%; padding: 8px 9px; border: 0; border-radius: 7px; background: none; color: var(--primary-text-color); font: inherit; font-size: .875rem; font-variant-numeric: tabular-nums; }
|
||||
.${p}-unit > input:focus { outline: 0; }
|
||||
.${p}-unit > span { padding: 0 11px 0 3px; white-space: nowrap; color: var(${tokens.muted}); font-size: .875rem; }
|
||||
.${p}-unit:focus-within { outline: 2px solid var(${tokens.accent}); outline-offset: 2px; }
|
||||
.${p}-unit.${p}-unit-wide, .${p}-unit.${p}-unit-wide > input { width: 100%; min-width: 0; }
|
||||
.${p}-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
@@ -143,38 +213,247 @@ function extrasCss(options: FormKitCssOptions): string {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.${prefix}-row > .${prefix}-label { min-width: 0; overflow-wrap: anywhere; }
|
||||
.${prefix}-seg {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
border: 1px solid var(${tokens.line});
|
||||
border-radius: 9px;
|
||||
overflow: hidden;
|
||||
.${p}-row > .${p}-label { min-width: 0; overflow-wrap: anywhere; }
|
||||
.${p}-toggle {
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr) 44px;
|
||||
grid-template-rows: auto auto;
|
||||
align-items: center;
|
||||
column-gap: 10px;
|
||||
row-gap: 4px;
|
||||
min-height: 58px;
|
||||
margin: 0;
|
||||
padding: 11px 0;
|
||||
color: var(--primary-text-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
.${prefix}-seg label {
|
||||
.${p}-toggle.${p}-noicon { grid-template-columns: minmax(0, 1fr) 44px; }
|
||||
.${p}-toggle > .${p}-toggle-icon { grid-column: 1; grid-row: 1; display: flex; align-items: center; justify-content: center; width: 28px; color: var(${tokens.muted}); --mdc-icon-size: 19px; }
|
||||
.${p}-toggle > .${p}-toggle-title { grid-row: 1; display: flex; align-items: center; gap: 4px; min-width: 0; min-height: 28px; font-size: .875rem; font-weight: 600; line-height: 1.4; }
|
||||
.${p}-toggle > .${p}-toggle-title > span { min-width: 0; overflow-wrap: anywhere; }
|
||||
.${p}-toggle > .${p}-toggle-caption { grid-row: 2; margin: 0; font-size: .875rem; line-height: 1.5; color: var(${tokens.muted}); overflow-wrap: anywhere; }
|
||||
.${p}-toggle:not(.${p}-noicon) > .${p}-toggle-title, .${p}-toggle:not(.${p}-noicon) > .${p}-toggle-caption { grid-column: 2; }
|
||||
.${p}-toggle.${p}-noicon > .${p}-toggle-title, .${p}-toggle.${p}-noicon > .${p}-toggle-caption { grid-column: 1; }
|
||||
.${p}-toggle > input { grid-column: -2; grid-row: 1 / 3; align-self: center; }
|
||||
.${p}-toggle.${p}-compact { grid-template-rows: auto; min-height: 40px; padding: 2px 0; }
|
||||
.${p}-toggle.${p}-compact > .${p}-toggle-title { font-weight: 500; min-height: 24px; }
|
||||
.${p}-toggle.${p}-compact > .${p}-toggle-icon { --mdc-icon-size: 18px; }
|
||||
.${p}-toggle:has(input:disabled) { cursor: default; }
|
||||
.${p}-toggle:has(input:disabled) > .${p}-toggle-title, .${p}-toggle:has(input:disabled) > .${p}-toggle-caption { opacity: .65; }
|
||||
.${p}-compact-list { display: grid; gap: 2px; }
|
||||
.${p}-toggle > input {
|
||||
appearance: none;
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex: 0 0 44px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.${p}-toggle > input::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 36px;
|
||||
height: 22px;
|
||||
inset: 11px 4px;
|
||||
border: 1px solid var(${tokens.line});
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(${tokens.surface}) 65%, var(${tokens.muted}));
|
||||
}
|
||||
.${p}-toggle > input::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
top: 14px;
|
||||
left: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-primary-color, #fff);
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / 20%);
|
||||
transition: left .15s;
|
||||
}
|
||||
.${p}-toggle > input:checked::before { background: var(${tokens.accent}); border-color: var(${tokens.accent}); }
|
||||
.${p}-toggle > input:checked::after { left: 21px; }
|
||||
.${p}-toggle > input:disabled { cursor: default; }
|
||||
.${p}-toggle > input:disabled::before { opacity: .4; }
|
||||
.${p}-seg {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
gap: 3px;
|
||||
padding: 3px;
|
||||
border: 1px solid var(${tokens.line});
|
||||
border-radius: 7px;
|
||||
background: var(${tokens.canvas});
|
||||
}
|
||||
.${p}-seg label {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 38px;
|
||||
flex: 1 1 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
gap: 7px;
|
||||
padding: 4px 5px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5px;
|
||||
color: var(${tokens.muted});
|
||||
font-size: .875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.${prefix}-seg label + label { border-left: 1px solid var(${tokens.line}); }
|
||||
.${prefix}-seg input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||
.${prefix}-seg label:has(input:checked) {
|
||||
background: var(${tokens.accent});
|
||||
color: var(--text-primary-color, #fff);
|
||||
.${p}-seg input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; pointer-events: none; }
|
||||
.${p}-seg label:has(input:checked) {
|
||||
background: var(${tokens.tint});
|
||||
border-color: var(${tokens.tintLine});
|
||||
color: var(${tokens.accent});
|
||||
}
|
||||
.${prefix}-seg label:has(input:focus-visible) {
|
||||
.${p}-seg label:has(input:focus-visible) {
|
||||
outline: 2px solid var(${tokens.accent});
|
||||
outline-offset: -2px;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.${prefix}-color { display: flex; min-width: 0; align-items: center; gap: 12px; }
|
||||
.${prefix}-color hp-color-opacity { flex: 0 0 auto; }`;
|
||||
.${p}-seg[aria-disabled="true"] { opacity: .5; }
|
||||
.${p}-seg .${p}-line { width: 25px; border-top: 2px solid currentColor; }
|
||||
.${p}-seg .${p}-line-dashed { border-top-style: dashed; }
|
||||
.${p}-tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
|
||||
.${p}-tile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 72px;
|
||||
padding: 10px 6px 9px;
|
||||
border: 1px solid var(${tokens.line});
|
||||
border-radius: 8px;
|
||||
background: var(${tokens.surface});
|
||||
color: var(${tokens.muted});
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
--mdc-icon-size: 22px;
|
||||
}
|
||||
.${p}-tile input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; }
|
||||
.${p}-tile > span { font-size: .875rem; font-weight: 500; line-height: 1.25; }
|
||||
.${p}-tile:has(input:checked) { background: var(${tokens.tint}); border-color: var(${tokens.tintLine}); color: var(${tokens.accent}); }
|
||||
.${p}-tile:has(input:focus-visible) { outline: 2px solid var(${tokens.accent}); outline-offset: 2px; }
|
||||
.${p}-tile:has(input:disabled) { opacity: .45; cursor: default; }
|
||||
.${p}-tile:has(input:disabled) input { cursor: default; }
|
||||
.${p}-colortiles { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
|
||||
.${p}-colortile { display: grid; gap: 6px; min-width: 0; padding: 6px 6px 7px; border: 1px solid var(${tokens.line}); border-radius: 8px; background: var(${tokens.surface}); }
|
||||
.${p}-colortile > .${p}-colortile-swatch { display: flex; align-items: center; gap: 8px; min-height: 48px; padding: 3px 8px 3px 3px; border: 1px solid var(${tokens.line}); border-radius: 6px; font-size: .875rem; font-weight: 500; line-height: 1.25; }
|
||||
.${p}-colortile > .${p}-colortile-swatch > span { min-width: 0; overflow-wrap: anywhere; }
|
||||
.${p}-colortile > .${p}-colortile-swatch > hp-color-opacity { flex: none; }
|
||||
.${p}-colortile-meta { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
|
||||
.${p}-colortile-meta .${p}-hex { font-size: .8125rem; }
|
||||
.${p}-colortile-meta .${p}-unit { height: 32px; }
|
||||
.${p}-colortile-meta .${p}-unit > input { width: 3.2em; min-width: 3em; padding: 4px 4px 4px 7px; font-size: .8125rem; }
|
||||
.${p}-colortile-meta .${p}-unit > span { padding: 0 7px 0 0; font-size: .8125rem; }
|
||||
.${p}-colorfield {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 3px 10px 3px 3px;
|
||||
border: 1px solid var(${tokens.line});
|
||||
border-radius: 9px;
|
||||
background: var(${tokens.canvas});
|
||||
}
|
||||
.${p}-colorfield > hp-color-opacity { flex: 0 0 auto; }
|
||||
.${p}-hex { font-size: .875rem; color: var(${tokens.muted}); font-variant-numeric: tabular-nums; }
|
||||
.${p}-colorfield > .${p}-opacity { display: flex; align-items: center; gap: 8px; font-size: .875rem; color: var(${tokens.muted}); }
|
||||
.${p}-colorfield > .${p}-opacity > .${p}-unit { height: 38px; }
|
||||
.${p}-colorfield > .${p}-opacity > .${p}-unit > input { width: 4em; min-width: 4em; }
|
||||
.${p}-colorrow { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px 16px; min-height: 56px; }
|
||||
.${p}-colorrow > .${p}-colorfield { justify-self: end; }
|
||||
.${p}-colorrow > .${p}-labelrow { min-height: 0; }
|
||||
.${p}-link { padding: 5px 0; border: 0; background: none; color: var(${tokens.accent}); font: inherit; font-size: .875rem; font-weight: 500; cursor: pointer; }
|
||||
.${p}-link:disabled { opacity: .5; cursor: default; }
|
||||
.${p}-inline { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.${p}-inline > .${p}-labelrow { flex: 1 1 auto; }
|
||||
.${p}-inline > .${p}-select { width: auto; flex: 0 0 auto; min-width: 200px; }
|
||||
.${p}-inline.${p}-wrap { flex-wrap: wrap; row-gap: 6px; }
|
||||
.${p}-radios { display: flex; flex-wrap: wrap; gap: 4px 18px; margin-left: auto; }
|
||||
.${p}-radios label { display: inline-flex; align-items: center; gap: 7px; min-height: 28px; font-size: .875rem; cursor: pointer; white-space: nowrap; }
|
||||
.${p}-radios input { width: 16px; height: 16px; margin: 0; accent-color: var(${tokens.accent}); cursor: pointer; }
|
||||
.${p}-radios label:has(input:checked) { color: var(${tokens.accent}); }
|
||||
.${p}-callout {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 11px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(${tokens.tint});
|
||||
color: var(--primary-text-color);
|
||||
font-size: .875rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.${p}-callout > ha-icon { flex: none; margin-top: 2px; --mdc-icon-size: 16px; color: var(${tokens.accent}); }
|
||||
.${p}-callout > p, .${p}-callout > span { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||
.${p}-callout.${p}-warning { background: color-mix(in srgb, var(--warning-color, #ff9800) 14%, var(${tokens.surface})); }
|
||||
.${p}-callout.${p}-warning > ha-icon { color: var(--warning-color, #ff9800); }
|
||||
.${p}-callout .${p}-link { padding: 0; font-weight: 600; }
|
||||
.${p}-range { display: flex; align-items: center; gap: 15px; }
|
||||
.${p}-range > input[type="range"], .${p}-range > ha-slider { flex: 1 1 auto; min-width: 0; accent-color: var(${tokens.accent}); }
|
||||
.${p}-range > .${p}-unit { flex: 0 0 auto; }
|
||||
.${p}-range > .${p}-unit > input { width: 5.8em; min-width: 5.8em; }
|
||||
.${p}-range-ends { display: flex; justify-content: space-between; padding-right: 122px; margin-top: -8px; font-size: .875rem; color: var(${tokens.muted}); }
|
||||
.${p}-headline { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.${p}-north { display: grid; grid-template-columns: minmax(0, 1fr) 44px; gap: 7px 16px; align-items: center; }
|
||||
.${p}-north > .${p}-labelrow { grid-area: 1 / 1; min-height: 28px; }
|
||||
.${p}-north > .${p}-select, .${p}-north > .${p}-unit, .${p}-north > .${p}-input { grid-area: 2 / 1; }
|
||||
.${p}-north > .${p}-north-n { grid-area: 1 / 2; text-align: center; font-size: .875rem; line-height: 28px; color: var(${tokens.muted}); }
|
||||
.${p}-compass { grid-area: 2 / 2; display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; border: 1px solid var(${tokens.line}); border-radius: 50%; background: var(${tokens.canvas}); color: var(${tokens.accent}); }
|
||||
.${p}-compass svg { width: 24px; height: 24px; transform-origin: 50% 50%; transform-box: view-box; }
|
||||
.${p}-compass.${p}-unset { color: var(${tokens.muted}); }
|
||||
.${p}-tint { padding: 14px; border: 1px solid var(${tokens.tintLine}); border-radius: 9px; background: var(${tokens.tint}); }
|
||||
.${p}-tint-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 12px; }
|
||||
.${p}-tint-head h4 { margin: 0; font-size: 1rem; font-weight: 600; }
|
||||
.${p}-tag { padding: 3px 10px; border-radius: 12px; background: color-mix(in srgb, var(${tokens.accent}) 18%, var(${tokens.surface})); color: var(${tokens.muted}); font-size: .8125rem; }
|
||||
.${p}-chips { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.${p}-chip { display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 4px 0 10px; border: 1px solid var(${tokens.tintLine}); border-radius: 18px; background: var(${tokens.tint}); color: var(${tokens.accent}); font-size: .875rem; font-weight: 500; }
|
||||
.${p}-chip > ha-icon { --mdc-icon-size: 16px; }
|
||||
.${p}-chip > button { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: inherit; cursor: pointer; --mdc-icon-size: 14px; }
|
||||
.${p}-chip > button:hover { background: color-mix(in srgb, var(${tokens.accent}) 15%, transparent); }
|
||||
.${p}-choices { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.${p}-choice { position: relative; display: flex; flex-direction: column; justify-content: center; gap: 5px; min-width: 0; min-height: 80px; padding: 13px 35px 13px 12px; border: 1px solid var(${tokens.line}); border-radius: 8px; cursor: pointer; }
|
||||
.${p}-choice input { position: absolute; right: 12px; top: 16px; width: 16px; height: 16px; margin: 0; accent-color: var(${tokens.accent}); }
|
||||
.${p}-choice:has(input:checked) { background: var(${tokens.tint}); border-color: var(${tokens.tintLine}); }
|
||||
.${p}-choice strong { font-size: .875rem; font-weight: 600; }
|
||||
.${p}-choice small { font-size: .875rem; line-height: 1.5; color: var(${tokens.muted}); }
|
||||
.${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; }
|
||||
@media (max-width: 480px) {
|
||||
.${p}-body { padding: 0 14px 14px; }
|
||||
.${p}-head { padding: 14px 14px 12px; }
|
||||
.${p}-grid, .${p}-grid.${p}-grid-narrow, .${p}-choices { grid-template-columns: minmax(0, 1fr); }
|
||||
.${p}-tiles, .${p}-colortiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.${p}-colorrow { grid-template-columns: minmax(0, 1fr); }
|
||||
.${p}-colorrow > .${p}-colorfield { justify-self: start; }
|
||||
.${p}-inline { flex-direction: column; align-items: stretch; gap: 7px; }
|
||||
.${p}-inline > .${p}-select { width: 100%; }
|
||||
.${p}-radios { margin-left: 0; }
|
||||
.${p}-seg { flex-wrap: wrap; }
|
||||
.${p}-seg label { flex-basis: 42%; }
|
||||
.${p}-range-ends { padding-right: 125px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.${p}-toggle > input::after { transition: none; }
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
${form} .${p}-select { appearance: auto; background-image: none; padding-right: 10px; }
|
||||
}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,5 +474,12 @@ export const cardDialogFormKitTokens = ` hp-dialog .hpf-form {
|
||||
--hpf-surface: var(--card-background-color, #fff);
|
||||
--hpf-line: var(--hp-line, var(--divider-color, #dce5e7));
|
||||
--hpf-accent: var(--hp-accent, var(--primary-color));
|
||||
/* Канва без темы: на 10 % ближе к тексту, чем карточка, — светлая тема
|
||||
даёт серый, тёмная — чуть светлее карточки. HA всегда задаёт свою. */
|
||||
--hpf-canvas: var(--secondary-background-color, color-mix(in srgb, var(--hpf-surface) 90%, var(--primary-text-color, #000)));
|
||||
--hpf-muted: var(--secondary-text-color, #777);
|
||||
--hpf-tint: color-mix(in srgb, var(--hpf-accent) 10%, var(--hpf-surface));
|
||||
--hpf-tint-line: color-mix(in srgb, var(--hpf-accent) 38%, var(--hpf-surface));
|
||||
--hpf-danger: var(--error-color, #db543d);
|
||||
}
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user