Files
homeassistant_espscreen/web/src/components/ActionPicker.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

119 lines
8.0 KiB
Vue

<script setup lang="ts">
// Perform action (app 0.2.67): the actions Home Assistant offers for a tile's entity, under its own names,
// and one field per value Home Assistant asks for (its selector). An empty field is left out.
import { computed } from "vue";
import { t } from "../i18n";
import { loadEntityActions, markDirty, state, supports } from "../store";
import type { EntityAction, Tile } from "../types";
import Segmented from "./Segmented.vue";
const props = defineProps<{ tile: Tile }>();
const list = computed(() => state.entityActions[props.tile.entity]);
const chosen = computed(() => props.tile.options?.action);
const entry = computed(() => list.value?.find((a) => a.action === chosen.value?.action));
const open = computed(() => state.actionPickerOpen || !chosen.value);
const rows = computed(() => {
const q = state.actionSearch.trim().toLocaleLowerCase();
return (list.value || []).filter((a) => `${a.name} ${a.action} ${a.description}`.toLocaleLowerCase().includes(q));
});
if (list.value === undefined) loadEntityActions(props.tile.entity);
function pick(action: EntityAction) {
const same = action.action === chosen.value?.action;
props.tile.options = { ...props.tile.options, action: same ? chosen.value : { action: action.action } };
state.actionPickerOpen = false;
markDirty();
}
function setField(key: string, value: unknown) {
const data: Record<string, unknown> = { ...(props.tile.options?.action?.data || {}) };
if (value === undefined) delete data[key];
else data[key] = value;
props.tile.options = { ...props.tile.options, action: { action: chosen.value!.action, ...(Object.keys(data).length ? { data } : {}) } };
markDirty();
}
const missing = computed(() => {
if (!entry.value) return [];
const data = props.tile.options?.action?.data || {};
return entry.value.fields.filter((f) => f.required && data[f.key] === undefined).map((f) => f.name);
});
type Field = EntityAction["fields"][number];
const kindOf = (field: Field) => Object.keys(field.selector || {})[0] || "text";
const configOf = (field: Field) => (field.selector || {})[kindOf(field)] || {};
const exampleOf = (field: Field) => (field.example === undefined ? "" : typeof field.example === "string" ? field.example : JSON.stringify(field.example));
// A select's own options, or the values this entity has where Home Assistant asks for one of them (a source, an effect).
function choicesOf(field: Field): [string, string][] | null {
const config = configOf(field), kind = kindOf(field);
if (Array.isArray(field.options)) return field.options.map((o) => [o, o]);
if (kind === "select" && Array.isArray(config.options) && !config.multiple)
return config.options.map((o: any) => (o && typeof o === "object" ? [String(o.value), String(o.label ?? o.value)] : [String(o), String(o)]));
return null;
}
const hex = (rgb: number[]) => "#" + rgb.map((c) => Math.max(0, Math.min(255, Number(c) || 0)).toString(16).padStart(2, "0")).join("");
const colorValue = (value: unknown) => (Array.isArray(value) && value.length === 3 ? hex(value as number[]) : "#ffffff");
const textValue = (value: unknown) => (value === undefined ? "" : typeof value === "string" ? value : JSON.stringify(value));
function onText(field: Field, raw: string) {
const text = raw.trim();
if (!text) return setField(field.key, undefined);
if (kindOf(field) === "text") return setField(field.key, raw);
try { setField(field.key, JSON.parse(text)); } catch { setField(field.key, raw); }
}
function onNumber(field: Field, raw: string) {
setField(field.key, raw === "" || !Number.isFinite(Number(raw)) ? undefined : Number(raw));
}
const unitOf = (field: Field) => configOf(field).unit_of_measurement || (kindOf(field) === "color_temp" ? configOf(field).unit || "" : "");
</script>
<template>
<div class="f">
<span class="f-label">{{ t("editor.action.label") }}</span>
<small v-if="list === undefined">{{ t("editor.action.asking") }}</small>
<small v-else-if="list === null" class="warn">{{ t("editor.action.not_answering") }}</small>
<template v-else>
<button type="button" class="row" :aria-expanded="open ? 'true' : 'false'" @click="state.actionPickerOpen = !open">
<span class="tx">
<b>{{ entry ? entry.name : chosen ? chosen.action : t("editor.action.choose") }}</b>
<small v-if="chosen" class="mono">{{ chosen.action }}</small>
</span>
<span class="link">{{ open ? t("editor.common.close") : t("editor.common.change") }}</span>
</button>
<div v-if="open" class="picker">
<input v-model="state.actionSearch" type="search" :placeholder="t('editor.action.search')" :aria-label="t('editor.action.search_label')" />
<div class="action-list">
<button v-for="action in rows" :key="action.action" type="button" class="action-choice" :aria-pressed="action.action === chosen?.action ? 'true' : 'false'" @click="pick(action)">
<strong>{{ action.name }}</strong>
<small>{{ action.description ? `${action.action} · ${action.description}` : action.action }}</small>
</button>
<p v-if="!rows.length" class="hint">{{ t("editor.action.none_found") }}</p>
</div>
</div>
<small v-if="chosen && !entry" class="warn">{{ t("editor.action.gone") }}</small>
<template v-if="entry && !open">
<div v-for="field in entry.fields" :key="field.key" class="f">
<span class="f-label">{{ field.required ? field.name : t("editor.action.optional", { name: field.name }) }}</span>
<Segmented v-if="kindOf(field) === 'boolean'" :choices="[['', t('editor.action.not_set')], ['true', t('editor.action.on')], ['false', t('editor.action.off')]]" :value="chosen?.data?.[field.key] === undefined ? '' : String(chosen?.data?.[field.key])"
@pick="(v) => setField(field.key, v === '' ? undefined : v === 'true')" />
<Segmented v-else-if="choicesOf(field) && choicesOf(field)!.length <= 4" :choices="[['', t('editor.action.not_set')], ...choicesOf(field)!]" :value="chosen?.data?.[field.key] === undefined ? '' : String(chosen?.data?.[field.key])"
@pick="(v) => setField(field.key, v === '' ? undefined : v)" />
<select v-else-if="choicesOf(field)" :aria-label="field.name" :value="String(chosen?.data?.[field.key] ?? '')" @change="setField(field.key, ($event.target as HTMLSelectElement).value === '' ? undefined : ($event.target as HTMLSelectElement).value)">
<option value="">{{ t("editor.action.not_set") }}</option>
<option v-for="[key, text] in choicesOf(field)!" :key="key" :value="key">{{ text }}</option>
</select>
<div v-else-if="kindOf(field) === 'color_rgb'" class="action-number">
<input type="color" :aria-label="field.name" :value="colorValue(chosen?.data?.[field.key])" @input="setField(field.key, [1, 3, 5].map((i) => parseInt(($event.target as HTMLInputElement).value.slice(i, i + 2), 16)))" />
<button type="button" class="btn quiet mini" @click="setField(field.key, undefined)">{{ t("editor.action.not_set") }}</button>
</div>
<div v-else-if="kindOf(field) === 'number' || kindOf(field) === 'color_temp'" class="action-number">
<input type="number" :aria-label="field.name" :min="configOf(field).min" :max="configOf(field).max" :step="configOf(field).step === 'any' ? 'any' : configOf(field).step"
:value="chosen?.data?.[field.key] ?? ''" :placeholder="exampleOf(field)" @input="onNumber(field, ($event.target as HTMLInputElement).value)" />
<span v-if="unitOf(field)">{{ unitOf(field) }}</span>
</div>
<input v-else type="text" :aria-label="field.name" :value="textValue(chosen?.data?.[field.key])" :placeholder="exampleOf(field)" @input="onText(field, ($event.target as HTMLInputElement).value)" />
<small v-if="field.description">{{ field.description }}</small>
</div>
<small v-if="missing.length" class="warn">{{ t("editor.action.needs", { fields: missing.join(", ") }) }}</small>
</template>
<small v-if="!supports(0, 2, 58)">{{ t("editor.action.needs_firmware") }}</small>
</template>
</div>
</template>