Files
houseplan-card/docs/specs/068-help-affordance.md
T

38 KiB
Raw Blame History

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:

  1. в legend «Является источником света» — общий смысл Auto/Always/Never;
  2. в первом legend disabled-fieldset «Цвет и яркость свечения» — различие трёх режимов, включая правило «минимум 1 %, выключение через Never»;
  3. рядом с подписью «Радиус свечения» — единицы и наследование общего значения.

Радиус визуально остаётся частью светоблока, но его 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 внутри пузыря.