38 KiB
068 — Подсказки к настройкам: переиспользуемая кнопка помощи в любом диалоге
- Issue: #68
- Приоритет: P2, polish
- Статус ТЗ: выпущено в v1.62.0-beta.1; локально усилено после ревью (ревизия 5)
- Связано: #30 (информационная архитектура диалогов), #31 (доступность), #62 (инфраструктура i18n), #85 (тесты должны уметь падать)
- Область: общий компонент
hp-help, фабрика вызова Houseplan Card,hp-dialog,hp-color-opacity, ключи i18n - Touch editor: поддерживается для самого affordance — подсказку можно открыть и
закрыть тапом; это явная гарантия данной задачи поверх общего best-effort правила
редакторов из
docs/TOUCH-SUPPORT.md
Задача поставляет механизм и три пилотных текста. Остальные подсказки пишутся отдельно — см. issue про контент, первая партия описана в §9.
Ревизия 4 по code review локальной реализации добавляет единый DOM/lifecycle-
контроллер fallback-поверхности для hp-help и hp-color-opacity, включает
dialog-owned portal в composed focus traversal и запрещает Escape переносить
фокус на hover-trigger. aria-describedby подключается только у открытой
подсказки: закрытая кнопка объявляет назначение, но не повторяет весь текст при
каждом попадании фокуса. Статический scanner дополнительно запрещает сочетать
title= и _help() на одном host-контроле.
Ревизия 5 по полному code review запрещает «мёртвый» affordance: кнопка помощи
не существует в DOM, если отсутствует или пуста хотя бы одна из двух обязательных
локализованных строк — текст подсказки либо полная ARIA-подпись. Фабрика проверяет
пару до создания host, а сам hp-help повторяет защиту для прямого и динамического
использования. Визуальный знак — MDI help-circle-outline («вопрос в кружке»), а не
зависящий от системного шрифта символ ?.
0. Что изменено ревью 2026-08-11
| # | Находка | Решение |
|---|---|---|
| R1 | Пилот неработоспособен в исходном виде. Кнопка внутри <fieldset disabled> отключается браузером по спецификации HTML, а пилотный светоблок диалога маркера — это именно <fieldset ?disabled=${glowControlsDisabled}> (houseplan-card.ts:15579). Подсказка умирала бы ровно там, где она нужнее всего: «почему это серое?» |
Нормативно: триггер рендерится внутри первого <legend> группы либо вне fieldset. <legend> — единственное исключение из disabled-наследования по стандарту, поэтому решение не требует новых хаков (§3.3) |
| R2 | Изобретался второй механизм всплытия. ТЗ требовало «слой, не обрезаемый диалогом» и своё место в шкале z-индексов, хотя в проекте уже есть решённый тот же случай: hp-color-opacity использует native Popover API + position: fixed и покрыт golden-сценой decor-color-popover-mobile-ru |
Нормативно: переиспользовать тот же подход, вынеся его в общий helper. Никакого нового z-слоя не вводится — top layer выше всего по определению (§3.4) |
| R3 | Escape не дошёл бы до пузыря. hp-dialog._onKeyDown (hp-dialog.ts:293) на Escape делает stopImmediatePropagation() и закрывает диалог. Требование «Escape закрывает сначала подсказку» не выполнялось бы само собой |
Введён явный реестр открытых поверхностей у hp-dialog; Escape отдаётся верхней из них (§3.5), и это отдельный ассерт смока |
| R4 | Цифры устарели. В теле issue: 57 title=, 38 hint-ключей |
На v1.61.0: 56 title=, 34 ключа вида *_tip/*_hint/*.hint из 721 (§1) |
| R5 | Обоснование пилота устарело. «Светоблок всё равно переделывается по #65–#67» — они вышли в v1.61.0 | Пилот сохранён, но по другой причине: это самый трудный случай (disabled-группа + три контрола, которые пришлось объяснять текстом) (§8) |
| R6 | Не было правил для тача, повторного открытия, прокрутки и клика по label | §3.6–§3.9 |
| R7 | Проверка паритета ключей не могла работать: ключ мог быть вычисляемым | Нормативно: только строковый литерал в разметке (§6) |
| R8 | Тесты не проверялись на способность падать | Добавлен обязательный мутант по контракту #85 (§7) |
| R9 | Компоненту неоткуда было взять правильный язык. hass.locale недостаточно: карточка поддерживает явный config.language, а автономный hp-help key="…" этого контекста не видит. При двух карточках с разным языком глобальный localizer также недопустим |
hp-help становится presentation-only, а Houseplan Card даёт единую фабрику _help('literal.help'), которая на каждом рендере передаёт уже локализованные text и ariaLabel. Вызов по-прежнему занимает одну строку и содержит один литеральный ключ (§3.1, §5) |
| R10 | Открытие «по фокусу» конфликтовало с кликом: pointer сначала фокусирует кнопку, затем click немедленно переключает уже открытый пузырь обратно |
Автооткрытие по focus разрешено только при :focus-visible; pointer-hover обслуживается только pointerType === 'mouse', а click остаётся единым toggle для мыши, тача и Enter/Space (§3.2) |
| R11 | Необязательная ссылка противоречила семантике tooltip: role="tooltip" не может содержать интерактивный элемент, а non-modal dialog потребовал бы другой фокусный контракт |
В первой версии пузырь строго текстовый и не получает фокус. Ссылки вынесены из механизма #68; их можно добавить отдельным расширением с отдельной семантикой. #86 остаётся задачей на короткий самостоятельный текст (§3.10, §10) |
| R12 | Настоящая кнопка внутри Shadow DOM нового компонента могла выпасть из ручного focus trap нативной ветки hp-dialog: текущий _focusableElements() видит только light DOM |
Нативная ветка hp-dialog получает обход focusable-элементов по composed tree/open shadow roots; порядок Tab и wrap проверяются отдельным смоком (§3.12, §7) |
| R13 | registerOverlay(close) не определял порядок, очистку и конкуренцию с пикером цвета |
Зафиксирован token/disposer-контракт, LIFO, идемпотентная очистка, pruning отключённых владельцев и одна exclusive transient-поверхность на диалог. hp-help и hp-color-opacity используют один контракт (§3.5) |
| R14 | В пилоте подсказка радиуса оставалась бы внутри тела disabled-fieldset и снова отключалась, несмотря на исправление для legend | Радиус вынесен из disabled-fieldset цвета/яркости; disabled назначается самому input, а живой help-trigger остаётся рядом с подписью. Динамическая причина блокировки сохраняется как отдельная status-note (§8) |
| R15 | Не был определён fallback при отсутствии Popover API и жизненный цикл при resize/orientation; комментарий fallback в hp-color-opacity сам по себе этого контракта не гарантирует |
Общий floating helper обязан иметь проверяемую ветку без showPopover, учитывать visual viewport и закрывать либо безопасно перепозиционировать поверхность при resize. Fallback тестируется принудительно (§3.4, §7) |
| R16 | Исходное требование «ниже toast» несовместимо с выбранным native top layer: обычный .toast { z-index: 120 } всё равно окажется под открытым popover |
Появление toast сначала закрывает transient-поверхности текущей карточки. Так toast остаётся единственным верхним сообщением без отдельной миграции toast в top layer (§3.5, §7) |
1. Зачем механизм, а не ещё один title=
Объяснения сейчас живут в трёх несовместимых формах (замеры на v1.61.0):
- 56 нативных
title=${this._t(...)}вhouseplan-card.ts— невидимы на тач-экране, задержка не управляется, оформление не управляется. То есть не работают ровно на планшетах и настенных панелях, где и живёт основная аудитория киоска; - 16 блоков
.rhintпод контролом — видны всем и всегда, включая тех, кто уже разобрался, и навсегда увеличивают высоту диалога; - у большинства опций объяснения нет вообще.
При этом 34 ключа i18n уже содержат подсказочный текст (*_tip, *_hint, *.hint)
из 721 ключа. Контент существует кусками, но не имеет единой подачи.
2. Что видит пользователь
После подписи опции — кружок со знаком вопроса того же размера, что подпись, в приглушённом цвете. По наведению, фокусу с клавиатуры или тапу рядом появляется компактный пузырь с объяснением этой опции. Пузырь закрывается уводом курсора, тапом мимо, повторным нажатием или Escape — и не закрывает при этом сам диалог.
Опции без подсказки выглядят ровно как сейчас: кружок не рендерится вовсе.
3. Механизм
3.1 Компонент
hp-help — presentation-only компонент. Он не импортирует словари, не читает
hass.locale, не ищет приватные поля host-карточки и не держит глобальный язык. На вход он
получает уже разрешённые строки text и ariaLabel.
Houseplan Card предоставляет одну фабрику, которая остаётся единственной точкой вызова:
${this._help('marker.light_role.help')}
Фабрика локализует тело и полную доступную подпись через текущий _t() карточки, поэтому
учитывает как язык профиля HA, так и явный config.language, и рендерит:
html`<hp-help .text=${text} .ariaLabel=${ariaLabel}></hp-help>`
Место вызова передаёт только один литеральный ключ. Ни пользовательских строк, ни разметки, ни callback локализации, ни позиционирования на стороне конкретного диалога. Смена языка родительской карточки обычным Lit-рендером обновляет уже открытый пузырь. Две карточки с разными языками на одной странице не влияют друг на друга.
3.2 Триггеры
Все три, потому что продукт живёт на десктопе, планшете и телевизоре:
- мышь:
pointerenterсpointerType === 'mouse'открывает с задержкой 300 мс; уход и с триггера, и с пузыря закрывает с задержкой 150 мс. Вход в пузырь отменяет таймер закрытия; - клавиатура: триггер — настоящий
<button type="button">, доступен по Tab. Фокус автоматически открывает подсказку только когда кнопка соответствует:focus-visible;Enter/Spaceчерез обычныйclickпереключают состояние; - мышь или тач по кнопке: обычный
clickпереключает. Эмулированные после тапа mouse-hover события игнорируются, потому что hover-ветка принимает только настоящий mouse pointer.
Pointer-фокус перед click не должен давать цикл «открылась по focus → закрылась по
click». Все таймеры открытия/закрытия отменяются при противоположном действии и в
disconnectedCallback; отложенный callback проверяет, что компонент ещё подключён и
намерение пользователя не изменилось.
3.3 Disabled-группы (R1)
Триггер обязан оставаться доступным, когда группа настроек отключена: половина ценности подсказки — объяснить, почему контрол недоступен.
Нормативно: вызов _help() внутри <fieldset> размещается в первом <legend>. По
HTML-стандарту disabled у fieldset не распространяется на содержимое первого
legend, поэтому кнопка остаётся живой без единого обходного приёма. Если у группы нет
legend, триггер выносится за пределы fieldset.
Запрещено: help-trigger в теле disabled-группы, отключение триггера вместе с контролом,
дублирование подсказки в aria-describedby отключённой группы.
3.4 Позиционирование (R2)
Диалог обрезает содержимое: overflow: hidden и на .surface (hp-dialog.ts:138), и на
ha-dialog::part(dialog) (:55). Абсолютно спозиционированный пузырь будет срезан у
края.
Нормативно: тот же механизм, что у hp-color-opacity — native Popover API
(showPopover()/hidePopover(), состояние :popover-open) плюс position: fixed и
пересчёт координат при открытии. Всплывающий слой оказывается в top layer, поэтому:
- новый z-слой не вводится и шкала
tip 60 < tray 70 < menu 80 < toast 120не меняется; - второй механизм всплытия в проекте не появляется.
Логика переворота и сдвига у края (flip/shift) выносится из hp-color-opacity в общий
чистый helper и используется обоими компонентами. Helper получает rect якоря, rect
поверхности, gap, безопасный отступ и границы visual viewport; возвращает координаты и
выбранную сторону. Он не владеет DOM, слушателями или политикой закрытия. Дублировать
геометрию копипастой запрещено.
Обязательные граничные случаи helper:
- обычное размещение снизу;
- flip наверх, если снизу не помещается;
- horizontal shift у обоих краёв;
- поверхность шире/выше доступной области: координата прижимается к safe edge, а сама
поверхность получает ограничение
max-inline-size/max-block-size; visualViewport.offsetLeft/offsetTop/width/height, если API доступен; иначеwindow.innerWidth/innerHeight.
Popover API — предпочтительная ветка, но не безусловное допущение. Общий контроллер
делает feature detection. Ветка без showPopover() обязана остаться видимой и не
обрезаться обеими ветками hp-dialog; конкретный способ fallback выбирает реализация,
но «оставить элемент внутри clipping ancestor и назвать это fallback» недостаточно.
Fallback имеет отдельный принудительный смок.
При resize окна/visual viewport и смене ориентации открытая поверхность либо один раз
перепозиционируется через rAF, либо закрывается — выбранная политика одинакова для
одинаковых причин и покрыта тестом. Исчезновение/отключение якоря всегда закрывает
поверхность. Scroll-политика подсказки отдельно зафиксирована в §3.8.
3.5 Escape и порядок закрытия (R3)
hp-dialog получает публичный реестр открытых внутри него transient-поверхностей. Один
вызов регистрации имеет следующий смысл (точное имя TypeScript API может отличаться,
семантика — нет):
registerOverlay({ owner, close, group: 'transient' }): () => void
- регистрация происходит только после фактического открытия поверхности;
- возвращённый disposer идемпотентен и вызывается при любом закрытии и обязательно в
disconnectedCallbackвладельца; - повторная регистрация того же owner не создаёт дубль, а переносит его наверх LIFO;
- перед использованием реестр удаляет записи с
!owner.isConnected; - открытие новой поверхности в exclusive-группе
transientсначала закрывает прежнюю из этой группы. Поэтому одновременно не остаются help-bubble и color picker; - callback
close('escape')синхронно переводит owner в закрытое состояние и не сам генерирует второй Escape.
На Escape диалог сначала закрывает верхнюю зарегистрированную поверхность и не
закрывает себя; если реестр пуст — закрывается сам. Событие по-прежнему получает
preventDefault() и stopImmediatePropagation() ровно один раз в capture-handler
диалога. Существующее поведение диалога без поверхностей не меняется.
hp-color-opacity переводится на тот же реестр в рамках этой задачи: сейчас его пикер и
диалог конкурируют за Escape. Сам hp-color-opacity и hp-help не ищут реестр глобально:
они регистрируются в ближайшем hp-dialog; вне диалога сохраняют собственное закрытие по
Escape и outside-pointer для изолированного demo/test применения.
Обычный toast не может перекрыть native top-layer числовым z-index. Поэтому перед
показом toast карточка закрывает все transient-поверхности только своего render root;
сам диалог остаётся открыт. Это сохраняет существующую визуальную иерархию «toast важнее
подсказки/пикера» без переноса toast в новый механизм.
3.6 Тач и указатель одновременно (R6)
События hover на тач-устройствах эмулируются, поэтому тап иначе открывал бы и тут же
закрывал пузырь. Нормативно: обработчики наведения игнорируются при
event.pointerType !== 'mouse'; activation живёт в обычном click, а не в параллельном
touchend. На hybrid-устройстве реальная мышь сохраняет hover, реальный touch — toggle.
3.7 Одна подсказка за раз (R6)
Открытие любого hp-help в диалоге закрывает предыдущую transient-поверхность этого
диалога, включая открытый hp-color-opacity, и наоборот. Разные карточки/диалоги не
делят глобальное состояние и не закрывают поверхности друг друга.
3.8 Прокрутка (R6)
Пузырь спозиционирован фиксированно и за прокруткой тела диалога не следует.
Нормативно: прокрутка любого предка закрывает пузырь. Пересчёт координат на каждый кадр
прокрутки запрещён. Scroll внутри другой независимой карточки не закрывает подсказку;
контролируется event path/принадлежность к тому же document+dialog, а не глобальный
document.scroll без фильтра.
Открытие/закрытие подсказки не меняет clientHeight, scrollHeight или scrollTop
тела диалога. Вспомогательный текст для aria-describedby постоянно остаётся вне
scroll-flow; его доступность переключается ARIA-состоянием, а не добавлением
абсолютно спозиционированного узла в overflow-геометрию. Требование одинаково для
native Popover и portal fallback.
3.9 Триггер и <label> (R6)
Help-trigger не размещается внутри <label>: клик по нему переключал бы связанный
контрол. Триггер ставится после закрывающего </label> либо в legend.
3.10 Содержимое
Первая версия — только короткий текст, только из i18n, без HTML и без интерактивных
элементов. Поверхность имеет role="tooltip" и никогда не принимает фокус.
Ссылка на документацию в эту версию не входит: интерактивная ссылка несовместима с семантикой tooltip и потребовала бы отдельного non-modal-dialog/focus-контракта. Если такая ценность подтвердится, это отдельное расширение компонента, а не необязательный ключ, меняющий семантику молча.
Ширина ограничена, текст переносится. Контент из #86 — одно предложение, максимум два;
при 200 % масштабе и ширине viewport 390 CSS px он должен помещаться без горизонтальной
прокрутки. Защитный max-block-size допускается, но штатный пилотный текст не должен
требовать внутренней прокрутки, недоступной визуальному keyboard-only пользователю.
3.11 Оформление и движение
Токены поверхности диалога, обе темы. При prefers-reduced-motion анимация появления и
исчезновения отключается. Видимый знак вопроса — 16–18 px, но зона настоящей кнопки не
меньше 32×32 CSS px на fine pointer и 40×40 px при pointer: coarse. Wrapper подписи
может честно увеличить высоту строки; отрицательные margins и перекрытие соседних targets
запрещены. На 200 % подпись и help-trigger могут перенестись вместе на следующую строку,
но не обрезаются.
3.12 Shadow DOM и focus trap (R12)
hp-help инкапсулирует настоящую кнопку в своём Shadow DOM. Нативная fallback-ветка
hp-dialog обязана включать focusable descendants открытых shadow roots в composed
порядке при initial-focus и Tab wrap. Обход не заходит в закрытые shadow roots и не
добавляет host отдельным tab-stop, если фокусным является его внутренний control.
Это исправление проверяется не только на hp-help, но и на существующем
hp-color-opacity: оба trigger доступны Tab, а переход после последнего focusable
возвращает на close/первый control ровно один раз. HA-ветка продолжает использовать trap
ha-dialog; ручная логика не должна создавать второй trap поверх него.
4. Доступность
- триггер — кнопка с
aria-labelв виде одной цельной строки i18n («Подсказка: радиус свечения»), без склейки из кусков; - у кнопки есть
aria-expanded; открытое состояние подключаетaria-describedbyк стабильному уникальному id. В закрытом состоянии связь отсутствует, чтобы screen reader не повторял объяснение при каждом фокусе; - пузырь имеет
role="tooltip", не содержит focusable-элементов и сам не получает фокус; фокус всё время остаётся на trigger; - Escape закрывает пузырь; если фокус уже был на trigger (keyboard/click path), он там остаётся. Hover-подсказка не крадёт текущий фокус и при закрытии его не переносит;
- screen reader получает и назначение кнопки, и открытое объяснение; одна и та же фраза не объявляется дважды из-за параллельного live-region;
- читаемо при 200 % масштабе браузера.
5. Соглашение о ключах
На один вызов нужны два механически связанных ключа:
<ключ опции>.help— текст пузыря:marker.light_role.help;<ключ опции>.help.aria— цельная доступная подпись кнопки, например «Подсказка: является ли устройство источником света». Это не конкатенация общего «Подсказка» с label во время выполнения.
В _help() передаётся первый ключ; .aria выводится из него механически. Тип фабрики —
Extract<I18nKey, ${string}.help>, поэтому произвольная строка не проходит typecheck.
Для опции без обоих ключей trigger не добавляется.
6. Проверка ключей в CI (R7)
Существующий тест паритета i18n расширяется: он сканирует исходники на вызовы
_help('…'), собирает help-ключи и для каждого требует сам ключ и производный .aria
в обоих словарях. Отсутствующий/пустой ключ валит CI, а не рендерит ключ или пустой
пузырь.
Нормативно: аргумент _help() — строковый литерал в разметке. Вычисляемые ключи запрещены, иначе
статический сканер их не видит и проверка становится фиктивной. Единственное
исключение — внутреннее механическое добавление .aria внутри самой фабрики.
7. Тесты
- юниты: pure flip/shift helper (низ, верх, оба horizontal edge, oversized surface,
visual viewport offset); разрешение help +
.aria, обнаружение отсутствующего ключа, стабильная связка aria; - паритет i18n: см. §6;
- смок: открытие по mouse-hover, по
:focus-visibleс клавиатуры, кликом мыши и touch tap; pointer-focus+click не закрывает только что открытый пузырь; закрытие тапом мимо и по Escape без закрытия диалога; второй Escape закрывает уже диалог; открытие help закрывает color picker и наоборот; закрытие при прокрутке своего dialog, но не чужой карточки; доступность триггера в disabled-группе; bounding box пузыря внутри visual viewport для триггера в правом нижнем углу диалога на экране 390 px; показ toast закрывает help/color picker своей карточки, но не dialog и не поверхности другой карточки; отдельный прогон с принудительно отключённой Popover API веткой; явныйconfig.languageотличается отhass.localeи определяет текст; две карточки с разными языками не заражают друг друга; - focus smoke: в нативной fallback-ветке Tab доходит до внутренних кнопок
hp-helpиhp-color-opacityв composed порядке, Shift+Tab идёт обратно, wrap происходит один раз; в HA-ветке не появляется второй trap; - golden: одна сцена с открытым пузырём, тёмная и светлая, с семантическим ассертом
«в области пузыря есть непрозрачные пиксели текста» — по образцу
warmPixelRegion(demo/golden/run.mjs); - мутант по #85 (обязателен): удаление логики flip/shift у края. Смок обязан покраснеть на ассерте «пузырь внутри вьюпорта». Без объявленного мутанта задача не считается выполненной.
8. Пилот (R5)
Конвертируется один блок — светоблок диалога маркера. В нём ровно три help-trigger:
- в legend «Является источником света» — общий смысл Auto/Always/Never;
- в первом legend disabled-fieldset «Цвет и яркость свечения» — различие трёх режимов, включая правило «минимум 1 %, выключение через Never»;
- рядом с подписью «Радиус свечения» — единицы и наследование общего значения.
Радиус визуально остаётся частью светоблока, но его row выводится за пределы
disabled-fieldset цвета/яркости. При блокировке disabled получает сам input; help-trigger
остаётся доступным. Input и fieldset ссылаются через aria-describedby на существующую
динамическую status-note с фактической причиной блокировки.
Переносятся и затем удаляются неиспользуемые старые ключи/строки:
marker.light_role_tip, marker.glow_color_tip, marker.glow_brightness_hint и
marker.glow_radius_hint. Три динамических marker.glow_disabled_* не превращаются
в общую подсказку и остаются видимой status-note: это не документация опции, а текущее
состояние конкретного маркера.
Контрол/подпись не может нести одновременно title=/старую .markerlighttip и новый
_help() — это проверяется сканером исходника. Основание пилота: это самый трудный случай
в продукте (disabled-группа, условные controls, color picker и динамическая причина), и
на нём проверяются сразу §3.3–§3.5 и §3.12.
9. Что дальше (контент)
Написание остальных текстов — отдельная задача #86.
Правило для неё: одна небольшая правка на диалог, пары .help + .help.aria пишутся
сразу в обоих словарях. Упоминание необязательной clickable-ссылки в #86 заменяется
правилом §3.10: ссылка требует отдельного расширения механизма.
10. Вне области
- Написание подсказок для всех опций.
- Массовая миграция 56
title=. Правило на будущее: новые объяснения делаются только через_help(), старыеtitle=мигрируют по мере касания диалога. - Clickable-ссылки, HTML/Markdown и произвольный rich content внутри пузыря.