Files
houseplan-card/demo/smoke_toolbar_stable_width.mjs
T
Claude 255330b9bc Шапка на телефоне: одна строка — вкладки, масштаб и шестерёнка с меню остального (#616)
На окне не шире 480 px шапка карточки и страницы бокового меню занимает одну
строку ≤ 56 px: вкладки пространств (без шестерёнок и «+», прокрутка вбок,
текущая вкладка докручивается в видимую область), масштаб, одна шестерёнка
«Действия и настройки» и у админа слот крестика #647. Заголовок скрыт.

Меню шестерёнки (src/header-menu.ts): редакторы, настройки и добавление
пространства, общие настройки, PDF, помощь, сводная панель и объёмный вид
(alpha) — в фиксированном порядке и с теми же условиями и действиями, что у
прежних кнопок. Пункт закрывает меню; Escape возвращает фокус на шестерёнку;
касание вне меню поглощается прозрачной подложкой. На > 480 px шапка прежняя,
в киоске меню нет.

- три кнопки шапки вынесены в renderHeaderActions — шапка карточки не растёт;
- сводная панель отдаёт свои пункты методом menuItems();
- i18n: title.header_menu (en/ru/de/fr);
- потолок initial View 291 000 → 292 600 (замер 291 635), база bundleBytes;
- смок smoke_mobile_view_header, юнит header-menu, 10 мутантов; смоки
  support_feedback, gear_tabs, modes, toolbar_stable_width переведены на
  меню на ≤ 480 px; подпись вкладки в span.tabtitle без pointer-events;
- USER-GUIDE.ru, UX-MODES, TOUCH-SUPPORT, STYLING-HOOKS, data-hp-contract,
  оба changelog.

Issue: #616
User-Visible: yes
2026-09-25 14:22:36 +03:00

123 lines
7.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// #647: основная панель — нет счётчика устройств, крестик редактора в своём
// слоте, ширина шапки и положение кнопок режимов не меняются при входе,
// выходе и переключении редакторов; кликабельная зона × не меньше 24 × 24
// (#195); × виден на обычных ширинах (721–1100 px — там раньше скрывался
// счётчик). Режимы переключаются настоящими кнопками через фасад #629;
// приватные поля карточки только читаются.
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1400, height: 820 }, 1);
const WIDTHS = [1400, 1000, 768, 390];
const out = {};
for (const width of WIDTHS) {
await page.setViewportSize({ width, height: 820 });
const r = await page.evaluate(async (width) => {
const o = {};
const c = window.__card;
const hp = window.__hpTest;
const sr = () => c.shadowRoot || c.renderRoot;
const head = () => sr().querySelector('.head');
const slot = () => sr().querySelector('.editor-close-slot');
const cross = () => sr().querySelector('.editor-close-slot .closex');
// #616: на ≤ 480 px кнопки режимов живут в меню шестерёнки; соседи × в строке —
// вкладки пространств, зум и шестерёнка, их положение и меряется.
const phone = width <= 480;
const tabs = () => [...sr().querySelectorAll(phone
? '.head > .tabs, .head > .zoomctl, [data-hp="header-menu"]' : '[data-hp="mode-tab"]')];
await hp.setMode('view');
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
// Позиции — относительно левого края шапки: прокрутка страницы их не меняет.
const measure = () => {
const origin = head().getBoundingClientRect().left;
let nextEl = slot()?.nextElementSibling;
while (nextEl && !nextEl.getClientRects().length) nextEl = nextEl.nextElementSibling;
return {
head: head().getBoundingClientRect().width,
tabs: tabs().map((t) => t.getBoundingClientRect().left - origin),
slot: slot()?.getBoundingClientRect(),
next: nextEl ? nextEl.getBoundingClientRect().left - origin : null,
};
};
const visibleTabs = tabs().filter((t) => t.getBoundingClientRect().width > 0).length === 3
&& (!phone || [...sr().querySelectorAll('[data-hp="mode-tab"]')].every((t) => !t.getClientRects().length));
o.tabsVisible = visibleTabs;
// AC1: числа нет — ни узла, ни текста вида «N dev.».
o.noHeaderCount = !head().querySelector('.count')
&& !/\d+\s*(dev\.|устр\.)/.test(head().textContent || '');
// AC4 (вне редактора): слот пуст, не фокусируется, скрыт от скринридера.
const idle = measure();
o.idleSlotInert = !!slot() && !cross()
&& slot().getAttribute('aria-hidden') === 'true'
&& !slot().querySelector('button, [tabindex], a, input');
o.idleSlotKeepsSize = !!idle.slot && Math.abs(idle.slot.width - 24) <= 0.5 && Math.abs(idle.slot.height - 24) <= 0.5;
const same = (a, b) => Math.abs(a.head - b.head) <= 1
&& a.tabs.every((left, i) => Math.abs(left - b.tabs[i]) <= 1)
&& (a.next === null || Math.abs(a.next - b.next) <= 1);
const steps = [];
const hits = [];
// View → каждый редактор → View, и прямые переключения между редакторами.
for (const sequence of [['plan', 'view'], ['devices', 'view'], ['decor', 'view'], ['plan', 'devices', 'decor', 'plan', 'view']]) {
for (const next of sequence) {
await hp.setMode(next);
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const now = measure();
steps.push({ next, stable: same(idle, now) });
if (next !== 'view' && cross()) {
// На 390 px карточка демо-стенда шире окна (так и на dev) — докрутить × в видимую область.
cross().scrollIntoView({ block: 'nearest', inline: 'nearest' });
const rect = cross().getBoundingClientRect();
const centre = sr().elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
hits.push({
visible: rect.width >= 24 && rect.height >= 24 && getComputedStyle(cross()).display !== 'none',
centreHitsCross: !!centre && (centre === cross() || cross().contains(centre)),
glyph13: getComputedStyle(cross().querySelector('ha-icon')).getPropertyValue('--mdc-icon-size').trim() === '13px',
noCrossInTabs: !sr().querySelector('.modetab .closex'),
});
}
}
}
// AC2/AC3: ширина шапки и левые границы кнопок режимов и контрола справа стабильны.
o.widthAndTabsStable = steps.every((s) => s.stable);
// AC5/AC6: × виден, ≥ 24 × 24, глиф 13 px, центр слота — это ×, кнопки режимов без ×.
o.crossVisibleAndSized = hits.length === 7 && hits.every((h) => h.visible && h.glyph13);
o.crossHitTarget = hits.every((h) => h.centreHitsCross);
o.noCrossInsideTabs = hits.every((h) => h.noCrossInTabs);
// AC4/AC5: клик в точку в 10 px от центра глифа по горизонтали закрывает редактор.
await hp.setMode('devices');
cross().scrollIntoView({ block: 'nearest', inline: 'nearest' });
const rect = cross().getBoundingClientRect();
const x = rect.left + rect.width / 2 - 10;
const y = rect.top + rect.height / 2;
const edge = sr().elementFromPoint(x, y);
edge?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, clientX: x, clientY: y }));
await hp.settled();
o.edgeClickCloses = sr().querySelector('ha-card')?.getAttribute('data-hp-mode') === 'view';
// AC7: нового горизонтального overflow шапки нет.
o.noHeadOverflow = head().scrollWidth <= head().clientWidth + 1;
o.width = width;
return o;
}, width);
for (const [key, value] of Object.entries(r)) if (key !== 'width') out[`w${width}_${key}`] = value;
}
// AC1 в русской локали на широком окне.
await page.setViewportSize({ width: 1400, height: 820 });
out.ru_noHeaderCount = await page.evaluate(async () => {
const c = window.__card;
c.hass = { ...c.hass, language: 'ru', locale: { ...(c.hass.locale || {}), language: 'ru' } };
await window.__hpTest.settled();
await new Promise((resolve) => setTimeout(resolve, 300));
const head = (c.shadowRoot || c.renderRoot).querySelector('.head');
return !head.querySelector('.count') && !/\d+\s*устр\./.test(head.textContent || '');
});
for (const [name, value] of Object.entries(out)) check(name, value);
await finish(browser, out);