Release v1.62.0-beta.4 candidate

This commit is contained in:
Matysh
2026-08-12 10:19:51 +03:00
parent 8a417539e0
commit 4dc3fdef36
30 changed files with 1459 additions and 545 deletions
+136 -43
View File
@@ -75,17 +75,71 @@ export interface ResolveToggleOptions {
lightSources?: readonly ResolvedLightSource<DevItem>[];
}
const POWER_DOMAINS = new Set([
'light', 'switch', 'fan', 'humidifier', 'climate', 'media_player',
'input_boolean', 'automation', 'remote', 'siren', 'vacuum', 'water_heater',
'camera',
]);
type PowerService = 'turn_on' | 'turn_off' | 'toggle';
const TURN_FEATURES: Record<string, Partial<Record<'turn_on' | 'turn_off', number>>> = {
// Home Assistant VacuumEntityFeature.TURN_ON / TURN_OFF.
vacuum: { turn_on: 1, turn_off: 2 },
// Home Assistant MediaPlayerEntityFeature.TURN_ON / TURN_OFF.
media_player: { turn_on: 128, turn_off: 256 },
interface PowerToggleAdapter {
/** States which mean disabled. Every other known state is active/on. */
offStates: readonly string[];
/** Whether an indeterminate state may use the domain/generic toggle. */
unknownUsesToggle: boolean;
/**
* Per-entity capability bits required by Home Assistant for each command.
* An absent entry means that the entity domain itself guarantees the basic
* turn-on/turn-off contract (for example light, switch and fan).
*/
featureMasks?: Partial<Record<PowerService, number>>;
}
const BASIC_POWER_ADAPTER: PowerToggleAdapter = {
offStates: ['off'],
unknownUsesToggle: true,
};
/**
* Explicit domain adapters are the capability allow-list for issue #94.
* `hass.services` is only a second, runtime guard: a service registered for a
* domain says nothing about whether one particular entity implements it.
*/
const POWER_ADAPTERS: Readonly<Record<string, PowerToggleAdapter>> = {
light: BASIC_POWER_ADAPTER,
switch: BASIC_POWER_ADAPTER,
fan: BASIC_POWER_ADAPTER,
humidifier: BASIC_POWER_ADAPTER,
input_boolean: BASIC_POWER_ADAPTER,
automation: BASIC_POWER_ADAPTER,
remote: BASIC_POWER_ADAPTER,
climate: {
...BASIC_POWER_ADAPTER,
// Home Assistant ClimateEntityFeature.TURN_OFF / TURN_ON.
featureMasks: { turn_on: 256, turn_off: 128, toggle: 128 | 256 },
},
media_player: {
...BASIC_POWER_ADAPTER,
// Home Assistant MediaPlayerEntityFeature.TURN_ON / TURN_OFF.
featureMasks: { turn_on: 128, turn_off: 256, toggle: 128 | 256 },
},
siren: {
...BASIC_POWER_ADAPTER,
// Home Assistant SirenEntityFeature.TURN_ON / TURN_OFF.
featureMasks: { turn_on: 1, turn_off: 2, toggle: 1 | 2 },
},
vacuum: {
...BASIC_POWER_ADAPTER,
// Deprecated but still supported legacy VacuumEntityFeature bits. Modern
// StateVacuumEntity instances deliberately do not advertise these and are
// therefore not treated as a binary power toggle.
featureMasks: { turn_on: 1, turn_off: 2, toggle: 1 | 2 },
},
water_heater: {
...BASIC_POWER_ADAPTER,
// Home Assistant WaterHeaterEntityFeature.ON_OFF.
featureMasks: { turn_on: 8, turn_off: 8, toggle: 8 },
},
camera: {
...BASIC_POWER_ADAPTER,
// Home Assistant CameraEntityFeature.ON_OFF.
featureMasks: { turn_on: 1, turn_off: 1, toggle: 1 },
},
};
const FEATURE_OPEN = 1;
@@ -96,13 +150,8 @@ function domainOf(entityId: string): string {
return entityId.slice(0, entityId.indexOf('.'));
}
function serviceCatalogPresent(hass: any): boolean {
return !!hass?.services && typeof hass.services === 'object'
&& Object.keys(hass.services).length > 0;
}
function serviceExists(hass: any, domain: string, service: string): boolean {
if (!serviceCatalogPresent(hass)) return true;
if (!hass?.services || typeof hass.services !== 'object') return false;
const services = hass.services?.[domain];
return !!services && Object.prototype.hasOwnProperty.call(services, service);
}
@@ -134,9 +183,9 @@ function registryDisabled(registryHass: any, entityId: string): boolean {
function supportedFeature(state: any, mask: number): boolean {
const raw = state?.attributes?.supported_features;
if (raw == null || raw === '') return true;
if (raw == null || raw === '') return false;
const features = Number(raw);
return Number.isFinite(features) && (features & mask) !== 0;
return Number.isFinite(features) && (features & mask) === mask;
}
function secureEntity(hass: any, registryHass: any, entityId: string): boolean {
@@ -198,16 +247,20 @@ function resolvePowerEntity(
if (stateObject.state === 'unavailable') {
return unsupportedSingle(hass, registryHass, ref, entityId, 'unavailable', 'power');
}
if (!POWER_DOMAINS.has(domain)) {
const adapter = POWER_ADAPTERS[domain];
if (!adapter) {
return unsupportedSingle(hass, registryHass, ref, entityId, 'unsupported');
}
let nextEffect: ToggleNextEffect;
let requested: 'turn_on' | 'turn_off' | 'toggle';
let requested: PowerService;
if (stateObject.state === 'unknown' || stateObject.state === '') {
if (!adapter.unknownUsesToggle) {
return unsupportedSingle(hass, registryHass, ref, entityId, 'unsupported', 'power');
}
nextEffect = 'toggle';
requested = 'toggle';
} else if (stateObject.state === 'off') {
} else if (adapter.offStates.includes(String(stateObject.state))) {
nextEffect = 'turn-on';
requested = 'turn_on';
} else {
@@ -215,7 +268,7 @@ function resolvePowerEntity(
requested = 'turn_off';
}
const featureMask = TURN_FEATURES[domain]?.[requested as 'turn_on' | 'turn_off'];
const featureMask = adapter.featureMasks?.[requested];
if (featureMask && !supportedFeature(stateObject, featureMask)) {
return unsupportedSingle(hass, registryHass, ref, entityId, 'unsupported', 'power');
}
@@ -291,20 +344,54 @@ function resolveEntity(
};
}
function entityCanRepresentToggle(entityId: string): boolean {
const domain = domainOf(entityId);
return domain === 'cover' || domain === 'valve' || domain === 'lock'
|| domain === 'alarm_control_panel' || POWER_DOMAINS.has(domain);
}
function ownCandidate(device: DevItem, registryHass: any): { entityId: string; via: ToggleTargetVia } | null {
function ownRoleCandidates(device: DevItem, registryHass: any): string[] {
if (device.bindingKind === 'entity' && device.bindingRef) {
return { entityId: device.bindingRef, via: 'binding' };
return [device.bindingRef];
}
const candidates = device.entities.length ? device.entities : device.allEntities || [];
const role = resolvedDeviceStateEntities(registryHass, candidates);
const entityId = role.find(entityCanRepresentToggle) || role[0] || null;
return entityId ? { entityId, via: 'device-role' } : null;
return resolvedDeviceStateEntities(registryHass, candidates);
}
function ownControllableCandidate(
device: DevItem, registryHass: any,
): { entityId: string; via: ToggleTargetVia } | null {
const role = ownRoleCandidates(device, registryHass);
const entityId = role.find(isControllable) || null;
return entityId ? {
entityId,
via: device.bindingKind === 'entity' ? 'binding' : 'device-role',
} : null;
}
/**
* Resolve the first supported member of the already-selected functional role.
* Missing/unavailable/secure members remain the explained target and never
* retarget. Capability-unsupported peers may be skipped only within that same
* role; `resolvedDeviceStateEntities` has already excluded random sibling
* config switches from a stronger cover/climate/media role.
*/
function resolveOwnEntity(
hass: any, registryHass: any, device: DevItem,
): SingleResolution | null {
const role = ownRoleCandidates(device, registryHass);
if (!role.length) return null;
const via: ToggleTargetVia = device.bindingKind === 'entity' ? 'binding' : 'device-role';
if (via === 'binding') return resolveEntity(hass, registryHass, role[0], via);
let firstUnsupported: SingleResolution | null = null;
let firstDisabled: SingleResolution | null = null;
for (const entityId of role) {
const result = resolveEntity(hass, registryHass, entityId, via);
if (result.command) return result;
const reason = result.skipped?.reason;
if (reason === 'missing' || reason === 'unavailable' || reason === 'secure') return result;
if (reason === 'ha-disabled') {
firstDisabled ||= result;
continue;
}
firstUnsupported ||= result;
}
return firstUnsupported || firstDisabled;
}
function reasonForSingle(result: SingleResolution): ToggleNoneReason {
@@ -333,7 +420,12 @@ export function projectedTapAction(
): 'info' | 'more-info' | 'toggle' | 'run' {
if (persisted === 'cover' || persisted === 'toggle') return 'toggle';
if (persisted === 'more-info' || persisted === 'run' || persisted === 'info') return persisted;
return defaultDomain === 'light' ? 'toggle' : 'info';
// Only actual absence activates the light default. An unknown persisted
// token is invalid data, not an absent choice: fail closed to the local card
// so the UI projection and `toggleOriginOf()` cannot disagree.
return persisted == null || persisted === ''
? (defaultDomain === 'light' ? 'toggle' : 'info')
: 'info';
}
export function toggleOriginOf(device: DevItem): ToggleOrigin | null {
@@ -372,7 +464,7 @@ function resolveControls(options: ResolveToggleOptions): ResolvedToggleIntent {
list.push(source);
markerSources.set(source.key, list);
}
const own = ownCandidate(device, registryHass);
const own = ownControllableCandidate(device, registryHass);
const markerDevices = new Map<string, DevItem>();
for (const item of devices) {
const markerId = String(item.marker?.id || item.id || '');
@@ -485,22 +577,23 @@ export function resolveToggleIntent(options: ResolveToggleOptions): ResolvedTogg
}
if (origin === 'legacy-cover') {
const candidates = device.entities.length ? device.entities : device.allEntities || [];
const entityId = candidates.find((eid) => eid.startsWith('cover.'));
// Legacy cover is an explicit historical target choice. Keep its identity
// even if HA later disables that cover while other active siblings remain.
// An active cover still wins: `allEntities` may contain an older disabled
// peer before it, and registry order must not downgrade a working target.
const entityId = device.entities.find((eid) => eid.startsWith('cover.'))
|| device.allEntities?.find((eid) => eid.startsWith('cover.'));
return entityId
? singleIntent(origin, resolveEntity(hass, registryHass, entityId, 'device-role'))
: emptyIntent(origin, 'no-actionable-entity');
}
const candidate = ownCandidate(device, registryHass);
if (!candidate) {
const own = resolveOwnEntity(hass, registryHass, device);
if (!own) {
return emptyIntent(origin, device.virtual || device.bindingKind === 'virtual'
? 'no-actionable-entity' : 'no-binding');
}
return singleIntent(
origin,
resolveEntity(hass, registryHass, candidate.entityId, candidate.via),
);
return singleIntent(origin, own);
}
/** Entity which owns cover presentation, even while temporarily unavailable. */
+81 -28
View File
@@ -135,7 +135,7 @@ import {
clampCanvasR, clampCanvasN, type ContentItem, type Rect,
} from './space-geometry';
import { optimizePlans, type OptimizeReport } from './plan-optimizer';
import { langOf, t, type I18nKey } from './i18n';
import { hasTranslation, langOf, t, type I18nKey } from './i18n';
import { CommandStack } from './command-stack';
import { resolvedSvgScreenBlend, svgScreenBlendSupported } from './glow-blend';
import {
@@ -179,7 +179,7 @@ import {
import { renderOpeningTunnelFills } from './render/opening-tunnels';
import { safeStoredColor } from './color';
const CARD_VERSION = '1.62.0-beta.3';
const CARD_VERSION = '1.62.0-beta.4';
/** Keeps every previously valid scale at the maximum 20 cm grid scale lossless. */
const DECOR_TEXT_CM_MAX = 2000;
const CELL_CM_MIN = 0.1;
@@ -3150,7 +3150,11 @@ class HouseplanCard extends LitElement {
return this._renderDeviceSnapshot?.devices || this._devices;
}
/** Full registry metadata for dialogs/ghost labels; never use for actions. */
/**
* Full registry metadata for diagnostics and action safety checks. Entity
* states/services for actions always come from the active `_planHass`;
* disabled rows are exposed here only so the resolver can reject/explain.
*/
private get _fullRegistryHass(): any {
void this._planHass;
return this._planHassMemo?.full || this.hass;
@@ -3783,16 +3787,24 @@ class HouseplanCard extends LitElement {
this._openMarkerDialog(d);
return;
}
const action = projectedTapAction(d.tapAction, d.primary?.split('.')[0]);
// The renderer may deliberately keep an older, complete visual snapshot
// on screen while a new HA/config frame is being committed (#73). Actions
// must never inherit that visual staleness: resolve the current DevItem by
// stable marker id before reading tap_action, controls or the binding.
const actionDevice = this._devices.find((item) => item.id === d.id);
if (!actionDevice) return; // marker disappeared from the live config
const action = projectedTapAction(
actionDevice.tapAction, actionDevice.primary?.split('.')[0],
);
// the accidental-tap guard (owner's spec 2026-07-29): any state-changing
// action — toggle or run — may ask first. The dialog is ours, not the
// browser confirm(), so it works and looks right on a wall tablet.
const guarded = (text: string, exec: () => void): void => {
if (d.marker?.tap_confirm) this._tapConfirm = { text, exec };
if (actionDevice.marker?.tap_confirm) this._tapConfirm = { text, exec };
else exec();
};
if (action === 'toggle') {
const initial = this._toggleIntent(d);
const initial = this._toggleIntent(actionDevice);
if (!initial?.command) return; // configured no-target is an intentional, quiet no-op
const execute = (intent: ResolvedToggleIntent): void => {
const command = intent.command;
@@ -3800,12 +3812,12 @@ class HouseplanCard extends LitElement {
this.hass.callService(command.domain, command.service, command.data)
.catch((e: any) => this._showToast(this._t('toast.error', { err: this._errText(e) })));
};
const name = toggleIntentName(initial) || d.name;
if (d.marker?.tap_confirm) {
const name = toggleIntentName(initial) || actionDevice.name;
if (actionDevice.marker?.tap_confirm) {
this._tapConfirm = {
text: this._t('confirm.tap_toggle', { name }),
exec: () => {
const currentDevice = this._devices.find((item) => item.id === d.id);
const currentDevice = this._devices.find((item) => item.id === actionDevice.id);
const current = currentDevice ? this._toggleIntent(currentDevice) : null;
if (!current?.command || !sameToggleCommandTargets(initial.command, current.command)) {
this._showToast(this._t('toast.tap_target_changed'));
@@ -3817,9 +3829,20 @@ class HouseplanCard extends LitElement {
} else execute(initial);
return;
}
if (!this._deviceBindingActive(d)) return;
// The House Plan device card is a local informational surface built from
// the DevItem that is already on screen. It does not call an HA service
// and must not disappear behind a second, momentary registry-status check
// (notably on compound cover/curtain devices). HA-backed actions below
// keep the active-binding gate. Closing the stage gesture first also
// prevents a pending pan/long-press lifecycle from swallowing the modal.
if (action === 'info') {
this._interruptViewGesture();
this._infoCard = d;
return;
}
if (!this._deviceBindingActive(actionDevice)) return;
if (action === 'run') {
const target = d.marker?.tap_target || '';
const target = actionDevice.marker?.tap_target || '';
const svc = runServiceFor(target);
const st = this.hass.states[target];
if (!svc || !st) {
@@ -3828,11 +3851,13 @@ class HouseplanCard extends LitElement {
}
const name = st.attributes?.friendly_name || target;
guarded(this._t('confirm.tap_run', { name }), () => {
if (!this._deviceBindingActive(d) || !this._planEntityAvailable(target)) return;
if (!this._deviceBindingActive(actionDevice) || !this._planEntityAvailable(target)) return;
this.hass
.callService(svc.domain, svc.service, { entity_id: target })
.then(() => {
this._stampActivity(d.id, 'event', this._activitySourceKey(d));
this._stampActivity(
actionDevice.id, 'event', this._activitySourceKey(actionDevice),
);
this.requestUpdate();
this._showToast(this._t('toast.run_started', { name }));
})
@@ -3840,8 +3865,8 @@ class HouseplanCard extends LitElement {
});
return;
}
if (action === 'more-info' && d.primary) {
this._openMoreInfo(d.primary);
if (action === 'more-info' && actionDevice.primary) {
this._openMoreInfo(actionDevice.primary);
return;
}
this._infoCard = d;
@@ -3853,10 +3878,12 @@ class HouseplanCard extends LitElement {
}
/** Localize both parts of a help affordance while hp-help stays presentation-only. */
private _help(key: Extract<I18nKey, `${string}.help`>): TemplateResult {
private _help(key: Extract<I18nKey, `${string}.help`>): TemplateResult | typeof nothing {
const ariaKey = `${key}.aria` as I18nKey;
const lang = langOf(this.hass, this._config?.language);
if (!hasTranslation(lang, key) || !hasTranslation(lang, ariaKey)) return nothing;
return html`<hp-help data-help-key=${key}
.text=${this._t(key)} .ariaLabel=${this._t(ariaKey)}></hp-help>`;
.text=${t(lang, key)} .ariaLabel=${t(lang, ariaKey)}></hp-help>`;
}
private get _stageEl(): HTMLElement | null {
@@ -4899,6 +4926,13 @@ class HouseplanCard extends LitElement {
private _setMode(mode: 'view' | 'plan' | 'devices' | 'decor'): void {
if (this._kiosk && mode !== 'view') return; // wall devices never edit
// A mode command is newer than the editor remembered by a same-route warm
// remount. Clear it before the same-mode early return: while can_write is
// pending, Lit may still be presenting the previous editor DOM even though
// the new instance already fails closed to `view`. Its close button must
// cancel the deferred editor in one press, not let the server response
// reopen it and force a second press (#95).
this._pendingNavMode = null;
if (this._mode === mode) return;
this._bootSoftCancel(); // navigation owns its own short, bounded transition
if ((mode === 'plan' || mode === 'decor') && !this._norm) {
@@ -15904,16 +15938,26 @@ class HouseplanCard extends LitElement {
});
}
/** One projection for the untouched select, hints and draft transitions. */
private _effectiveMarkerTapAction(
d: NonNullable<HouseplanCard['_markerDialog']>,
preview = this._markerPreviewDevice(d),
): string {
return d.tapActionTouched
? d.tapAction
: projectedTapAction(
d.originalHasTapAction ? d.originalTapAction : null,
preview?.primary?.split('.')[0],
);
}
/** Store one user-triggered announcement; live HA state ticks do not mutate it. */
private _announceToggleDraft(
d: NonNullable<HouseplanCard['_markerDialog']>,
): NonNullable<HouseplanCard['_markerDialog']> {
const preview = this._markerPreviewDevice(d);
const next = !d.tapActionTouched
? { ...d, tapAction: projectedTapAction(
d.originalHasTapAction ? d.originalTapAction : null,
preview?.primary?.split('.')[0],
) }
? { ...d, tapAction: this._effectiveMarkerTapAction(d, preview) }
: d;
const text = next.tapAction === 'toggle'
? this._toggleHintLines(this._toggleIntentForDialog(next)).join(' ')
@@ -16093,13 +16137,20 @@ class HouseplanCard extends LitElement {
const bindingStatus = isVirtual ? null : this._bindingStatus(d.binding);
const canOpenBindingInHa = !isVirtual && this._bindingHasHaPage(d.binding);
const previewDevice = this._markerPreviewDevice(d);
// Untouched defaults are projections, not stored values. Re-resolve the
// select from the current preview on every render: HA may reveal a more
// meaningful leading entity (most visibly `light.*`) after the dialog was
// opened. Runtime already uses that current primary, so keeping the stale
// draft value here made the select say "Device card" while a tap toggled
// the lamp. An explicit user choice remains authoritative and stable.
const effectiveTapAction = this._effectiveMarkerTapAction(d, previewDevice);
const previewSpaceDisplay = previewDevice
? spaceDisplayOf(this._serverCfg?.spaces.find((space: any) => space.id === previewDevice.space))
: null;
const previewLightDevices = previewDevice
? [...this._devices.filter((item) => item.id !== previewDevice.id), previewDevice]
: this._devices;
const toggleIntent = d.tapAction === 'toggle' && previewDevice
const toggleIntent = effectiveTapAction === 'toggle' && previewDevice
? this._toggleIntent(previewDevice, previewLightDevices) : null;
const toggleHintLines = this._toggleHintLines(toggleIntent);
const previewPresentation = previewDevice
@@ -16269,8 +16320,8 @@ class HouseplanCard extends LitElement {
${this._renderVacSection(d)}
<label>${this._t('marker.tap_label')}</label>
<select class="areasel" .value=${d.tapAction}
aria-describedby=${d.tapAction === 'toggle' ? 'marker-toggle-hint' : nothing}
<select id="marker-tap-action" class="areasel"
aria-describedby=${effectiveTapAction === 'toggle' ? 'marker-toggle-hint' : nothing}
@change=${(e: Event) => {
const next = {
...d,
@@ -16280,16 +16331,18 @@ class HouseplanCard extends LitElement {
this._markerDialog = this._announceToggleDraft(next);
}}>
${TAP_ACTIONS.map((v) => [v, 'tap.' + v.replace('-', '_')] as const).map(
([v, k]) => html`<option value=${v}>${this._t(k as any)}</option>`,
([v, k]) => html`<option value=${v} ?selected=${v === effectiveTapAction}>
${this._t(k as any)}
</option>`,
)}
</select>
${d.tapAction === 'toggle'
${effectiveTapAction === 'toggle'
? html`<div id="marker-toggle-hint" class="rhint togglehint">
${toggleHintLines.map((line) => html`<div>${line}</div>`)}
</div>
<div class="sr-only" role="status" aria-live="polite">${d.tapHintAnnouncement}</div>`
: nothing}
${d.tapAction === 'run'
${effectiveTapAction === 'run'
? (() => {
const q = d.runFilter.trim().toLowerCase();
const cands = this._runCandidates().filter(
@@ -16318,7 +16371,7 @@ class HouseplanCard extends LitElement {
: nothing}`;
})()
: nothing}
${d.tapAction === 'run' || (d.tapAction === 'toggle' && !!toggleIntent?.command)
${effectiveTapAction === 'run' || (effectiveTapAction === 'toggle' && !!toggleIntent?.command)
? html`<label class="srcrow" title=${this._t('marker.tap_confirm_tip')}>
${this._boolInput(d.tapConfirm, (v) => (this._markerDialog = { ...d, tapConfirm: v }))}
<span>${this._t('marker.tap_confirm')}</span>
+29 -6
View File
@@ -1,4 +1,5 @@
import { LitElement, css, html, nothing, type PropertyValues, type TemplateResult } from 'lit';
import { mdiHelpCircleOutline } from '@mdi/js';
import { floatingViewport, placeFloatingSurface } from './floating-surface';
import { FloatingSurfaceController } from './floating-surface-controller';
import type { HpDialog, HpOverlayCloseReason } from './hp-dialog';
@@ -21,11 +22,15 @@ export class HpHelp extends LitElement {
static styles = css`
:host {
display: inline-flex;
display: none;
flex: none;
vertical-align: middle;
}
:host([data-has-content]) {
display: inline-flex;
}
.trigger {
width: 32px;
height: 32px;
@@ -39,11 +44,17 @@ export class HpHelp extends LitElement {
background: transparent;
border: 0;
border-radius: 50%;
font: 700 15px/1 system-ui, sans-serif;
cursor: help;
-webkit-tap-highlight-color: transparent;
}
.trigger svg {
display: block;
width: 18px;
height: 18px;
fill: currentColor;
}
.trigger:hover,
.trigger:focus-visible,
.trigger[aria-expanded='true'] {
@@ -146,6 +157,10 @@ export class HpHelp extends LitElement {
this.addEventListener('keydown', this._keyDown, true);
}
protected willUpdate(): void {
this.toggleAttribute('data-has-content', this._hasContent());
}
disconnectedCallback(): void {
this.ownerDocument.removeEventListener('pointerdown', this._outsidePointerDown, true);
const win = this.ownerDocument.defaultView;
@@ -164,7 +179,7 @@ export class HpHelp extends LitElement {
}
protected updated(changed: PropertyValues): void {
if (!this.text && this._open) {
if (!this._hasContent() && this._open) {
this._closeHelp();
return;
}
@@ -186,6 +201,11 @@ export class HpHelp extends LitElement {
return this._floating.usesPopover(this._forceFallback);
}
private _hasContent(): boolean {
return typeof this.text === 'string' && this.text.trim().length > 0
&& typeof this.ariaLabel === 'string' && this.ariaLabel.trim().length > 0;
}
private _clearTimers(): void {
const win = this._window();
if (this._openTimer) win?.clearTimeout(this._openTimer);
@@ -279,7 +299,7 @@ export class HpHelp extends LitElement {
private readonly _floating = new FloatingSurfaceController(this, 'help', this._keyDown);
private async _openHelp(): Promise<void> {
if (this._open || !this.text) return;
if (this._open || !this._hasContent()) return;
this._clearTimers();
this._forceFallback = false;
this._open = true;
@@ -382,7 +402,8 @@ export class HpHelp extends LitElement {
}
render() {
const label = this.ariaLabel || 'Help';
if (!this._hasContent()) return nothing;
const label = this.ariaLabel.trim();
return html`
<span id=${this._descriptionId} class="sr-only" role="tooltip" ?hidden=${!this._open}>${this.text}</span>
<button class="trigger" type="button" aria-label=${label}
@@ -390,7 +411,9 @@ export class HpHelp extends LitElement {
aria-expanded=${this._open ? 'true' : 'false'}
@pointerenter=${this._triggerPointerEnter} @pointerleave=${this._triggerPointerLeave}
@focus=${this._triggerFocus} @blur=${this._triggerBlur} @click=${this._triggerClick}>
<span aria-hidden="true">?</span>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d=${mdiHelpCircleOutline}></path>
</svg>
</button>
${this._usesPopover() ? this._tooltipTemplate(true) : nothing}
`;
+6
View File
@@ -26,4 +26,10 @@ export function t(lang: Lang, key: Key, vars?: Record<string, string | number>):
return subst(DICTS[lang][key] ?? en[key] ?? key, vars);
}
/** Whether a localized value exists and contains useful text after fallback. */
export function hasTranslation(lang: Lang, key: string): boolean {
const value = DICTS[lang][key] ?? DICTS.en[key];
return typeof value === 'string' && value.trim().length > 0;
}
export type { Key as I18nKey };