mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 13:18:58 +00:00
Release v1.62.0-beta.4 candidate
This commit is contained in:
+136
-43
@@ -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
@@ -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
@@ -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}
|
||||
`;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user