mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
180 lines
8.2 KiB
TypeScript
180 lines
8.2 KiB
TypeScript
/**
|
|
* #616: компактная шапка на телефоне (≤ 480 px, docs/UX-MODES.md).
|
|
*
|
|
* В строке остаются вкладки пространств, зум и одна шестерёнка; всё прочее из
|
|
* шапки — пункты её меню, в фиксированном порядке и ровно с теми же действиями,
|
|
* что у прежних кнопок. Состав меню — чистая функция (`headerMenuItems`),
|
|
* проверяется юнитом; раскрытие, закрытие и видимость активной вкладки —
|
|
* `HeaderMenu`. На > 480 px кнопка и меню в разметке есть, но скрыты CSS
|
|
* (`chrome.styles.ts`), прежние кнопки видны — поэтому поведение широкой
|
|
* шапки не зависит от этого модуля.
|
|
*/
|
|
import { html, nothing, type TemplateResult } from 'lit';
|
|
import type { I18nKey } from './i18n';
|
|
|
|
export type HeaderMode = 'view' | 'plan' | 'devices' | 'decor';
|
|
|
|
export type HeaderMenuItem = {
|
|
/** Стабильный id пункта: `data-id` в разметке, ключ в тестах. */
|
|
id: string;
|
|
label: string;
|
|
run: () => void;
|
|
icon?: string;
|
|
glyph?: TemplateResult;
|
|
/** Переключатель: состояние для `aria-pressed`. */
|
|
pressed?: boolean;
|
|
/** Текущий редактор: `aria-current`. */
|
|
current?: boolean;
|
|
};
|
|
|
|
export type HeaderMenuActions = {
|
|
setMode(mode: Exclude<HeaderMode, 'view'>): void;
|
|
configureSpace(): void;
|
|
addSpace(): void;
|
|
settings(): void;
|
|
pdf(): void;
|
|
support(): void;
|
|
};
|
|
|
|
export type HeaderMenuInput = {
|
|
canEdit: boolean;
|
|
kiosk: boolean;
|
|
mode: HeaderMode;
|
|
hasFixedFloor: boolean;
|
|
/** Пункты сводной панели; мобильное меню добавляет их только в просмотре. */
|
|
summary: HeaderMenuItem[];
|
|
t: (key: I18nKey) => string;
|
|
actions: HeaderMenuActions;
|
|
};
|
|
|
|
const EDITORS = [['plan', 'mdi:floor-plan'], ['devices', 'mdi:tune-variant'], ['decor', 'mdi:draw']] as const;
|
|
|
|
/**
|
|
* Состав меню по роли и режиму (ТЗ #616 п.4). Условия — те же, что у
|
|
* прежних кнопок шапки: редакторы, пространство и настройки — `canEdit`;
|
|
* «Добавить пространство» — ещё и без фиксированного пространства; сводная
|
|
* панель — только в просмотре; 2.5D включается в «Общих настройках» (#649), пункта в меню нет; в киоске меню нет.
|
|
*/
|
|
export function headerMenuItems(input: HeaderMenuInput): HeaderMenuItem[] {
|
|
if (input.kiosk) return [];
|
|
const { t, actions } = input;
|
|
const items: HeaderMenuItem[] = [];
|
|
if (input.canEdit) {
|
|
for (const [mode, icon] of EDITORS) {
|
|
items.push({
|
|
id: `mode-${mode}`, icon, label: t(`mode.${mode}` as I18nKey),
|
|
current: input.mode === mode, run: () => actions.setMode(mode),
|
|
});
|
|
}
|
|
items.push({ id: 'space-settings', icon: 'mdi:cog-outline', label: t('title.configure_space'), run: actions.configureSpace });
|
|
if (!input.hasFixedFloor) {
|
|
items.push({ id: 'space-add', icon: 'mdi:plus', label: t('title.add_space'), run: actions.addSpace });
|
|
}
|
|
items.push(
|
|
{ id: 'settings', icon: 'mdi:cog-outline', label: t('title.general_settings'), run: actions.settings },
|
|
{ id: 'pdf', icon: 'mdi:printer-outline', label: t('title.export_pdf'), run: actions.pdf },
|
|
{ id: 'support', icon: 'mdi:help-circle-outline', label: t('support.title'), run: actions.support },
|
|
);
|
|
}
|
|
if (input.mode === 'view') items.push(...input.summary);
|
|
return items;
|
|
}
|
|
|
|
/** What the menu needs from the card: a re-render and its shadow root — not the editor host port. */
|
|
type MenuOwner = { requestUpdate(): void; renderRoot: ParentNode };
|
|
|
|
export class HeaderMenu {
|
|
open = false;
|
|
private revealedFor = '';
|
|
private readonly onKey = (event: KeyboardEvent): void => {
|
|
if (event.key !== 'Escape' || !this.open) return;
|
|
event.stopPropagation();
|
|
this.close(true);
|
|
};
|
|
|
|
constructor(private readonly owner: MenuOwner) {}
|
|
|
|
toggle(): void {
|
|
if (this.open) this.close(false);
|
|
else {
|
|
this.open = true;
|
|
window.addEventListener('keydown', this.onKey, true);
|
|
this.owner.requestUpdate();
|
|
}
|
|
}
|
|
|
|
close(focusButton: boolean): void {
|
|
if (!this.open) return;
|
|
this.open = false;
|
|
window.removeEventListener('keydown', this.onKey, true);
|
|
this.owner.requestUpdate();
|
|
if (focusButton) {
|
|
(this.owner.renderRoot.querySelector('[data-hp="header-menu"]') as HTMLElement | null)?.focus();
|
|
}
|
|
}
|
|
|
|
disconnect(): void {
|
|
this.open = false;
|
|
window.removeEventListener('keydown', this.onKey, true);
|
|
}
|
|
|
|
/**
|
|
* Вкладки на телефоне прокручиваются в одну строку. Активная вкладка
|
|
* докручивается в видимую область один раз на каждую смену пространства —
|
|
* не на каждом рендере: иначе обновление hass отнимало бы у человека
|
|
* ручную прокрутку вкладок.
|
|
*/
|
|
revealActiveTab(): void {
|
|
const nav = this.owner.renderRoot.querySelector('.head .tabs') as HTMLElement | null;
|
|
const tab = nav?.querySelector('.tab.active') as HTMLElement | null;
|
|
if (!nav || !tab || nav.clientWidth === 0) return;
|
|
const id = tab.dataset.id || '';
|
|
if (id === this.revealedFor) return;
|
|
this.revealedFor = id;
|
|
const box = nav.getBoundingClientRect();
|
|
const rect = tab.getBoundingClientRect();
|
|
if (rect.left < box.left) nav.scrollLeft -= box.left - rect.left;
|
|
else if (rect.right > box.right) nav.scrollLeft += rect.right - box.right;
|
|
}
|
|
|
|
render(items: HeaderMenuItem[], label: string): TemplateResult | typeof nothing {
|
|
if (!items.length) return nothing;
|
|
const run = (item: HeaderMenuItem) => { this.close(false); item.run(); };
|
|
return html`<span class="header-menu-wrap">
|
|
<button class="btn header-menu-button ${this.open ? 'on' : ''}" data-hp="header-menu"
|
|
aria-haspopup="true" aria-expanded=${this.open ? 'true' : 'false'} aria-controls="hp-header-menu"
|
|
title=${label} aria-label=${label} @click=${() => this.toggle()}>
|
|
<ha-icon icon="mdi:cog-outline"></ha-icon>
|
|
</button>
|
|
${this.open ? html`<div class="header-menu-scrim" data-hp="header-menu-scrim"
|
|
@click=${(event: Event) => { event.stopPropagation(); this.close(false); }}></div>
|
|
<div class="header-menu" id="hp-header-menu" role="group" aria-label=${label}>
|
|
${items.map((item) => html`<button type="button" class="header-menu-item ${item.current || item.pressed ? 'on' : ''}"
|
|
data-hp="header-menu-item" data-id=${item.id}
|
|
aria-current=${item.current ? 'true' : nothing}
|
|
aria-pressed=${item.pressed === undefined ? nothing : item.pressed ? 'true' : 'false'}
|
|
@click=${() => run(item)}>
|
|
${item.glyph ?? html`<ha-icon icon=${item.icon ?? 'mdi:circle-small'}></ha-icon>`}<span>${item.label}</span>
|
|
</button>`)}
|
|
</div>` : nothing}
|
|
</span>`;
|
|
}
|
|
}
|
|
|
|
/** Прежние кнопки шапки (> 480 px): вынесены сюда, чтобы шапка карточки не росла. */
|
|
export function renderHeaderActions(
|
|
t: (key: I18nKey) => string, open: { settings(): void; pdf(): void; support(): void },
|
|
): TemplateResult {
|
|
return html`<button class="btn header-action settings-button" data-hp="settings" @click=${open.settings} title=${t('title.general_settings')}>
|
|
<ha-icon icon="mdi:cog-outline"></ha-icon>
|
|
</button>
|
|
<button class="btn header-action pdf-button" data-hp="pdf" @click=${open.pdf}
|
|
title=${t('title.export_pdf')} aria-label=${t('title.export_pdf')}>
|
|
<ha-icon icon="mdi:printer-outline"></ha-icon>
|
|
</button>
|
|
<button class="btn header-action support-button" data-hp="support" @click=${open.support}
|
|
title=${t('support.title')} aria-label=${t('support.title')}>
|
|
<ha-icon icon="mdi:help-circle-outline"></ha-icon>
|
|
</button>`;
|
|
}
|