Files
homeassistant_espscreen/web/src/components/TopbarInspector.vue
T
Max Gramser 1dc2eab0cf Speak the editor in each person's Home Assistant language: every text in the editor section of en.json, the mockup in the screens' language, and one Language & region card for all screens
- vue-i18n 11 with the Composition API: English comes with the page, any other language loads the first time it is
  needed from screen_manager/translations, and a text a language lacks shows in English. The build keeps only _meta
  and editor of each file (vite.config.ts), never the screens' or the add-on's texts.
- The editor follows the language of the user's Home Assistant profile (<html lang> of Home Assistant's page), else
  the browser's; plural forms follow the file's _meta.plural, with the screens' rules.
- Every request tells the add-on the editor's language (X-ESP-Screens-Language), so its own words can follow it.
- The mockup draws the screens' words (Unavailable, Page 2 ›, the date, "5 min ago") in the screens' language, with
  their clock and number format.
- Settings → Language & region: the screen language, time format and number format for every screen, with the user's
  own profile offered when it differs. The per-screen clock choice goes; the top bar points there. A screen that
  still needs the new language says so in its update badge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:48:12 +02:00

193 lines
11 KiB
Vue

<script setup lang="ts">
// The top bar: the name on the left; on the right up to six items: the time, an analog clock, the date, or an
// entity's state or last change. Choices apply live; the bar on every page follows.
import { computed, ref } from "vue";
import { t } from "../i18n";
import { barLayout, BUILTIN_ICONS, clockText, dateText, glyph, itemKey } from "../model/topbar";
import {
automaticIcon, barMetrics, clock24, closeInspector, entityName, iconNamed, markDirty, moveTopbarItem, openBar, openBarAdd,
removeTopbarItem, screenLanguage, screenText, setTopbarItems, state, supports, topbarItems, topbarLabel, topbarMax, topbarView,
} from "../store";
import type { HeaderItem } from "../types";
import IconPicker from "./IconPicker.vue";
import Segmented from "./Segmented.vue";
import TopbarSvg from "./TopbarSvg.vue";
const props = defineProps<{ index: number }>();
const items = computed(() => topbarItems());
const item = computed<HeaderItem | undefined>(() => items.value[props.index]);
const lay = computed(() => {
void state.fontsVersion; void state.now; void state.topbarPreviews;
return barLayout(items.value, barMetrics.value, state.layout?.title || screenText("editor.mockup.home"), topbarView);
});
const overflow = computed(() => lay.value.dropped);
const needed = computed(() => state.inventory.header?.min_firmware || "0.2.32");
const supported = computed(() => { const [a, b, c] = needed.value.split(".").map(Number); return supports(a, b, c); });
const hint = computed(() => supported.value
? t(overflow.value.size ? "editor.topbar.hint.overflow" : "editor.topbar.hint.reorder")
: t("editor.topbar.hint.needs_firmware", { version: needed.value }));
const detail = (it: HeaderItem, i: number) => {
const view = topbarView(it);
return !view.shown ? t("editor.topbar.detail.hidden") : overflow.value.has(i) ? t("editor.topbar.detail.overflow") : view.analog ? t("editor.topbar.detail.dial") : view.text;
};
const iconOf = (it: HeaderItem) => {
const view = topbarView(it);
return view.analog || it.type !== "entity" ? iconNamed(BUILTIN_ICONS[it.type])?.cp : view.icon;
};
const justAdded = (it: HeaderItem) => state.topbarAdded?.key === itemKey(it) && Date.now() - state.topbarAdded.time < 1200;
function rename(value: string) {
if (!state.layout) return;
state.layout.title = value;
markDirty();
}
function update(patch: Partial<HeaderItem>) {
const list = [...items.value];
list[props.index] = { ...list[props.index], ...patch };
setTopbarItems(list);
}
const liveNote = computed(() => {
if (!item.value) return "";
const view = topbarView(item.value);
return !view.shown ? t("editor.topbar.live.hidden") : overflow.value.has(props.index) ? t("editor.topbar.live.overflow") : t("editor.topbar.live.looks");
});
const samples = computed(() => ({
clock: clockText(clock24.value, new Date(state.now)),
analog: t("editor.topbar.analog_sample"),
date: dateText(new Date(state.now), screenLanguage.value),
} as Record<string, string>));
// Pointer drag between the rows, mouse and touch (touch after a short hold, so the list still scrolls). The order
// updates while dragging, the mockup follows, and a finished drag is not a click.
const drag = ref({ index: -1, active: false, moved: false });
let start: { x: number; y: number } | null = null, timer = 0, pointerId: number | null = null, suppressUntil = 0;
function down(e: PointerEvent, i: number) {
if (e.button !== 0 || (e.target as HTMLElement).closest(".x")) return;
if (e.pointerType !== "touch") e.preventDefault();
drag.value = { index: i, active: false, moved: false };
start = { x: e.clientX, y: e.clientY };
pointerId = e.pointerId;
clearTimeout(timer);
if (e.pointerType === "touch") timer = window.setTimeout(begin, 260);
document.addEventListener("pointermove", move);
document.addEventListener("pointerup", end);
document.addEventListener("pointercancel", end);
}
function begin() {
drag.value.active = true;
document.addEventListener("touchmove", block, { passive: false });
}
function block(e: TouchEvent) { if (drag.value.active) e.preventDefault(); }
function move(e: PointerEvent) {
if (e.pointerId !== pointerId || !start) return;
if (!drag.value.active) {
const distance = Math.hypot(e.clientX - start.x, e.clientY - start.y);
if (e.pointerType === "touch") { if (distance > 10) { clearTimeout(timer); start = null; } return; }
if (distance < 6) return;
begin();
}
// The target is the row whose middle the pointer passed.
const rows = [...document.querySelectorAll<HTMLElement>(".items .item[data-index]")];
let target = drag.value.index;
rows.forEach((row, i) => {
const r = row.getBoundingClientRect();
if (i < drag.value.index && e.clientY < r.top + r.height / 2) target = Math.min(target, i);
if (i > drag.value.index && e.clientY > r.top + r.height / 2) target = Math.max(target, i);
});
if (target !== drag.value.index && state.layout) {
const list = [...items.value];
list.splice(target, 0, ...list.splice(drag.value.index, 1));
state.layout.header = { items: list };
const selectedMoved = props.index === drag.value.index;
drag.value.index = target;
drag.value.moved = true;
if (selectedMoved) openBar(target);
}
}
function end(e: PointerEvent) {
if (e.pointerId !== pointerId) return;
clearTimeout(timer);
document.removeEventListener("pointermove", move);
document.removeEventListener("pointerup", end);
document.removeEventListener("pointercancel", end);
document.removeEventListener("touchmove", block);
if (drag.value.active) {
suppressUntil = Date.now() + 400;
if (drag.value.moved) markDirty();
}
drag.value = { index: -1, active: false, moved: false };
start = null; pointerId = null;
}
function pick(i: number) {
if (Date.now() < suppressUntil) return;
openBar(i);
}
function onKey(e: KeyboardEvent, i: number) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openBar(i); return; }
const step = ({ ArrowUp: -1, ArrowDown: 1 } as Record<string, number>)[e.key];
if (step && moveTopbarItem(i, i + step)) { if (props.index === i) openBar(i + step); e.preventDefault(); }
}
</script>
<template>
<div class="dr-head">
<span class="av mdi" style="background: var(--seg); color: var(--ink-2)">{{ item ? glyph(iconOf(item) || "F0150") : glyph("F0150") }}</span>
<span class="tx">
<b>{{ item ? (item.type === "entity" ? entityName(item.entity!) : topbarLabel(item)) : t("editor.topbar.title") }}</b>
<small :class="{ mono: item?.type === 'entity' }">{{ item ? (item.type === "entity" ? item.entity : t("editor.topbar.builtin")) : t("editor.topbar.items", { used: items.length }, topbarMax()) }}</small>
</span>
<button type="button" class="icon-btn" :aria-label="t('editor.common.close')" @click="closeInspector">✕</button>
</div>
<div class="dr-body">
<div class="f">
<label class="f-label" for="title">{{ t("editor.topbar.name") }}</label>
<input id="title" :value="state.layout?.title" maxlength="60" :placeholder="t('editor.topbar.name_placeholder')" @input="rename(($event.target as HTMLInputElement).value)" />
</div>
<div class="f">
<span class="f-label" id="topbar-caption">{{ t("editor.topbar.right") }} <span style="text-transform: none; letter-spacing: 0; font-weight: 500"> · {{ items.length }} / {{ topbarMax() }}</span></span>
<div class="items" id="topbar-chips" role="list" aria-labelledby="topbar-caption">
<div v-for="(it, i) in items" :key="itemKey(it) + i" class="item" role="listitem" tabindex="0" :data-index="i"
:class="{ selected: i === index, 'is-hidden': !topbarView(it).shown, 'is-overflow': overflow.has(i), 'just-added': justAdded(it), 'dragging-chip': drag.active && drag.index === i }"
:aria-label="t('editor.topbar.item_label', { name: topbarLabel(it), slot: i + 1 })"
@pointerdown="down($event, i)" @click="pick(i)" @keydown="onKey($event, i)">
<span class="grip" aria-hidden="true">⋮⋮</span>
<span class="av mdi" :style="topbarView(it).color ? { color: topbarView(it).color! } : undefined">{{ iconOf(it) ? glyph(iconOf(it)!) : "" }}</span>
<span class="tx"><b>{{ topbarLabel(it) }}</b><small>{{ detail(it, i) }}</small></span>
<button type="button" class="x" :aria-label="t('editor.topbar.remove_named', { name: topbarLabel(it) })" @click.stop="removeTopbarItem(i)">✕</button>
</div>
<button type="button" class="ghost-btn" id="topbar-add" :disabled="items.length >= topbarMax()" :title="items.length >= topbarMax() ? t('editor.topbar.max', topbarMax()) : t('editor.topbar.add_title')" @click="openBarAdd">{{ t("editor.topbar.add_button") }}</button>
</div>
<small id="topbar-hint" :class="{ warn: overflow.size > 0 && supported }">{{ hint }}</small>
</div>
<template v-if="item">
<div class="live" id="topbar-live">
<small>{{ liveNote }}</small>
<TopbarSvg :items="[item]" single />
</div>
<template v-if="item.type === 'entity'">
<div class="f">
<span class="f-label">{{ t("editor.topbar.content.label") }}</span>
<Segmented :choices="(state.inventory.header?.contents || []).map((c) => [c.key, c.label] as [string, string])" :value="item.content" @pick="(v) => update({ content: v })" />
<small>{{ t("editor.topbar.content.hint") }}</small>
</div>
<IconPicker :selected="item.icon || 'auto'" :automatic="state.topbarPreviews[itemKey(item)]?.auto_icon || automaticIcon(item.entity!)" :auto-label="t('editor.topbar.auto_icon')" allow-none @pick="(n) => update({ icon: n })" />
<div class="f">
<span class="f-label">{{ t("editor.topbar.show.label") }}</span>
<Segmented :choices="(state.inventory.header?.shows || []).map((s) => [s.key, s.label] as [string, string])" :value="item.show" @pick="(v) => update({ show: v })" />
<small>{{ t("editor.topbar.show.hint") }}</small>
</div>
</template>
<!-- The clock's format is one choice for every screen, under Settings → Language & region (app 0.2.90). -->
<i18n-t v-else-if="item.type !== 'date'" :keypath="clock24 ? 'editor.topbar.clock_24' : 'editor.topbar.clock_12'" tag="small" id="topbar-clock" scope="global">
<template #settings><a href="#settings">{{ t("editor.topbar.clock_settings") }}</a></template>
</i18n-t>
<small v-else>{{ t("editor.topbar.date_hint", { date: samples.date }) }}</small>
</template>
</div>
<div v-if="item" class="dr-foot">
<button type="button" class="btn danger" @click="removeTopbarItem(index)">{{ t("editor.common.remove") }}</button>
<span class="spacer"></span>
<button type="button" class="btn quiet" :disabled="index === 0" @click="moveTopbarItem(index, index - 1) && openBar(index - 1)">{{ t("editor.topbar.up") }}</button>
<button type="button" class="btn quiet" :disabled="index >= items.length - 1" @click="moveTopbarItem(index, index + 1) && openBar(index + 1)">{{ t("editor.topbar.down") }}</button>
</div>
</template>