/** * #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): 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` ${this.open ? html`
{ event.stopPropagation(); this.close(false); }}>
${items.map((item) => html``)}
` : nothing}
`; } } /** Прежние кнопки шапки (> 480 px): вынесены сюда, чтобы шапка карточки не росла. */ export function renderHeaderActions( t: (key: I18nKey) => string, open: { settings(): void; pdf(): void; support(): void }, ): TemplateResult { return html` `; }