fix: улучшить доступность View (#565)

Issue: #565
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-14 07:06:16 +00:00
committed by claude[bot]
parent 6103e7de68
commit 6aca447fa0
73 changed files with 927 additions and 530 deletions
+23
View File
@@ -60,6 +60,29 @@ export function markerLqiColor(lqi: number): string {
return lqiColor(lqi);
}
/**
* Join short, already-localised accessible-name segments without making a
* screen reader repeat the same fact. Only whole segments are deduplicated:
* state, value and signal details remain independent even when they share a
* word.
*/
export function deviceAccessibleLabel(
segments: readonly (string | null | undefined | false)[],
): string {
const seen = new Set<string>();
const unique: string[] = [];
for (const segment of segments) {
if (typeof segment !== 'string') continue;
const value = segment.trim().replace(/\s+/g, ' ');
if (!value) continue;
const key = value.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
unique.push(value);
}
return unique.join(', ');
}
export function deviceA11yState(presentation: Pick<
ResolvedDevicePresentation, 'visual' | 'lockState' | 'display'
>): DeviceA11yState {
+30 -30
View File
@@ -310,7 +310,8 @@ import {
type DeviceActivity, type DeviceVisualState, type EntityVisualSample,
} from './device-visual';
import {
activitySourceSignature, deviceA11yState, presentationClasses, resolveDevicePresentation,
activitySourceSignature, deviceAccessibleLabel, deviceA11yState, presentationClasses,
resolveDevicePresentation,
resolvePresentationSources, type ResolvedDevicePresentation,
} from './device-presentation';
import {
@@ -980,6 +981,7 @@ export class HouseplanCard extends LitElement {
temp?: number | null;
hum?: number | null;
room?: boolean;
source?: 'pointer' | 'focus'; deviceId?: string;
} | null = null;
/** Room whose physical perimeter is highlighted in View. The explicit
* overlay is needed because thick wall bodies paint above room shapes. */
@@ -5733,26 +5735,20 @@ export class HouseplanCard extends LitElement {
}
private _showDeviceTip(ev: PointerEvent, d: DevItem): void {
this._notePointer(ev);
if (!this._pointerModality.hoverEnabled || this._drag || this._deviceDrag) {
this._deviceHits.hover(this.renderRoot, null);
return;
}
const showLqi = this._spaceDisplayForRender().showLqi ?? this._config?.show_signal ?? true;
const presentation = this._devicePresentation(d, showLqi);
const disabledReason = presentation.disabledReason;
const ghostLabel = presentation.haDisabled
? this._t((`marker.ha_disabled_${disabledReason}`) as I18nKey)
: d.userHidden ? this._t('marker.hidden_ghost') : d.name;
const metrics = [
d.model,
presentation.valueBadge?.fullText || '',
presentation.lqiText != null ? 'LQI ' + presentation.lqiText : '',
].filter(Boolean).join(' · ');
this._deviceHits.hover(this.renderRoot, d.id);
this._showTip(ev, d.name, presentation.haDisabled ? ghostLabel : metrics);
if (this._liveRt) this._liveRt.devicePointerTip(ev, d);
else void this._ensureLiveRuntime().then(() => this._liveRt?.devicePointerTip(ev, d));
}
private _showDeviceFocusTip(ev: FocusEvent, d: DevItem): void {
const target = ev.currentTarget as HTMLElement | null;
if (this._liveRt) this._liveRt.deviceFocusTip(target, d);
else void this._ensureLiveRuntime().then(() => this._liveRt?.deviceFocusTip(target, d));
}
private _hideDeviceFocusTip(deviceId: string): void { this._liveRt?.deviceBlur(deviceId); }
private _clearPointerHover(): void { this._liveRt?.pointerLeave() ?? this._clearTransientHover(); }
/** Right click in VIEW mode always opens HA's more-info (owner's decision). */
private _ctxDevice(ev: MouseEvent, d: DevItem): void {
if (this._mode !== 'view') return; // editors keep the native context menu
@@ -7436,7 +7432,7 @@ export class HouseplanCard extends LitElement {
this._notePointer(ev);
if (!this._pointerModality.hoverEnabled) return;
if (this._drag || this._deviceDrag) return;
this._tip = { x: ev.clientX, y: ev.clientY, title, meta, lqi, temp, hum, room };
this._tip = { x: ev.clientX, y: ev.clientY, title, meta, lqi, temp, hum, room, source: 'pointer' };
this._syncLiveHover();
}
@@ -7602,6 +7598,7 @@ export class HouseplanCard extends LitElement {
this._clearRoomFocus(true);
this._cancelDangerConfirm();
this._resetDeviceHitState();
this._clearTransientHover(true);
}
this._warmModeRequest = 0;
if (!this._editorRuntime) {
@@ -11466,7 +11463,7 @@ export class HouseplanCard extends LitElement {
<ha-icon icon="mdi:home-city"></ha-icon>
${this._config.title || this._t('card.title')}
</div>`}
<div class="tabs" @pointermove=${(e: PointerEvent) => this._tabPointerMove(e)}>
<nav class="tabs" aria-label=${this._t('nav.spaces')} @pointermove=${(e: PointerEvent) => this._tabPointerMove(e)}>
${navigationSpaces.map(
(s) => html`<button
data-hp="space-tab" data-id="${s.id}"
@@ -11475,6 +11472,7 @@ export class HouseplanCard extends LitElement {
this._tabDrag?.moved && this._tabDrag.targetId === s.id
? ` drop-${this._tabDrag.placement}` : ''}"
?data-reorderable=${this._canReorderTabs}
aria-current=${this._space === s.id ? 'page' : nothing}
@pointerdown=${(e: PointerEvent) => this._tabPointerDown(e, s.id)}
@pointerup=${(e: PointerEvent) => this._tabPointerUp(e)}
@pointercancel=${() => this._endTabDrag()}
@@ -11503,7 +11501,7 @@ export class HouseplanCard extends LitElement {
<ha-icon icon="mdi:plus"></ha-icon>
</button>`
: nothing}
</div>
</nav>
${this._canEdit
? html`<div class="modes">
${([['plan', 'mdi:floor-plan'], ['devices', 'mdi:tune-variant'], ['decor', 'mdi:draw']] as const).map(
@@ -11734,32 +11732,32 @@ export class HouseplanCard extends LitElement {
d="${[obstaclePath, ...holes.map(pathD)].join(' ')}"
@pointerenter=${enterRoom}
@pointermove=${tip}
@pointerleave=${() => this._clearTransientHover()}></path>`
@pointerleave=${() => this._clearPointerHover()}></path>`
: holes.length && fillPoly
? svg`<path class="${cls}" style="${style}" fill-rule="evenodd"
data-hp="room" data-id=${hpId} data-area=${hpArea}
d="${[fillPoly, ...holes].map(pathD).join(' ')}"
@pointerenter=${enterRoom}
@pointermove=${tip}
@pointerleave=${() => this._clearTransientHover()}></path>`
@pointerleave=${() => this._clearPointerHover()}></path>`
: fillPoly && fillPoly !== myPoly
? svg`<polygon class="${cls}" style="${style}" points="${fillPoly.map((p) => p.join(',')).join(' ')}"
data-hp="room" data-id=${hpId} data-area=${hpArea}
@pointerenter=${enterRoom}
@pointermove=${tip}
@pointerleave=${() => this._clearTransientHover()}></polygon>`
@pointerleave=${() => this._clearPointerHover()}></polygon>`
: r.poly
? svg`<polygon class="${cls}" style="${style}" points="${r.poly.map((p) => p.join(',')).join(' ')}"
data-hp="room" data-id=${hpId} data-area=${hpArea}
@pointerenter=${enterRoom}
@pointermove=${tip}
@pointerleave=${() => this._clearTransientHover()}></polygon>`
@pointerleave=${() => this._clearPointerHover()}></polygon>`
: svg`<rect class="${cls}" style="${style}"
data-hp="room" data-id=${hpId} data-area=${hpArea}
x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}" rx="${Math.min(r.w!, r.h!) * 0.03}"
@pointerenter=${enterRoom}
@pointermove=${tip}
@pointerleave=${() => this._clearTransientHover()}></rect>`;
@pointerleave=${() => this._clearPointerHover()}></rect>`;
const trimmed = edgeCuts.length && myPoly
? outlineWithout(myPoly, edgeCuts, this._gridPitch * 0.02)
: null;
@@ -12518,7 +12516,7 @@ export class HouseplanCard extends LitElement {
: d.userHidden ? this._t('marker.hidden_ghost') : d.name;
const a11yState = deviceA11yState(presentation);
const interactive = this._mode === 'view' || this._mode === 'devices';
const deviceAriaLabel = [
const deviceAriaLabel = deviceAccessibleLabel([
ghostLabel,
!presentation.haDisabled
? this._t((`marker.state_a11y_${a11yState}`) as I18nKey) : '',
@@ -12530,7 +12528,7 @@ export class HouseplanCard extends LitElement {
? this._t((`marker.lqi_a11y_${presentation.lqiBand}`) as I18nKey, {
value: presentation.lqiText,
}) : '',
].filter(Boolean).join(', ');
]);
return html`<div
${''/* docs/STYLING-HOOKS.md §3: the styling contract. `nothing` on an
attribute binding REMOVES the attribute, so a virtual marker has
@@ -12555,12 +12553,14 @@ export class HouseplanCard extends LitElement {
style="${st.join(';')}"
@click=${(e: MouseEvent) => this._clickDevice(e, d)}
@keydown=${(e: KeyboardEvent) => this._keyDevice(e, d)}
@focus=${(e: FocusEvent) => this._showDeviceFocusTip(e, d)}
@focusout=${() => this._hideDeviceFocusTip(d.id)}
@contextmenu=${(e: MouseEvent) => this._ctxDevice(e, d)}
@pointerover=${(e: PointerEvent) => {
if (this._mode !== 'view' && this._mode !== 'devices') return;
this._showDeviceTip(e, this._deviceForPointerEvent(e, d));
}}
@pointerleave=${() => this._clearTransientHover()}
@pointerleave=${() => this._clearPointerHover()}
@pointerdown=${(e: PointerEvent) => this._pointerDown(e, d)}
@pointermove=${(e: PointerEvent) => {
if (this._mode !== 'view' && this._mode !== 'devices') return;
+1
View File
@@ -52,6 +52,7 @@
"card.title": "Hausplan",
"count.devices": "{n} Entwicklung",
"empty.no_spaces": "Noch keine Bereiche.",
"nav.spaces": "House-Plan-Bereiche",
"empty.add_first": "Ersten Bereich hinzufügen und einen Grundriss hochladen.",
"empty.read_only": "Es sind keine Bereiche eingerichtet. Bitten Sie einen Administrator, einen hinzuzufügen.",
"empty.install": "Installieren Sie die House Plan-Integration und fügen Sie sie unter „Geräte & Dienste“ hinzu.",
+1
View File
@@ -52,6 +52,7 @@
"card.title": "House plan",
"count.devices": "{n} dev.",
"empty.no_spaces": "No spaces yet.",
"nav.spaces": "House Plan spaces",
"empty.add_first": "Add the first space and upload a floor plan.",
"empty.read_only": "No spaces are configured. Ask an administrator to add one.",
"empty.install": "Install the House Plan integration and add it in \"Devices & services\".",
+1
View File
@@ -52,6 +52,7 @@
"card.title": "Plan de la maison",
"count.devices": "{n} app.",
"empty.no_spaces": "Aucun espace pour le moment.",
"nav.spaces": "Espaces House Plan",
"empty.add_first": "Ajoutez le premier espace et importez un plan.",
"empty.read_only": "Aucun espace n’est configuré. Demandez à un administrateur d’en ajouter un.",
"empty.install": "Installez l’intégration House Plan et ajoutez-la dans « Appareils et services ».",
+1
View File
@@ -52,6 +52,7 @@
"card.title": "План дома",
"count.devices": "{n} устр.",
"empty.no_spaces": "Пространств пока нет.",
"nav.spaces": "Пространства House Plan",
"empty.add_first": "Добавьте первое пространство и загрузите план этажа.",
"empty.read_only": "Пространства не настроены. Попросите администратора добавить первое.",
"empty.install": "Установите интеграцию House Plan и добавьте запись в «Устройства и службы».",
+91 -2
View File
@@ -1,4 +1,7 @@
import { lqiColor } from './logic';
import type { I18nKey } from './i18n';
import type { ResolvedDevicePresentation } from './device-presentation';
import type { DevItem } from './types';
export interface LiveTip {
x: number;
@@ -9,6 +12,8 @@ export interface LiveTip {
temp?: number | null;
hum?: number | null;
room?: boolean;
source?: 'pointer' | 'focus';
deviceId?: string;
}
interface HoverState {
@@ -25,8 +30,85 @@ interface LiveHoverHost {
_roomHoverPaths: (model: unknown) => { fillD: string; outlineD: string } | null;
}
interface DeviceTipHost extends LiveHoverHost {
_mode: string;
_drag: unknown;
_deviceDrag: unknown;
_config: { show_signal?: boolean } | null;
_pointerModality: { hoverEnabled: boolean };
_deviceHits: { hover: (root: ParentNode, id: string | null) => void };
_spaceDisplayForRender: () => { showLqi?: boolean };
_devicePresentation: (device: DevItem, showLqi: boolean) => ResolvedDevicePresentation;
_notePointer: (event: PointerEvent) => void;
_showTip: (event: PointerEvent, title: string, meta: string) => void;
_t: LiveHoverHost['_t'] & ((key: I18nKey) => string);
}
const states = new WeakMap<object, HoverState>();
const deviceTipContent = (host: DeviceTipHost, device: DevItem): { title: string; meta: string } => {
const showLqi = host._spaceDisplayForRender().showLqi ?? host._config?.show_signal ?? true;
const presentation = host._devicePresentation(device, showLqi);
const ghostLabel = presentation.haDisabled
? host._t((`marker.ha_disabled_${presentation.disabledReason}`) as I18nKey)
: device.userHidden ? host._t('marker.hidden_ghost') : device.name;
const metrics = [
device.model,
presentation.valueBadge?.fullText || '',
presentation.lqiText != null ? `LQI ${presentation.lqiText}` : '',
].filter(Boolean).join(' · ');
return { title: device.name, meta: presentation.haDisabled ? ghostLabel : metrics };
};
export function showDevicePointerTip(value: object, event: PointerEvent, device: DevItem): void {
const host = value as DeviceTipHost;
host._notePointer(event);
if (!host._pointerModality.hoverEnabled || host._drag || host._deviceDrag) {
host._deviceHits.hover(host.renderRoot, null);
return;
}
if (host._tip?.source === 'focus') return;
const tip = deviceTipContent(host, device);
host._deviceHits.hover(host.renderRoot, device.id);
host._showTip(event, tip.title, tip.meta);
}
export function showDeviceFocusTip(value: object, target: HTMLElement | null, device: DevItem): void {
const host = value as DeviceTipHost;
if (host._mode !== 'view' || host._drag || host._deviceDrag
|| !target?.matches(':focus-visible')) return;
const rect = target.getBoundingClientRect();
host._tip = {
x: rect.right, y: rect.top, ...deviceTipContent(host, device),
source: 'focus', deviceId: device.id,
};
syncHouseplanHover(host);
}
export function hideDeviceFocusTip(value: object, deviceId: string): void {
const host = value as DeviceTipHost;
if (host._tip?.source !== 'focus' || host._tip.deviceId !== deviceId) return;
host._tip = null;
syncHouseplanHover(host);
}
export function clearPointerHover(value: object): void {
const host = value as DeviceTipHost;
host._deviceHits.hover(host.renderRoot, null);
if (host._tip?.source !== 'focus') host._tip = null;
host._hoverRoom = null;
syncHouseplanHover(host);
}
export function reconcileDeviceFocusTip(value: object): void {
const host = value as DeviceTipHost;
if (host._tip?.source !== 'focus') return;
const marker = [...host.renderRoot.querySelectorAll<HTMLElement>('[data-hp="device"]')]
.find((node) => node.dataset.id === host._tip?.deviceId);
if (marker?.matches(':focus-visible')) return;
host._tip = null;
}
const appendMeta = (tip: HTMLElement, label: string, value?: string, color?: string): void => {
if (!value) return;
const row = document.createElement('span');
@@ -51,8 +133,10 @@ const syncTip = (host: LiveHoverHost, root: ParentNode): void => {
element.replaceChildren();
return;
}
element.style.left = `${tip.x + 12}px`;
element.style.top = `${tip.y + 12}px`;
const gap = 12;
const margin = 8;
element.style.left = `${tip.x + gap}px`;
element.style.top = `${tip.y + gap}px`;
element.replaceChildren();
const title = document.createElement('b');
title.textContent = tip.title;
@@ -63,6 +147,11 @@ const syncTip = (host: LiveHoverHost, root: ParentNode): void => {
appendMeta(element, host._t('tip.lqi'), tip.lqi == null ? '' : String(tip.lqi),
tip.lqi == null ? undefined : lqiColor(tip.lqi));
element.hidden = false;
const box = element.getBoundingClientRect();
const maxLeft = Math.max(margin, window.innerWidth - box.width - margin);
const maxTop = Math.max(margin, window.innerHeight - box.height - margin);
element.style.left = `${Math.min(maxLeft, Math.max(margin, tip.x + gap))}px`;
element.style.top = `${Math.min(maxTop, Math.max(margin, tip.y + gap))}px`;
};
const setRoomPath = (root: ParentNode, selector: string, d: string): void => {
+14 -1
View File
@@ -1,11 +1,15 @@
import {
commitHouseplanViewport, disposeHouseplanViewport, scheduleHouseplanViewport,
} from './live-viewport';
import { resetHouseplanHover, syncHouseplanHover } from './live-hover';
import {
clearPointerHover, hideDeviceFocusTip, reconcileDeviceFocusTip, resetHouseplanHover,
showDeviceFocusTip, showDevicePointerTip, syncHouseplanHover,
} from './live-hover';
import {
classifyHassRenderChange, type HassRenderDependencies, type HassRenderSnapshot,
} from './render-invalidation';
import type { RenderLifecycle } from './houseplan-render-lifecycle';
import type { DevItem } from './types';
interface LiveRuntimeHost {
_renderLife: RenderLifecycle;
@@ -53,6 +57,14 @@ export class LiveRuntime {
}
public viewport(now = false): void { scheduleHouseplanViewport(this.host, now); }
public hover(): void { syncHouseplanHover(this.host); }
public devicePointerTip(event: PointerEvent, device: DevItem): void {
showDevicePointerTip(this.host, event, device);
}
public deviceFocusTip(target: HTMLElement | null, device: DevItem): void {
showDeviceFocusTip(this.host, target, device);
}
public deviceBlur(deviceId: string): void { hideDeviceFocusTip(this.host, deviceId); }
public pointerLeave(): void { clearPointerHover(this.host); }
public active(): boolean {
const host = this.host as LiveRuntimeHost;
return host._pointers.size > 0 || host._cameraTransition.active || !!host._deviceDrag
@@ -62,6 +74,7 @@ export class LiveRuntime {
}
public commit(): void {
commitHouseplanViewport(this.host);
reconcileDeviceFocusTip(this.host);
resetHouseplanHover(this.host);
syncHouseplanHover(this.host);
}
+3 -3
View File
@@ -37,7 +37,7 @@ import {
} from './render/opening-symbol';
import { activeRegistryHass, fullRegistryHass, type HaRegistrySnapshot } from './ha-binding-status';
import {
deviceA11yState, resolveDevicePresentation,
deviceAccessibleLabel, deviceA11yState, resolveDevicePresentation,
type PresentationActivityRuntime, type ResolvedDevicePresentation,
} from './device-presentation';
import { presentationSnapshotKey } from './render-device-snapshot';
@@ -591,7 +591,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
});
const st = [`left:${left}%`, `top:${top}%`, ...deviceFaceStyle(presentation)];
const a11yState = deviceA11yState(presentation);
const deviceAriaLabel = [
const deviceAriaLabel = deviceAccessibleLabel([
d.name,
t(o.lang, (`marker.state_a11y_${a11yState}`) as any),
presentation.pulse.kind !== 'none'
@@ -602,7 +602,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
? t(o.lang, (`marker.lqi_a11y_${presentation.lqiBand}`) as any, {
value: presentation.lqiText,
}) : '',
].filter(Boolean).join(', ');
]);
return html`<div class="dev ${deviceThemeClass(planHass)} ${presentation.classes.join(' ')} ${d.virtual ? 'virtual' : ''} ${presentation.valueText != null ? 'valonly' : ''}"
data-hp="device" data-id="${d.id}" data-entity=${d.primary || nothing} data-area=${d.area || nothing}
role="img" aria-label=${deviceAriaLabel}