mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 14:39:22 +00:00
General settings colour tiles (room fills and glow) paint their colour at its opacity over the checkerboard again, so "No light sources" at 0 % no longer looks like solid grey. The label ink is chosen from the visible colour (hex mixed with the checkerboard by alpha). Colour plates (flat-swatch without cover-swatch) stay solid — now witnessed by the - hp-color-opacity: one `_solidSwatch` condition drives background and opacity; cover-swatch keeps the checkerboard - editors/color-tile-ink.ts: pure isLightHex/colorTileInk, unit-tested - smoke_dialog_polish_605: tileAlpha, defaultsDiffer, noneInkDark, liveAlpha, platesFlat, plateSolid; oneSurface drops the opaque check - mutants M-615-tile, M-615-plate, M-615-label - USER-GUIDE (ru/en): "Colour tile" row; both changelogs Issue: #615 User-Visible: yes
638 lines
30 KiB
TypeScript
638 lines
30 KiB
TypeScript
/**
|
||
* Разметка общего набора контролов формы (#594, шаг 1 эпика #591; расширен в
|
||
* #600 до полного набора референса `docs/design/600-settings-dialogs/`).
|
||
*
|
||
* Контролы ничего не знают о конкретном диалоге: на вход подпись, значение,
|
||
* обработчик и опции — на выход `TemplateResult`. Состояние остаётся на хосте,
|
||
* как и у вынесенных диалогов (#592 К2).
|
||
*
|
||
* Стили — в `src/styles/form-kit.styles.ts`; имена классов там и здесь связаны
|
||
* префиксом `hpf`. Каждый примитив соответствует поверхности из §3.1 `SPEC.md`
|
||
* референса; расхождение с ним — находка, а не вкус.
|
||
*/
|
||
import { html, nothing, type TemplateResult } from 'lit';
|
||
|
||
import { CARD_DIALOG_FORM_KIT, cardDialogFormKitTokens, formKitCss } from '../styles/form-kit.styles';
|
||
import { colorTileInk } from './color-tile-ink';
|
||
|
||
/**
|
||
* Лист набора живёт в ленивом редакторском графе, а не в `cardStyles` (#594 AC8).
|
||
*
|
||
* Замер был однозначным: в синхронном графе набор дал +1459 Б gzip и пробил
|
||
* потолок initial View на 415 Б. Диалоги — инструмент администратора, во View их
|
||
* не открывают никогда, поэтому лист приезжает вместе с редактором тем же
|
||
* способом, которым это делает боковая панель: `adoptedStyleSheets` с откатом
|
||
* на `<style>` там, где конструируемых листов нет.
|
||
*/
|
||
const FORM_KIT_CSS = cardDialogFormKitTokens
|
||
+ formKitCss(CARD_DIALOG_FORM_KIT, { withSwitch: false });
|
||
const adopted = new WeakSet<ShadowRoot>();
|
||
let sheet: CSSStyleSheet | null = null;
|
||
|
||
export interface FormKitStyleHost {
|
||
renderRoot: DocumentFragment | HTMLElement;
|
||
}
|
||
|
||
/**
|
||
* Идемпотентно вносит лист набора в теневой корень карточки.
|
||
*
|
||
* Документ берётся у самого корня, а не у хоста: тип хоста редактора его не
|
||
* обещает, а `ShadowRoot` всегда знает свой документ.
|
||
*/
|
||
export function ensureFormKitStyles(host: FormKitStyleHost): void {
|
||
const root = host.renderRoot as ShadowRoot;
|
||
if (!root || adopted.has(root)) return;
|
||
const doc = root.ownerDocument ?? globalThis.document;
|
||
const Sheet = doc?.defaultView?.CSSStyleSheet;
|
||
if (Sheet && 'adoptedStyleSheets' in root) {
|
||
if (!sheet) { sheet = new Sheet(); sheet.replaceSync(FORM_KIT_CSS); }
|
||
root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
|
||
adopted.add(root);
|
||
return;
|
||
}
|
||
if (root.querySelector('style[data-hp-form-kit]')) { adopted.add(root); return; }
|
||
const style = doc.createElement('style');
|
||
style.dataset.hpFormKit = 'true';
|
||
style.textContent = FORM_KIT_CSS;
|
||
root.appendChild(style);
|
||
adopted.add(root);
|
||
}
|
||
|
||
export interface FormCardOptions {
|
||
/** Заголовок группы. Пустой заголовок — карточка без шапки. */
|
||
title?: string;
|
||
/** Кнопка «?» — уже готовый `hp-help` от вызывающего. */
|
||
help?: TemplateResult | typeof nothing;
|
||
/** Содержимое карточки. */
|
||
body: TemplateResult | readonly (TemplateResult | typeof nothing)[];
|
||
/** Машинное имя карточки для смоков и парных кадров (`data-card`). */
|
||
id?: string;
|
||
}
|
||
|
||
/**
|
||
* Карточка-группа: подложка, заголовок и справка рядом с ним (§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}
|
||
<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;
|
||
/** Действие справа от контрола — например «Сбросить». */
|
||
action?: TemplateResult | typeof nothing;
|
||
}
|
||
|
||
/** Однострочный ряд: подпись слева, контрол справа. */
|
||
export function formRow({ label, control, action }: FormRowOptions): TemplateResult {
|
||
return html`<div class="hpf-row">
|
||
<span class="hpf-label">${label}</span>
|
||
${control}${action ?? nothing}
|
||
</div>`;
|
||
}
|
||
|
||
export interface SegmentedOption<T extends string> {
|
||
value: T;
|
||
label: string;
|
||
/** Образец линии слева от подписи (стиль нулевых стен). */
|
||
sample?: 'dashed' | 'solid';
|
||
/** mdi-иконка слева от подписи. */
|
||
icon?: string;
|
||
/** Отключённый вариант при живой группе (режим свечения без источника). */
|
||
disabled?: boolean;
|
||
/** `aria-describedby` варианта — ссылка на объяснение, почему он отключён. */
|
||
describedBy?: string;
|
||
}
|
||
|
||
export interface SegmentedOptions<T extends string> {
|
||
/** Имя группы радиокнопок — общее для всех вариантов. */
|
||
name: string;
|
||
value: T;
|
||
options: readonly SegmentedOption<T>[];
|
||
onChange: (value: T) => void;
|
||
/** Доступное имя группы для скринридера. */
|
||
ariaLabel?: string;
|
||
disabled?: boolean;
|
||
}
|
||
|
||
/**
|
||
* Сегментированный переключатель.
|
||
*
|
||
* Под капотом — обычная группа радиокнопок, и это не деталь реализации, а
|
||
* требование: стрелки, Tab и объявление скринридером обязаны работать так же,
|
||
* как в списке, который сегмент заменил (#594 К7). Визуальная форма живёт в
|
||
* CSS, семантика — в разметке.
|
||
*/
|
||
export function segmented<T extends string>({
|
||
name, value, options, onChange, ariaLabel, disabled = false,
|
||
}: SegmentedOptions<T>): TemplateResult {
|
||
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} value=${option.value} .checked=${option.value === value}
|
||
?disabled=${disabled || !!option.disabled} aria-describedby=${option.describedBy ?? nothing}
|
||
@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.toUpperCase()}</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 ColorTileOptions {
|
||
label: string;
|
||
/** Текущий цвет `#rrggbb` — фон плитки и печать hex. */
|
||
hex: string;
|
||
opacity: number;
|
||
opacityLabel: string;
|
||
/** Готовый `hp-color-opacity` (Q5): свотч открывает существующую панель. */
|
||
picker: TemplateResult;
|
||
onOpacity: (opacity: number) => void;
|
||
}
|
||
|
||
export { isLightHex } from './color-tile-ink';
|
||
|
||
/**
|
||
* Плитка цвета (§3.1 «Плитки цвета»): название поверх свотча на всю ширину,
|
||
* ниже hex и число прозрачности. Свотч — trigger существующего пикера.
|
||
*
|
||
* #615: поверхность рисует сам trigger (`cover-swatch`) — шахматка и цвет
|
||
* поверх неё с непрозрачностью α, поэтому у обёртки своего фона нет. Подпись
|
||
* выбирается по видимому цвету (`colorTileInk`), а не по одному `hex`.
|
||
*/
|
||
export function colorTile({ label, hex, opacity, opacityLabel, picker, onOpacity }: ColorTileOptions): TemplateResult {
|
||
const pct = Math.round(Math.min(1, Math.max(0, opacity)) * 100);
|
||
return html`<div class="hpf-colortile">
|
||
<div class="hpf-colortile-swatch" style=${`color:${colorTileInk(hex, opacity)}`}>
|
||
${picker}<span>${label}</span>
|
||
</div>
|
||
<div class="hpf-colortile-meta">
|
||
<code class="hpf-hex">${hex.toUpperCase()}</code>
|
||
<span class="hpf-unit" title=${opacityLabel}><input type="number" inputmode="numeric" min="0" max="100" step="1"
|
||
.value=${String(pct)} aria-label=${opacityLabel}
|
||
@input=${(e: Event) => {
|
||
const n = Number((e.target as HTMLInputElement).value);
|
||
if (Number.isFinite(n)) onOpacity(Math.min(1, Math.max(0, n / 100)));
|
||
}} /><span>%</span></span>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
/** Ряд плиток цвета: три колонки, на узком экране две. */
|
||
export function colorTiles(tiles: readonly TemplateResult[]): TemplateResult {
|
||
return html`<div class="hpf-colortiles">${tiles}</div>`;
|
||
}
|
||
|
||
export interface ColorRowOptions {
|
||
label: string;
|
||
help?: TemplateResult | typeof nothing;
|
||
/** Готовая плашка `colorField` (или сам `hp-color-opacity`). */
|
||
picker: TemplateResult;
|
||
action?: TemplateResult | typeof nothing;
|
||
}
|
||
|
||
/** Строка цвета (§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' | 'note';
|
||
id?: string;
|
||
}
|
||
|
||
/**
|
||
* Сообщение о состоянии (§3.1 «callout», К5): остаётся на виду, под «?» не
|
||
* уезжает. Это не пояснение к настройке, а сигнал, что прямо сейчас что-то
|
||
* сломано или изменится при сохранении.
|
||
*/
|
||
export function callout({ text, kind = 'info', icon, action, role, id }: CalloutOptions): TemplateResult {
|
||
return html`<div id=${id ?? nothing} 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;
|
||
/** `aria-describedby` — объяснение состояния поля (например, почему оно отключено). */
|
||
describedBy?: 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;
|
||
/** Необязательный атомарный commit для полей с промежуточным текстовым вводом. */
|
||
onChange?: (raw: string, input: HTMLInputElement) => void;
|
||
}
|
||
|
||
/** Числовое поле с единицей внутри рамки (§3.1 «unit-inside»). */
|
||
export function unitInput({
|
||
id, describedBy, value, unit, min, max, step, placeholder, ariaLabel, invalid, disabled, wide,
|
||
onInput, onChange,
|
||
}: UnitInputOptions): TemplateResult {
|
||
return html`<span class="hpf-unit ${wide ? 'hpf-unit-wide' : ''}">
|
||
<input id=${id ?? nothing} type="number" inputmode="decimal" .value=${value}
|
||
aria-describedby=${describedBy ?? nothing}
|
||
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)}
|
||
@change=${onChange
|
||
? (e: Event) => onChange((e.target as HTMLInputElement).value, e.target as HTMLInputElement)
|
||
: nothing} />
|
||
<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;
|
||
/** `id` числового поля — цель `<label for>` строки. */
|
||
id?: string;
|
||
}
|
||
|
||
function stepPrecision(step: number): number {
|
||
const text = String(step).toLowerCase();
|
||
if (!text.includes('e')) return (text.split('.')[1] || '').length;
|
||
const [coefficient, exponentText] = text.split('e');
|
||
const fraction = (coefficient.split('.')[1] || '').length;
|
||
return Math.max(0, fraction - Number(exponentText));
|
||
}
|
||
|
||
/** Convert a committed range-line draft into the exact value its slider can represent. */
|
||
export function committedRangeLineValue(
|
||
raw: string, min: number, max: number, step: number,
|
||
): number | null {
|
||
if (!raw.trim()) return null;
|
||
const parsed = Number(raw);
|
||
if (!Number.isFinite(parsed) || !Number.isFinite(step) || step <= 0) return null;
|
||
const clamped = Math.min(max, Math.max(min, parsed));
|
||
const snapped = min + Math.round((clamped - min) / step) * step;
|
||
const precision = stepPrecision(step);
|
||
return Math.min(max, Math.max(min, Number(snapped.toFixed(precision))));
|
||
}
|
||
|
||
/** Слайдер + числовое поле с единицей в одну строку (§3.1 «range-line»). */
|
||
export function rangeLine({ min, max, step, value, unit, ariaLabel, disabled, slider, onInput, id }: RangeLineOptions): TemplateResult {
|
||
return html`<div class="hpf-range">
|
||
${slider}
|
||
${unitInput({
|
||
id, value: String(value), unit, min, max, step, ariaLabel, disabled,
|
||
// A partial string is not product state. Commit it atomically on change;
|
||
// Lit preserves it across renders while the confirmed value is stable,
|
||
// but an intervening slider change produces a new value and wins.
|
||
onInput: () => undefined,
|
||
onChange: (raw, input) => {
|
||
const next = committedRangeLineValue(raw, min, max, step);
|
||
if (next === null) {
|
||
input.value = String(value);
|
||
return;
|
||
}
|
||
input.value = String(next);
|
||
onInput(next);
|
||
},
|
||
})}
|
||
</div>`;
|
||
}
|
||
|
||
/** Подписи концов шкалы под слайдером. */
|
||
export function rangeEnds(from: string, to: string): TemplateResult {
|
||
return html`<div class="hpf-range-ends" aria-hidden="true">
|
||
<span class="hpf-range-ends-track"><span>${from}</span><span>${to}</span></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 {
|
||
/** Необязательный текст статуса; не дублирует состояние кнопки Save. */
|
||
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" />`;
|
||
|
||
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;
|
||
/** Дополнение к строке поиска в панели (например, чекбокс «Show entities»). */
|
||
toolbar?: TemplateResult | typeof nothing;
|
||
/** Отключённая кнопка: панель не открывается. */
|
||
disabled?: boolean;
|
||
}
|
||
|
||
/**
|
||
* Кнопка выбора источника с панелью в потоке (§3.1 «Кнопка выбора источника»):
|
||
* кнопка в стиле селекта, под ней поиск и список кандидатов. Панель не
|
||
* всплывает поверх — раздвигает карточку, скроллер диалога один (К3).
|
||
*/
|
||
export function sourcePicker({
|
||
open, current, placeholder, ariaLabel, filter, filterPlaceholder, candidates, selected, emptyText,
|
||
onToggle, onFilter, onPick, id, toolbar, disabled = false,
|
||
}: 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} ?disabled=${disabled} @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">
|
||
<div class="hpf-toolbar">
|
||
<input class="hpf-input" type="text" autocomplete="off" placeholder=${filterPlaceholder}
|
||
aria-label=${filterPlaceholder} .value=${filter}
|
||
@input=${(e: Event) => onFilter((e.target as HTMLInputElement).value)} />
|
||
${toolbar ?? nothing}
|
||
</div>
|
||
<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>`;
|
||
}
|