Compare commits

..
Author SHA1 Message Date
Paul Bottein 1717d07049 Use card editor style YAML toggle for sections 2026-09-07 15:47:23 +02:00
Paul Bottein 33a31ff345 Add UI support for section strategies 2026-09-07 15:37:38 +02:00
48 changed files with 647 additions and 1035 deletions
+1 -20
View File
@@ -1,6 +1,6 @@
---
name: ha-frontend-review
description: Home Assistant frontend PR and review guidance, including implementation quality, existing review feedback, established design patterns, and the authority of UI/UX evidence. Use when reviewing frontend changes, preparing a PR, checking recurring review issues, or applying the PR template.
description: Home Assistant frontend PR and review guidance. Use when reviewing frontend changes, preparing a PR, checking recurring review issues, or applying the PR template.
---
# HA Frontend Review
@@ -92,28 +92,9 @@ Configuration and props:
- Keep APIs extensible without adding speculative abstractions.
- Validate configuration before applying changes.
## UI/UX Evidence
For user-facing changes, establish the existing design context as part of frontend review:
- Treat an applicable gallery specification as the authoritative repository source for the documented component or interaction. Inspect its written guidance and demos when present.
- Note relevant production designs, approved designs, and shared `ha-*` components so the implementation can also be compared with established frontend behaviour.
- Require the applicable gallery documentation or demo to change when the implementation intentionally changes behaviour documented there.
- Prefer an established input layout or appropriate shared component over a raw input or ad hoc control.
- Treat explicit UI/UX approval, current repository guidance, and direct human or workflow instructions as stronger evidence than inference.
- Treat linked tasks as evidence of the problem and stated requirements, not automatic UI/UX approval.
- When the direction remains uncertain, search frontend pull requests that carry or previously carried **Needs UX**. Inspect their label history, comments, and reviews rather than relying on the current label alone.
- Judge relevant feedback by its content and surrounding discussion. When GitHub provides `author_association`, prioritise feedback marked `MEMBER`.
- Prefer recent feedback about similar interactions or components. Label removal can show that a workflow gate moved on, but does not prove approval without the surrounding discussion.
- Summarise the applicable guidance instead of maintaining a reviewer list or treating one historical decision as a permanent rule.
## Review Flow
- Before reviewing a pull request, read its existing comments, reviews, and threads, including their status, resolver, and Copilot resolution reason when available.
- Prioritise substantive human feedback, especially from authors marked `MEMBER`, and validate agent-generated feedback against the code and repository guidance.
- Do not duplicate unresolved findings as new inline comments; reference any that still need action in the review summary. Treat resolved feedback as closed only when the resolution reason or surrounding discussion supports that outcome; otherwise validate it against the current code before suppressing it. Respect **Won't fix** and **Incorrect** reasons.
- Identify behavioral regressions, bugs, accessibility issues, and missing tests first.
- Record the applicable UI/UX evidence for user-facing changes, whether or not further input is needed.
- Keep style-only comments secondary unless they affect maintainability or user experience.
- Prefer small, direct fixes over large refactors during review follow-up.
- Load the matching `ha-frontend-*` skill when a finding falls within its area.
@@ -1,44 +0,0 @@
---
name: ha-frontend-ux-readiness
description: Home Assistant frontend UI/UX readiness routing for new cards, badges, screens, and interaction patterns. Use it with `ha-frontend-review` to decide whether frontend review is enough or further UI/UX input is needed. Skip it for PRs already carrying Needs UX and for fixes or technical details that preserve an established experience.
---
# HA Frontend UI/UX Readiness
Use the design context and evidence gathered through `ha-frontend-review` to decide whether a change's UI/UX direction is settled and whether further UI/UX review should be recommended or is explicitly required. This skill routes the result; it does not repeat or replace implementation, accessibility, or frontend quality review.
## Early Exit
If the PR already carries the **Needs UX** label, reviewers have already decided to ask designers or UX reviewers for guidance. Do not use this skill to make that recommendation again.
## Triage
1. Apply `ha-frontend-review` and record its UI/UX evidence.
- Include the applicable gallery specification, production or approved designs, shared `ha-*` components, task requirements, historical feedback, and direct instructions.
- Keep the implementation review separate from this readiness decision.
2. Classify the user-facing decision.
- Existing-feature fixes and technical details normally remain with frontend review when they preserve the established experience.
- New cards, badges, screens, and interaction patterns normally need this readiness check.
3. Decide whether the evidence gathered during frontend review settles the direction.
- Frontend maintainer guidance, explicit UI/UX approval, current guidance, an established pattern, or a task that settles the material visual and interaction choices can settle the direction.
- Insufficient or conflicting evidence leaves the direction unresolved.
4. Select exactly one route below and state which frontend-review evidence supports it.
## Routes
- **Frontend review sufficient:** The change preserves the established experience, or the evidence settles the material visual and interaction choices. Cite that evidence and continue with `ha-frontend-review`.
- **UI/UX review recommended:** A new user experience has material design choices that remain unresolved. Identify the decision that needs input and recommend UI/UX review.
- **UI/UX review required:** A workflow rule or direct human instruction explicitly requires UI/UX review. Cite that evidence. A new design, task link, or lack of evidence is not enough on its own.
Frontend maintainer guidance, explicit UI/UX approval, established patterns, and task requirements inform the route. A task can settle requirements without settling the UI/UX. Recommend UI/UX review when important design choices remain unresolved.
## Reporting
Keep the result concise and use this structure:
- **Route:** Frontend review sufficient, UI/UX review recommended, or UI/UX review required
- **Established patterns:** relevant gallery specifications, production designs, and shared `ha-*` components for the next reviewer to compare
- **Supporting evidence:** approval, guidance, established patterns, task requirements, direct instructions, or the evidence gap or conflict
- **Next action:** the review action implied by the route
Separate whether the UI/UX direction is settled from whether the implementation is correct.
+1 -2
View File
@@ -47,8 +47,7 @@ Detailed guidance lives in project skills under `.agents/skills/`. Load the matc
- `ha-frontend-styling`: theme variables, spacing tokens, responsive layout, RTL, and view transitions.
- `ha-frontend-testing`: lint, typecheck, Vitest, Playwright e2e dev servers, and benchmarks.
- `ha-frontend-user-facing-text`: localization, terminology, sentence case, and Home Assistant text style.
- `ha-frontend-review`: PR template use, existing review feedback, review checklist, recurring issues, and UI/UX evidence including gallery specifications.
- `ha-frontend-ux-readiness`: UI/UX routing that complements frontend review for new user experiences.
- `ha-frontend-review`: PR template use, review checklist, and recurring review issues.
- `ha-frontend-gallery`: gallery pages, demos, sidebar structure, content, and verification.
- `ha-frontend-demo`: standalone demo structure, configurations, navigation, shared stubs, and verification.
+3 -3
View File
@@ -48,7 +48,7 @@
"@codemirror/language": "6.12.4",
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.2",
"@codemirror/state": "6.7.4",
"@codemirror/state": "6.7.2",
"@codemirror/view": "6.43.11",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
@@ -175,10 +175,10 @@
"@vitest/coverage-v8": "4.1.11",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist": "4.28.9",
"browserslist": "4.28.8",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.10.0",
"eslint": "10.9.1",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
-12
View File
@@ -57,18 +57,6 @@ export const formatDurationLong = (
duration: HaDurationData
) => formatDurationLongMem(locale).format(duration);
const formatDurationNarrowMem = memoizeOne(
(locale: FrontendLocaleData) =>
new Intl.DurationFormat(locale.language, {
style: "narrow",
})
);
export const formatDurationNarrow = (
locale: FrontendLocaleData,
duration: HaDurationData
) => formatDurationNarrowMem(locale).format(duration);
const formatDigitalDurationMem = memoizeOne(
(locale: FrontendLocaleData) =>
new Intl.DurationFormat(locale.language, {
-5
View File
@@ -1,5 +0,0 @@
export const isExternalHassUrl = (): boolean =>
Boolean(__HASS_URL__) && __HASS_URL__ !== location.origin;
export const resolveHassUrl = (path: string): string =>
isExternalHassUrl() ? new URL(path, __HASS_URL__).toString() : path;
+13 -29
View File
@@ -1,12 +1,11 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { css, html, LitElement, nothing } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import { fireEvent } from "../common/dom/fire_event";
import { fetchHassioAddonsInfo } from "../data/hassio/addon";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./ha-alert";
import "./ha-app-icon";
import "./ha-combo-box-item";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
@@ -19,22 +18,13 @@ const SEARCH_KEYS = [
{ name: "search_labels.repository", weight: 5 },
];
interface AddonPickerItem extends PickerComboBoxItem {
slug: string;
hasIcon: boolean;
}
const rowRenderer: RenderItemFunction<AddonPickerItem> = (item) => html`
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
<ha-combo-box-item type="button">
<span slot="headline">${item.primary}</span>
<span slot="supporting-text">${item.secondary}</span>
${
item.hasIcon
? html`<ha-app-icon
slot="start"
.slug=${item.slug}
.hasIcon=${item.hasIcon}
></ha-app-icon>`
item.icon
? html` <img alt="" slot="start" .src=${item.icon} /> `
: nothing
}
</ha-combo-box-item>
@@ -50,7 +40,7 @@ class HaAddonPicker extends LitElement {
@property() public helper?: string;
@state() private _addons?: AddonPickerItem[];
@state() private _addons?: PickerComboBoxItem[];
@property({ type: Boolean }) public disabled = false;
@@ -112,10 +102,11 @@ class HaAddonPicker extends LitElement {
.filter((addon) => addon.version)
.map((addon) => ({
id: addon.slug,
slug: addon.slug,
hasIcon: addon.icon,
primary: addon.name,
secondary: addon.slug,
icon: addon.icon
? `/api/hassio/addons/${addon.slug}/icon`
: undefined,
search_labels: {
description: addon.description || null,
repository: addon.repository || null,
@@ -160,22 +151,15 @@ class HaAddonPicker extends LitElement {
private _valueRenderer = (itemId: string) => {
const item = this._addons!.find((addon) => addon.id === itemId);
return html`${
item?.hasIcon
? html`<ha-app-icon
item?.icon
? html`<img
slot="start"
.slug=${item.slug}
.hasIcon=${item.hasIcon}
.alt=${item.primary ?? "Unknown"}
></ha-app-icon>`
alt=${item.primary ?? "Unknown"}
.src=${item.icon}
/>`
: nothing
}<span slot="headline">${item?.primary || "Unknown"}</span>`;
};
static styles = css`
ha-app-icon {
--ha-app-icon-size: var(--ha-space-8);
}
`;
}
declare global {
-77
View File
@@ -1,77 +0,0 @@
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { keyed } from "lit/directives/keyed";
import type { HASSDomCurrentTargetEvent } from "../common/dom/fire_event";
import { isExternalHassUrl } from "../common/url/hass-url";
import { supervisorUrl } from "../data/hassio/common";
@customElement("ha-app-icon")
export class HaAppIcon extends LitElement {
@property() public slug = "";
@property({ attribute: "has-icon", type: Boolean })
public hasIcon?: boolean;
@property() public alt = "";
@property() public loading: "eager" | "lazy" = "eager";
@state() private _failedSrc?: string;
@query("img") private _image?: HTMLImageElement;
protected render() {
const src = supervisorUrl(`addons/${this.slug}/icon`);
if (!this.slug || this.hasIcon === false || this._failedSrc === src) {
return html`<slot></slot>`;
}
return keyed(
src,
html`
<img
src=${src}
alt=${this.alt}
loading=${this.loading}
crossorigin=${ifDefined(
isExternalHassUrl() ? undefined : "anonymous"
)}
referrerpolicy="no-referrer"
@error=${this._handleError}
/>
`
);
}
private _handleError(ev: HASSDomCurrentTargetEvent<HTMLImageElement>) {
if (ev.currentTarget !== this._image) {
return;
}
this._failedSrc = ev.currentTarget.getAttribute("src") ?? undefined;
}
static styles = css`
:host {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--ha-app-icon-size, var(--mdc-icon-size));
height: var(--ha-app-icon-size, var(--mdc-icon-size));
flex-shrink: 0;
}
img {
width: 100%;
height: 100%;
object-fit: contain;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-app-icon": HaAppIcon;
}
}
+1 -10
View File
@@ -51,19 +51,10 @@ export class HaComboBoxItem extends HaMdListItem {
white-space: normal;
}
::slotted(state-badge),
::slotted(img),
::slotted(ha-app-icon) {
::slotted(img) {
width: 32px;
height: 32px;
}
::slotted(ha-app-icon.colored) {
width: 24px;
height: 24px;
padding: var(--ha-space-1);
border-radius: var(--ha-border-radius-circle);
background-color: var(--app-icon-background-color);
color: var(--white-color);
}
::slotted(.code) {
font-family: var(--ha-font-family-code);
font-size: var(--ha-font-size-xs);
-13
View File
@@ -13,8 +13,6 @@ import "./ha-dialog-footer";
import "./ha-filter-pane-chip";
import "./ha-icon-button";
const HIGHLIGHT_DURATION = 1500;
/**
* Filter pane for a filtered page: a column next to the content on wide
* screens, a bottom sheet on narrow ones. Mirrors the filter pane of
@@ -115,15 +113,6 @@ export class HaFilterPane extends LitElement {
`;
}
public highlight() {
const color = getComputedStyle(this).getPropertyValue("--primary-color");
this.getAnimations().forEach((animation) => animation.cancel());
this.animate([{ outlineColor: color }, { outlineColor: "transparent" }], {
duration: HIGHLIGHT_DURATION,
easing: "ease-out",
});
}
private _close() {
fireEvent(this, "close-filter-pane");
}
@@ -144,8 +133,6 @@ export class HaFilterPane extends LitElement {
box-sizing: border-box;
overflow: hidden;
border-inline-end: 1px solid var(--divider-color);
outline: var(--ha-border-width-md) solid transparent;
outline-offset: calc(-1 * var(--ha-border-width-md));
}
/* The bottom sheet positions itself, so the pane takes no space. */
+1 -2
View File
@@ -2,7 +2,6 @@ import { isComponentLoaded } from "../common/config/is_component_loaded";
import { atLeastVersion } from "../common/config/version";
import type { HomeAssistant, LogFileDisabledReason } from "../types";
import type { HassioAddonInfo } from "./hassio/addon";
import { supervisorUrl } from "./hassio/common";
export interface LogProvider {
key: string;
@@ -19,7 +18,7 @@ export const fetchErrorLog = (hass: HomeAssistant) =>
export const getErrorLogDownloadUrl = (hass: HomeAssistant) =>
hasSupervisorCoreLogDownload(hass)
? supervisorUrl("core/logs/latest")
? "/api/hassio/core/logs/latest"
: "/api/error_log";
export const getCoreLogFileDownloadUnavailableReason = (
-4
View File
@@ -1,9 +1,5 @@
import { resolveHassUrl } from "../../common/url/hass-url";
import type { CallWS } from "../../types";
export const supervisorUrl = (path: string): string =>
resolveHassUrl(`/api/hassio/${path}`);
export interface HassioResponse<T> {
data: T;
message?: string;
+8 -10
View File
@@ -1,6 +1,6 @@
import type { HomeAssistant, PanelInfo } from "../../types";
import type { SupervisorArch } from "../supervisor/supervisor";
import { supervisorUrl, type HassioResponse } from "./common";
import type { HassioResponse } from "./common";
export interface HassioHomeAssistantInfo {
arch: SupervisorArch;
@@ -198,20 +198,18 @@ export const fetchHassioLogsFollowSkip = async (
);
export const getHassioLogDownloadUrl = (provider: string) =>
supervisorUrl(
`${provider.includes("_") ? `addons/${provider}` : provider}/logs`
);
`/api/hassio/${
provider.includes("_") ? `addons/${provider}` : provider
}/logs`;
export const getHassioLogDownloadLinesUrl = (
provider: string,
lines: number,
boot = 0
) =>
supervisorUrl(
`${
provider.includes("_") ? `addons/${provider}` : provider
}/logs${boot !== 0 ? `/boots/${boot}` : ""}?lines=${lines}`
);
`/api/hassio/${
provider.includes("_") ? `addons/${provider}` : provider
}/logs${boot !== 0 ? `/boots/${boot}` : ""}?lines=${lines}`;
export const setSupervisorOption = async (
hass: HomeAssistant,
@@ -225,4 +223,4 @@ export const setSupervisorOption = async (
});
};
export const coreLatestLogsUrl = supervisorUrl("core/logs/latest");
export const coreLatestLogsUrl = "/api/hassio/core/logs/latest";
+10 -7
View File
@@ -28,7 +28,6 @@ export interface NavigationComboBoxItem extends PickerComboBoxItem {
path: string;
image?: string;
iconColor?: string;
app?: Pick<HassioAddonInfo, "slug" | "icon">;
}
export interface BaseNavigationCommand {
@@ -39,7 +38,6 @@ export interface BaseNavigationCommand {
iconPath?: string;
iconColor?: string;
image?: string;
app?: Pick<HassioAddonInfo, "slug" | "icon">;
}
export interface ActionCommandComboBoxItem extends PickerComboBoxItem {
@@ -64,14 +62,19 @@ const generateNavigationPanelCommands = (
const primary = localize(translationKey) || panel.title || panel.url_path;
const panelApp = apps?.find(({ slug }) => slug === panel.url_path);
let image: string | undefined;
if (apps) {
const app = apps.find(({ slug }) => slug === panel.url_path);
if (app) {
image = app.icon ? `/api/hassio/addons/${app.slug}/icon` : undefined;
}
}
return {
primary,
icon,
app: panelApp
? { slug: panelApp.slug, icon: panelApp.icon }
: undefined,
image,
path: `/${panel.url_path}`,
};
});
@@ -168,7 +171,7 @@ export const generateNavigationCommands = (
for (const app of apps.filter((a) => a.version)) {
appItems.push({
path: `/config/app/${app.slug}`,
app: { slug: app.slug, icon: app.icon },
image: app.icon ? `/api/hassio/addons/${app.slug}/icon` : undefined,
primary: hass.localize(
"ui.dialogs.quick-bar.commands.navigation.app_info",
{ app: app.name }
+30 -53
View File
@@ -12,7 +12,6 @@ import { navigate } from "../../common/navigate";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import "../../components/entity/state-badge";
import "../../components/ha-adaptive-dialog";
import "../../components/ha-app-icon";
import "../../components/ha-combo-box-item";
import "../../components/ha-domain-icon";
import "../../components/ha-icon";
@@ -311,7 +310,6 @@ export class QuickBar extends LitElement {
}
const iconPath = item.icon_path || mdiDevices;
const iconColor = "iconColor" in item ? item.iconColor : undefined;
return html`
<ha-combo-box-item
@@ -336,65 +334,44 @@ export class QuickBar extends LitElement {
brand-fallback
></ha-domain-icon>
`
: "app" in item && item.app
: "image" in item && item.image
? html`
<ha-app-icon
<img
slot="start"
.slug=${item.app.slug}
.hasIcon=${item.app.icon}
.alt=${item.primary ?? "Unknown"}
class=${iconColor ? "colored" : nothing}
alt=${item.primary ?? "Unknown"}
.src=${item.image}
style=${
iconColor
? `--app-icon-background-color: ${iconColor}`
: nothing
"iconColor" in item && item.iconColor
? `background-color: ${item.iconColor}; padding: 4px; border-radius: var(--ha-border-radius-circle); width: 24px; height: 24px`
: ""
}
>
${
item.icon
? html`<ha-icon .icon=${item.icon}></ha-icon>`
: html`<ha-svg-icon .path=${iconPath}></ha-svg-icon>`
}
</ha-app-icon>
/>
`
: "image" in item && item.image
? html`
<img
slot="start"
alt=${item.primary ?? "Unknown"}
.src=${item.image}
style=${
"iconColor" in item && item.iconColor
? `background-color: ${item.iconColor}; padding: 4px; border-radius: var(--ha-border-radius-circle); width: 24px; height: 24px`
: ""
}
/>
`
: item.icon
? html`<ha-icon
style="margin: var(--ha-space-1);"
slot="start"
.icon=${item.icon}
></ha-icon>`
: "iconColor" in item && item.iconColor
? html`
<div
slot="start"
style=${`padding: 4px; border-radius: var(--ha-border-radius-circle); background-color: ${item.iconColor};`}
>
<ha-svg-icon
style="color: var(--white-color); --mdc-icon-size: 24px;"
.path=${iconPath}
></ha-svg-icon>
</div>
`
: html`
: item.icon
? html`<ha-icon
style="margin: var(--ha-space-1);"
slot="start"
.icon=${item.icon}
></ha-icon>`
: "iconColor" in item && item.iconColor
? html`
<div
slot="start"
style=${`padding: 4px; border-radius: var(--ha-border-radius-circle); background-color: ${item.iconColor};`}
>
<ha-svg-icon
style="margin: var(--ha-space-1);"
slot="start"
style="color: var(--white-color); --mdc-icon-size: 24px;"
.path=${iconPath}
></ha-svg-icon>
`
</div>
`
: html`
<ha-svg-icon
style="margin: var(--ha-space-1);"
slot="start"
.path=${iconPath}
></ha-svg-icon>
`
}
<span slot="headline">${item.primary}</span>
${
+2 -2
View File
@@ -267,9 +267,9 @@ class PartialPanelResolver extends HassRouterPage {
// screen, so later panel updates do not fire it again. Native apps remove
// it instantly because their own splash screen is still visible.
if (
removeLaunchScreen(!!this.hass.auth?.external?.config.hasSplashscreen)
removeLaunchScreen(!!this.hass.auth.external?.config.hasSplashscreen)
) {
this.hass.auth?.external?.fireMessage({ type: "frontend/loaded" });
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
}
}
}
@@ -86,7 +86,6 @@ import type { HassioStats } from "../../../../../data/hassio/common";
import {
extractApiErrorMessage,
fetchHassioStats,
supervisorUrl,
} from "../../../../../data/hassio/common";
import type { StoreAddonDetails } from "../../../../../data/supervisor/store";
import {
@@ -206,9 +205,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
<img
class="logo"
alt=""
src=${supervisorUrl(
`addons/${this._currentAddon.slug}/logo`
)}
src="/api/hassio/addons/${this._currentAddon.slug}/logo"
/>
`
: nothing
@@ -3,7 +3,6 @@ import { mdiCheckCircle, mdiHelpCircleOutline } from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../../components/ha-app-icon";
import "../../../../components/ha-svg-icon";
import type { AddonStage, AddonState } from "../../../../data/hassio/addon";
import type { HomeAssistant } from "../../../../types";
@@ -42,28 +41,21 @@ class SupervisorAppsCardContent extends LitElement {
@property() public icon = mdiHelpCircleOutline;
@property({ attribute: false }) public appSlug?: string;
@property({ attribute: false }) public hasAppIcon?: boolean;
@property({ attribute: false }) public iconImage?: string;
protected render(): TemplateResult {
return html`
<div class="app">
<div class="icon-wrapper">
${
this.appSlug
this.iconImage
? html`
<ha-app-icon
.slug=${this.appSlug}
.hasIcon=${this.hasAppIcon}
.alt=${this.iconTitle ?? ""}
.title=${this.iconTitle ?? ""}
>
<ha-svg-icon
class="app-icon"
.path=${this.icon}
></ha-svg-icon>
</ha-app-icon>
<img
class="icon-image"
src=${this.iconImage}
.title=${this.iconTitle}
alt=${this.iconTitle ?? ""}
/>
`
: html`
<ha-svg-icon
@@ -142,15 +134,15 @@ class SupervisorAppsCardContent extends LitElement {
width: 40px;
height: 40px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.app-icon {
margin-left: var(--ha-space-2);
margin-top: var(--ha-space-2);
color: var(--secondary-text-color);
}
ha-app-icon {
--ha-app-icon-size: 40px;
.icon-image {
max-height: 40px;
max-width: 40px;
}
.title {
flex: 1;
@@ -151,8 +151,11 @@ export class HaConfigAppsInstalled extends LitElement {
"ui.panel.config.apps.installed.app_running"
)
}
.appSlug=${addon.slug}
.hasAppIcon=${addon.icon}
.iconImage=${
addon.icon
? `/api/hassio/addons/${addon.slug}/icon`
: undefined
}
></supervisor-apps-card-content>
</div>
</ha-card>
@@ -116,8 +116,11 @@ export class SupervisorAppsRepositoryEl extends LitElement {
? "not_available"
: ""
}
.appSlug=${addon.slug}
.hasAppIcon=${addon.icon}
.iconImage=${
addon.icon
? `/api/hassio/addons/${addon.slug}/icon`
: undefined
}
.showTopbar=${addon.installed || !addon.available}
.topbarClass=${
addon.installed
@@ -39,9 +39,9 @@ import { capitalizeFirstLetter } from "../../../../common/string/capitalize-firs
import { truncateWithEllipsis } from "../../../../common/string/truncate-with-ellipsis";
import { handleStructError } from "../../../../common/structs/handle-errors";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import "../../../../components/automation/ha-automation-condition-live-test";
import "../../../../components/automation/ha-automation-row";
import type { HaAutomationRow } from "../../../../components/automation/ha-automation-row";
import "../../../../components/automation/ha-automation-condition-live-test";
import "../../../../components/automation/ha-automation-row-event-chip";
import "../../../../components/ha-card";
import "../../../../components/ha-dropdown";
@@ -91,7 +91,6 @@ import type { HomeAssistant } from "../../../../types";
import { isMac } from "../../../../util/is_mac";
import { showEditorToast } from "../editor-toast";
import "../ha-automation-editor-warning";
import "../ha-automation-row-options";
import { overflowStyles, rowStyles } from "../styles";
import { getDeviceTarget } from "../target/get_device_target";
import { getEntityTarget } from "../target/get_entity_target";
@@ -374,13 +373,6 @@ export default class HaAutomationActionRow extends LitElement {
)
: nothing
}
${
type === "wait_template" || type === "wait_for_trigger"
? html`<ha-automation-row-options
.config=${this.action}
></ha-automation-row-options>`
: nothing
}
${
noteTooltipText
? html`
@@ -35,9 +35,9 @@ import { capitalizeFirstLetter } from "../../../../common/string/capitalize-firs
import { truncateWithEllipsis } from "../../../../common/string/truncate-with-ellipsis";
import { handleStructError } from "../../../../common/structs/handle-errors";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import "../../../../components/automation/ha-automation-condition-live-test";
import "../../../../components/automation/ha-automation-row";
import type { HaAutomationRow } from "../../../../components/automation/ha-automation-row";
import "../../../../components/automation/ha-automation-condition-live-test";
import "../../../../components/automation/ha-automation-row-event-chip";
import "../../../../components/ha-alert";
import "../../../../components/ha-card";
@@ -73,8 +73,6 @@ import type { HomeAssistant } from "../../../../types";
import { isMac } from "../../../../util/is_mac";
import { showEditorToast } from "../editor-toast";
import "../ha-automation-editor-warning";
import "../ha-automation-row-behavior";
import "../ha-automation-row-options";
import { overflowStyles, rowStyles } from "../styles";
import { getDeviceTarget } from "../target/get_device_target";
import { getEntityTarget } from "../target/get_entity_target";
@@ -230,15 +228,6 @@ export default class HaAutomationConditionRow extends LitElement {
${capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg)
)}
${
this._getType(this.condition, this.conditionDescriptions) ===
"platform"
? html`<ha-automation-row-behavior
mode="condition"
.config=${this.condition}
></ha-automation-row-behavior>`
: nothing
}
${
target !== undefined || targetRequired
? this._renderTargets(
@@ -249,14 +238,6 @@ export default class HaAutomationConditionRow extends LitElement {
)
: nothing
}
${
this._getType(this.condition, this.conditionDescriptions) ===
"platform"
? html`<ha-automation-row-options
.config=${this.condition}
></ha-automation-row-options>`
: nothing
}
${
this.condition.note?.trim()
? html`
@@ -1,66 +0,0 @@
import { consume, type ContextType } from "@lit/context";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { internationalizationContext } from "../../../data/context";
import type {
AutomationBehaviorConditionMode,
AutomationBehaviorTriggerMode,
} from "../../../data/selector";
import { rowSummaryStyles } from "./styles";
interface HaAutomationRowBehaviorConfig {
options?: {
behavior?: AutomationBehaviorTriggerMode | AutomationBehaviorConditionMode;
};
}
@customElement("ha-automation-row-behavior")
export class HaAutomationRowBehavior extends LitElement {
@property({ attribute: false }) public config!: HaAutomationRowBehaviorConfig;
@property() public mode: "trigger" | "condition" = "trigger";
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
private get _label(): string | undefined {
const behavior = this.config?.options?.behavior;
if (!behavior) {
return undefined;
}
return (
(this.mode === "condition"
? this._i18n?.localize(
`ui.components.selectors.automation_behavior.condition.options.${behavior as AutomationBehaviorConditionMode}.row_label`
)
: this._i18n?.localize(
`ui.components.selectors.automation_behavior.trigger.options.${behavior as AutomationBehaviorTriggerMode}.row_label`
)) || undefined
);
}
protected render() {
const label = this._label;
if (!label) {
return nothing;
}
return html`<span class="dot-separator"></span>${label}`;
}
protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
// Collapse the host when empty so the parent flex gap is not reserved.
this.toggleAttribute("hidden", !this._label);
}
static styles = rowSummaryStyles;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-row-behavior": HaAutomationRowBehavior;
}
}
@@ -1,125 +0,0 @@
import { consume, type ContextType } from "@lit/context";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { createDurationData } from "../../../common/datetime/create_duration_data";
import { formatDurationNarrow } from "../../../common/datetime/format_duration";
import { hasTemplate } from "../../../common/string/has-template";
import type { ForDict } from "../../../data/automation";
import { internationalizationContext } from "../../../data/context";
import { rowSummaryStyles } from "./styles";
interface HaAutomationRowOptionsConfig {
options?: {
for?: string | number | ForDict;
offset?: string | number | ForDict;
offset_type?: "before" | "after";
};
timeout?: string | number | ForDict;
}
@customElement("ha-automation-row-options")
export class HaAutomationRowOptions extends LitElement {
@property({ attribute: false }) public config!: HaAutomationRowOptionsConfig;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
protected render() {
const supportedOptions = this._formatOptions(this.config, this._i18n);
if (!supportedOptions.length) {
return nothing;
}
return html`<span class="dot-separator"></span
>${supportedOptions.join(", ")}`;
}
protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
// Collapse the host when empty so the parent flex gap is not reserved.
this.toggleAttribute(
"hidden",
!this._formatOptions(this.config, this._i18n).length
);
}
private _formatOptions = memoizeOne(
(
config: HaAutomationRowOptionsConfig,
_i18n: typeof this._i18n
): string[] => {
const parts: string[] = [];
if ("options" in config && config.options) {
const options = config.options;
const forDuration = this._duration(options.for);
if (forDuration) {
parts.push(
this._i18n.localize(
"ui.panel.config.automation.editor.row_options.for",
{
duration: forDuration,
}
)
);
}
const offsetDuration = this._duration(options.offset);
if (offsetDuration) {
const offsetType =
options.offset_type === "before" ? "before" : "after";
parts.push(
this._i18n.localize(
`ui.panel.config.automation.editor.row_options.offset_${offsetType}`,
{ offset: offsetDuration }
)
);
}
}
if ("timeout" in config) {
const timeoutDuration = this._duration(config.timeout);
if (timeoutDuration) {
parts.push(
this._i18n.localize(
"ui.panel.config.automation.editor.row_options.timeout",
{ duration: timeoutDuration }
)
);
}
}
return parts;
}
);
private _duration(value: unknown): string | undefined {
if (value === undefined || hasTemplate(value)) {
return undefined;
}
const duration = createDurationData(
value as string | number | ForDict | undefined
);
if (
!duration ||
Object.values(duration).some(
(part) => part !== undefined && !Number.isFinite(part)
)
) {
return undefined;
}
return formatDurationNarrow(this._i18n.locale, duration);
}
static styles = rowSummaryStyles;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-row-options": HaAutomationRowOptions;
}
}
-19
View File
@@ -3,25 +3,6 @@ import { css } from "lit";
export const SIDEBAR_MIN_WIDTH = 375;
export const CONTENT_MIN_WIDTH = 350;
export const rowSummaryStyles = css`
:host {
display: inline-flex;
align-items: center;
gap: var(--ha-space-2);
vertical-align: middle;
color: var(--ha-color-text-secondary);
}
:host([hidden]) {
display: none;
}
.dot-separator {
width: 2px;
height: 2px;
border-radius: var(--ha-border-radius-circle);
background-color: currentColor;
}
`;
export const rowStyles = css`
ha-icon-button {
--mdc-theme-text-primary-on-background: var(--primary-text-color);
@@ -72,8 +72,6 @@ import type { HomeAssistant } from "../../../../types";
import { isMac } from "../../../../util/is_mac";
import { showEditorToast } from "../editor-toast";
import "../ha-automation-editor-warning";
import "../ha-automation-row-behavior";
import "../ha-automation-row-options";
import { overflowStyles, rowStyles } from "../styles";
import { getDeviceTarget } from "../target/get_device_target";
import { getEntityTarget } from "../target/get_entity_target";
@@ -255,13 +253,6 @@ export default class HaAutomationTriggerRow extends LitElement {
${capitalizeFirstLetter(
describeTrigger(this.trigger, this.hass, this._entityReg)
)}
${
type === "platform"
? html`<ha-automation-row-behavior
.config=${this.trigger}
></ha-automation-row-behavior>`
: nothing
}
${
target !== undefined || targetRequired
? this._renderTargets(
@@ -272,13 +263,6 @@ export default class HaAutomationTriggerRow extends LitElement {
)
: nothing
}
${
type === "platform"
? html`<ha-automation-row-options
.config=${this.trigger}
></ha-automation-row-options>`
: nothing
}
${
type !== "list" &&
(this.trigger as Exclude<Trigger, TriggerList>).note?.trim()
@@ -4,10 +4,8 @@ import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stringCompare } from "../../../../common/string/compare";
import "../../../../components/ha-app-icon";
import "../../../../components/ha-checkbox";
import type { HaCheckbox } from "../../../../components/ha-checkbox";
import "../../../../components/ha-svg-icon";
import type { HomeAssistant } from "../../../../types";
import "./ha-backup-formfield-label";
@@ -52,17 +50,13 @@ export class HaBackupAddonsPicker extends LitElement {
<ha-backup-formfield-label
.label=${item.name}
.version=${this.hideVersion ? undefined : item.version}
.iconPath=${item.iconPath || mdiPuzzle}
.imageUrl=${
this.addons?.find((a) => a.slug === item.slug)?.icon
? `/api/hassio/addons/${item.slug}/icon`
: undefined
}
>
<ha-app-icon
slot="icon"
.slug=${item.slug}
.hasIcon=${item.icon}
loading="lazy"
>
<ha-svg-icon
.path=${item.iconPath || mdiPuzzle}
></ha-svg-icon>
</ha-app-icon>
</ha-backup-formfield-label>
</ha-checkbox>
`
@@ -92,10 +86,6 @@ export class HaBackupAddonsPicker extends LitElement {
padding-inline-start: var(--ha-space-2);
padding-bottom: var(--ha-space-3);
}
ha-app-icon {
--ha-app-icon-size: var(--ha-space-6);
}
`;
}
@@ -15,25 +15,20 @@ class SupervisorFormfieldLabel extends LitElement {
protected render(): TemplateResult {
return html`
<slot name="icon">
${
this.imageUrl
? html`<img
loading="lazy"
alt=""
src=${this.imageUrl}
class="icon"
/>`
: this.iconPath
? html`
<ha-svg-icon
.path=${this.iconPath}
class="icon"
></ha-svg-icon>
`
: nothing
}
</slot>
${
this.imageUrl
? html`<img
loading="lazy"
alt=""
src=${this.imageUrl}
class="icon"
/>`
: this.iconPath
? html`
<ha-svg-icon .path=${this.iconPath} class="icon"></ha-svg-icon>
`
: nothing
}
<span class="label">
${this.label}
${
@@ -17,7 +17,6 @@ import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../../../common/config/is_component_loaded";
import { caseInsensitiveStringCompare } from "../../../../../common/string/compare";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-app-icon";
import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-icon-next";
@@ -205,12 +204,23 @@ export class SerialConfigDashboard extends LitElement {
});
}
private _renderConsumerIcon(src: string, alt: string): TemplateResult {
return html`<img
slot="start"
.src=${src}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=${alt}
/>`;
}
// The panel the integration behind this consumer is configured in, if it has
// one. A stopped consumer has no panel loaded to send the user to.
private _consumerPanel(consumer: SerialPortConsumer): string | undefined {
if (!consumer.active) {
return undefined;
}
const domain =
consumer.kind === "config_entry"
? consumer.domain
@@ -243,28 +253,22 @@ export class SerialConfigDashboard extends LitElement {
<ha-md-list-item type="link" href=${href} class="consumer">
${
consumer.kind === "config_entry"
? html`<img
slot="start"
.src=${brandsUrl(
? this._renderConsumerIcon(
brandsUrl(
{
domain: consumer.domain!,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=${consumer.domain!}
/>`
),
consumer.domain!
)
: consumer.kind === "app"
? html`<ha-app-icon
slot="start"
.slug=${consumer.slug!}
.alt=${consumer.title || consumer.slug!}
>
<ha-svg-icon .path=${mdiPuzzle}></ha-svg-icon>
</ha-app-icon>`
? this._renderConsumerIcon(
`/api/hassio/addons/${consumer.slug}/icon`,
consumer.slug!
)
: html`<ha-svg-icon
slot="start"
.path=${mdiPuzzle}
@@ -669,8 +673,7 @@ export class SerialConfigDashboard extends LitElement {
--md-list-item-leading-space: var(--ha-space-14);
}
ha-md-list-item.consumer img[slot="start"],
ha-md-list-item.consumer ha-app-icon[slot="start"] {
ha-md-list-item.consumer img[slot="start"] {
width: 24px;
height: 24px;
}
+33 -39
View File
@@ -17,7 +17,6 @@ import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import { stringCompare } from "../../../common/string/compare";
import { extractSearchParam } from "../../../common/url/search-params";
import "../../../components/ha-app-icon";
import "../../../components/ha-button";
import "../../../components/ha-generic-picker";
import type { HaGenericPicker } from "../../../components/ha-generic-picker";
@@ -25,10 +24,7 @@ import type { PickerComboBoxItem } from "../../../components/ha-picker-combo-box
import "../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../components/input/ha-input-search";
import type { LogProvider } from "../../../data/error_log";
import {
fetchHassioAddonsInfo,
type HassioAddonInfo,
} from "../../../data/hassio/addon";
import { fetchHassioAddonsInfo } from "../../../data/hassio/addon";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import { mdiHomeAssistant } from "../../../resources/home-assistant-logo-svg";
@@ -65,11 +61,6 @@ const logProviders: LogProvider[] = [
},
];
interface LogProviderPickerItem extends PickerComboBoxItem {
addon?: HassioAddonInfo;
hasAppIcon?: boolean;
}
@customElement("ha-config-logs")
export class HaConfigLogs extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -147,9 +138,18 @@ export class HaConfigLogs extends LitElement {
@click=${this._openPicker}
>
${
selectedProvider
? this._renderProviderIcon(selectedProvider)
: nothing
selectedProvider?.icon
? html`<img
src=${selectedProvider.icon}
alt=${selectedProvider.primary}
slot="start"
/>`
: selectedProvider?.icon_path
? html`<ha-svg-icon
slot="start"
.path=${selectedProvider.icon_path}
></ha-svg-icon>`
: nothing
}
${selectedProvider?.primary}
<ha-svg-icon
@@ -264,40 +264,33 @@ export class HaConfigLogs extends LitElement {
}
}
private _getLogProviderItems = (): LogProviderPickerItem[] =>
private _getLogProviderItems = (): PickerComboBoxItem[] =>
this._logProviders.map((provider) => ({
id: provider.key,
primary: provider.name,
addon: provider.addon,
hasAppIcon: provider.addon
icon: provider.addon
? atLeastVersion(this.hass.config.version, 0, 105) &&
provider.addon.icon
? `/api/hassio/addons/${provider.addon.slug}/icon`
: undefined
: undefined,
icon_path: provider.addon
? mdiPuzzle
: this._getProviderIconPath(provider.key),
}));
private _renderProviderIcon(item: LogProviderPickerItem) {
if (item.addon) {
return html`<ha-app-icon
slot="start"
.alt=${item.primary}
.hasIcon=${item.hasAppIcon}
.slug=${item.addon.slug}
>
<ha-svg-icon .path=${item.icon_path}></ha-svg-icon>
</ha-app-icon>`;
}
return item.icon_path
? html`<ha-svg-icon slot="start" .path=${item.icon_path}></ha-svg-icon>`
: nothing;
}
private _providerRenderer = (item: LogProviderPickerItem) => html`
private _providerRenderer = (item: PickerComboBoxItem) => html`
<ha-combo-box-item type="button" compact>
${this._renderProviderIcon(item)}
${
item.icon
? html`<img src=${item.icon} alt=${item.primary} slot="start" />`
: item.icon_path
? html`<ha-svg-icon
slot="start"
.path=${item.icon_path}
></ha-svg-icon>`
: nothing
}
<span slot="headline">${item.primary}</span>
${
item.secondary
@@ -315,10 +308,11 @@ export class HaConfigLogs extends LitElement {
return {
id: provider.key,
primary: provider.name,
addon: provider.addon,
hasAppIcon: provider.addon
icon: provider.addon
? atLeastVersion(this.hass.config.version, 0, 105) &&
provider.addon.icon
? `/api/hassio/addons/${provider.addon.slug}/icon`
: undefined
: undefined,
icon_path: provider.addon
? mdiPuzzle
@@ -371,8 +365,8 @@ export class HaConfigLogs extends LitElement {
--mdc-icon-size: var(--ha-space-6);
}
ha-app-icon {
--ha-app-icon-size: 32px;
img {
height: 32px;
}
@media all and (max-width: 870px) {
+8 -17
View File
@@ -221,19 +221,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
public disconnectedCallback() {
super.disconnectedCallback();
// Hidden-tab panel suspend detaches an ancestor and later reattaches
// this same instance. Direct removal (route change) has no parentNode,
// so we still tear down even if the tab is hidden — e.g. saving a new
// scene remounts the itemId editor.
// Leave the live subscription in place on suspend so the websocket
// library can restore it after reconnect; a fresh subscribe here races
// the closed socket.
if (document.hidden && this.parentNode) {
return;
}
if (this._mode === "live" && this.hass) {
this._exitLiveMode();
} else if (this._unsubscribeEvents) {
if (this._unsubscribeEvents) {
this._unsubscribeEvents();
this._unsubscribeEvents = undefined;
}
@@ -923,14 +911,11 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
}
private async _subscribeEvents() {
if (this._unsubscribeEvents) {
return;
}
const unsubscribe = await this.hass!.connection.subscribeEvents<HassEvent>(
(event) => this._stateChanged(event),
"state_changed"
);
if (!this.isConnected || this._mode !== "live" || this._unsubscribeEvents) {
if (!this.isConnected || this._mode !== "live") {
unsubscribe();
return;
}
@@ -1107,6 +1092,9 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
};
private _goBack(): void {
if (this._mode === "live") {
applyScene(this.hass, this._storedStates);
}
afterNextRender(() => goBack("/config/scene/dashboard"));
}
@@ -1131,6 +1119,9 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
return;
}
await deleteScene(this.hass, this.sceneId);
if (this._mode === "live") {
applyScene(this.hass, this._storedStates);
}
goBack("/config/scene/dashboard");
}
-2
View File
@@ -455,8 +455,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
}
private async _editHomeZone(ev) {
// Keep the click from selecting the list item, which zooms the map
ev.stopPropagation();
if (ev.currentTarget.noEdit) {
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.zone.can_not_edit"),
+13 -10
View File
@@ -41,7 +41,6 @@ import type { HaDropdownSelectEvent } from "../../components/ha-dropdown";
import "../../components/ha-dropdown-item";
import "../../components/ha-empty-state";
import "../../components/ha-filter-pane";
import type { HaFilterPane } from "../../components/ha-filter-pane";
import "../../components/ha-filter-pane-chip";
import "../../components/ha-icon-button";
import type { SourceFilters } from "../../components/ha-sources-picker";
@@ -109,8 +108,6 @@ class HaPanelHistory extends LitElement {
@state() private _showSources?: boolean;
@query("ha-filter-pane") private _filterPane?: HaFilterPane;
@state() private _entitySources?: EntitySources;
@state() private _stateHistory?: HistoryResult;
@@ -297,9 +294,19 @@ class HaPanelHistory extends LitElement {
: "ui.panel.history.start_search"
)}
>
<ha-button appearance="plain" @click=${this._openSources}>
${this.hass.localize("ui.panel.history.change_sources")}
</ha-button>
${
!this._sourcesShown()
? html`
<ha-button appearance="plain" @click=${this._openSources}>
${this.hass.localize(
hasTargets
? "ui.panel.history.change_sources"
: "ui.panel.history.add_targets"
)}
</ha-button>
`
: nothing
}
</ha-empty-state>
`;
}
@@ -390,10 +397,6 @@ class HaPanelHistory extends LitElement {
}
private _openSources() {
if (this._sourcesShown()) {
this._filterPane?.highlight();
return;
}
this._showSources = true;
}
+1 -8
View File
@@ -9,7 +9,7 @@ import {
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fromUnixTime } from "date-fns";
import { ensureArray } from "../../common/array/ensure-array";
@@ -37,7 +37,6 @@ import "../../components/ha-dropdown-item";
import "../../components/ha-empty-state";
import "../../components/ha-filter-pane-chip";
import "../../components/ha-filter-pane";
import type { HaFilterPane } from "../../components/ha-filter-pane";
import "../../components/ha-icon-button";
import {
applySourceFilters,
@@ -82,8 +81,6 @@ export class HaPanelLogbook extends LitElement {
@state() private _showSources?: boolean;
@query("ha-filter-pane") private _filterPane?: HaFilterPane;
@state() private _entitySources?: EntitySources;
@state() private _targetPickerValue: HassServiceTarget = {};
@@ -247,10 +244,6 @@ export class HaPanelLogbook extends LitElement {
}
private _openSources() {
if (this._sourcesShown()) {
this._filterPane?.highlight();
return;
}
this._showSources = true;
}
@@ -1,5 +1,6 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import {
mdiAutoFix,
mdiDelete,
mdiDotsVertical,
mdiDragHorizontalVariant,
@@ -7,13 +8,14 @@ import {
mdiPlusCircleMultipleOutline,
} from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
@@ -32,10 +34,32 @@ export class HuiSectionEditMode extends LitElement {
@property({ attribute: false }) public viewIndex!: number;
@property({ type: Boolean, attribute: "is-strategy", reflect: true })
public isStrategy = false;
protected render(): TemplateResult {
return html`
<div class="section-header">
<div class="section-actions">
${
this.isStrategy
? html`
<ha-svg-icon
id="strategy-icon"
.path=${mdiAutoFix}
role="img"
aria-label=${this.hass.localize(
"ui.panel.lovelace.editor.section.automatic"
)}
></ha-svg-icon>
<ha-tooltip for="strategy-icon">
${this.hass.localize(
"ui.panel.lovelace.editor.section.automatic"
)}
</ha-tooltip>
`
: nothing
}
<ha-svg-icon
aria-hidden="true"
class="handle"
@@ -70,7 +94,25 @@ export class HuiSectionEditMode extends LitElement {
</div>
</div>
<div class="section-wrapper">
<slot></slot>
<div class="section-content" ?inert=${this.isStrategy}>
<slot></slot>
</div>
${
this.isStrategy
? html`
<button
class="edit-overlay"
type="button"
aria-label=${this.hass.localize(
"ui.panel.lovelace.editor.section.edit_automatic"
)}
@click=${this._editSection}
>
<ha-svg-icon .path=${mdiPencil}></ha-svg-icon>
</button>
`
: nothing
}
</div>
`;
}
@@ -183,7 +225,12 @@ export class HuiSectionEditMode extends LitElement {
padding: 8px;
}
#strategy-icon {
padding: var(--ha-space-2);
}
.section-wrapper {
position: relative;
padding: 8px;
border-radius: var(
--ha-section-border-radius,
@@ -193,6 +240,67 @@ export class HuiSectionEditMode extends LitElement {
border: 2px dashed var(--divider-color);
min-height: var(--row-height);
}
:host([is-strategy]) .section-wrapper {
border-style: solid;
}
.section-content {
position: relative;
z-index: 0;
}
.edit-overlay {
position: absolute;
z-index: 0;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: inherit;
background: none;
color: var(--primary-text-color);
cursor: pointer;
opacity: 0;
transition: opacity var(--ha-animation-duration-fast) ease-in-out;
}
.edit-overlay::before {
content: "";
position: absolute;
inset: 0;
border: 1px solid var(--divider-color);
border-radius: inherit;
background: var(--primary-background-color);
opacity: 0.8;
}
.edit-overlay:hover,
.edit-overlay:focus-visible,
.edit-overlay:active {
opacity: 1;
}
.edit-overlay:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: -2px;
}
.edit-overlay ha-svg-icon {
position: relative;
padding: var(--ha-space-2);
border-radius: var(--ha-border-radius-circle);
background: var(--secondary-background-color);
--mdc-icon-size: 20px;
}
@media (prefers-reduced-motion: reduce) {
.edit-overlay {
transition: none;
}
}
`,
];
}
@@ -1,15 +1,13 @@
import {
mdiClose,
mdiDotsVertical,
mdiFileMoveOutline,
mdiPlaylistEdit,
} from "@mdi/js";
import { mdiClose, mdiDotsVertical, mdiFileMoveOutline } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { cache } from "lit/directives/cache";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { withViewTransition } from "../../../../common/util/view-transition";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-dialog-footer";
@@ -22,6 +20,8 @@ import "../../../../components/ha-tab-group-tab";
import "../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section";
import { isStrategySection } from "../../../../data/lovelace/config/section";
import type { LovelaceStrategyConfig } from "../../../../data/lovelace/config/strategy";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import { saveConfig } from "../../../../data/lovelace/config/types";
import {
@@ -35,7 +35,8 @@ import {
haStyleDialog,
haStyleDialogFixedTop,
} from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import { cleanLegacyStrategyConfig } from "../../strategies/legacy-strategy";
import type { Lovelace } from "../../types";
import { addSection, deleteSection, moveSection } from "../config-util";
import {
@@ -45,12 +46,15 @@ import {
import { showSelectViewDialog } from "../select-view/show-select-view-dialog";
import "./hui-section-settings-editor";
import "./hui-section-visibility-editor";
import "./hui-section-strategy-element-editor";
import type { HuiSectionStrategyElementEditor } from "./hui-section-strategy-element-editor";
import type { ConfigChangedEvent } from "../hui-element-editor";
import type { EditSectionDialogParams } from "./show-edit-section-dialog";
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
import { getViewType } from "../../views/get-view-type";
import { SECTIONS_VIEW_LAYOUT } from "../../views/const";
const TABS = ["tab-settings", "tab-visibility"] as const;
const TABS = ["tab-strategy", "tab-appearance", "tab-visibility"] as const;
@customElement("hui-dialog-edit-section")
export class HuiDialogEditSection
@@ -73,8 +77,15 @@ export class HuiDialogEditSection
@state() private _open = false;
@state() private _strategyError = false;
@state() private _yamlError = false;
@query("ha-yaml-editor") private _editor?: HaYamlEditor;
@query("hui-section-strategy-element-editor")
private _strategyEditor?: HuiSectionStrategyElementEditor;
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (this._yamlMode && changedProperties.has("_yamlMode")) {
@@ -95,6 +106,9 @@ export class HuiDialogEditSection
this._params.viewIndex,
this._params.sectionIndex,
]);
this._currTab = isStrategySection(this._config)
? "tab-strategy"
: "tab-appearance";
this._viewConfig = findLovelaceContainer(this._params.lovelaceConfig, [
this._params.viewIndex,
]);
@@ -111,6 +125,8 @@ export class HuiDialogEditSection
this._yamlMode = false;
this._config = undefined;
this._currTab = TABS[0];
this._strategyError = false;
this._yamlError = false;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -123,6 +139,15 @@ export class HuiDialogEditSection
"ui.panel.lovelace.editor.edit_section.header"
);
const strategy = isStrategySection(this._config)
? cleanLegacyStrategyConfig(this._config.strategy)
: undefined;
const tabs = strategy ? TABS : TABS.filter((tab) => tab !== "tab-strategy");
const currentTab =
!strategy && this._currTab === "tab-strategy"
? "tab-appearance"
: this._currTab;
let content: TemplateResult<1> | typeof nothing = nothing;
if (this._yamlMode) {
@@ -134,8 +159,8 @@ export class HuiDialogEditSection
></ha-yaml-editor>
`;
} else {
switch (this._currTab) {
case "tab-settings":
switch (currentTab) {
case "tab-appearance":
content = html`
<hui-section-settings-editor
.config=${this._config}
@@ -155,6 +180,17 @@ export class HuiDialogEditSection
</hui-section-visibility-editor>
`;
break;
case "tab-strategy":
content = html`
<hui-section-strategy-element-editor
.hass=${this.hass}
.lovelace=${this._params.lovelaceConfig}
.value=${strategy}
in-dialog
@config-changed=${this._strategyConfigChanged}
></hui-section-strategy-element-editor>
`;
break;
}
}
@@ -187,12 +223,6 @@ export class HuiDialogEditSection
.label=${this.hass!.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-dropdown-item value="toggle-yaml">
<ha-svg-icon slot="icon" .path=${mdiPlaylistEdit}></ha-svg-icon>
${this.hass.localize(
`ui.panel.lovelace.editor.edit_view.edit_${!this._yamlMode ? "yaml" : "ui"}`
)}
</ha-dropdown-item>
<ha-dropdown-item value="move-to-view">
<ha-svg-icon
slot="icon"
@@ -207,12 +237,12 @@ export class HuiDialogEditSection
!this._yamlMode
? html`
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
${TABS.map(
${tabs.map(
(tab) => html`
<ha-tab-group-tab
slot="nav"
.panel=${tab}
.active=${this._currTab === tab}
.active=${currentTab === tab}
>
${this.hass!.localize(
`ui.panel.lovelace.editor.edit_section.${tab.replace("-", "_")}`
@@ -225,8 +255,21 @@ export class HuiDialogEditSection
: nothing
}
</ha-dialog-header>
${content}
${this._yamlMode ? content : cache(content)}
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
class="gui-mode-button"
appearance="plain"
@click=${this._toggleMode}
.disabled=${this._yamlMode && this._yamlError}
>
${this.hass.localize(
this._yamlMode
? "ui.panel.lovelace.editor.edit_card.show_visual_editor"
: "ui.panel.lovelace.editor.edit_card.show_code_editor"
)}
</ha-button>
<ha-button
slot="secondaryAction"
appearance="plain"
@@ -238,7 +281,7 @@ export class HuiDialogEditSection
<ha-button
slot="primaryAction"
@click=${this._save}
?disabled=${!this.isDirtyState}
?disabled=${!this.isDirtyState || this._yamlError || this._strategyError}
>
${this.hass!.localize("ui.common.save")}
</ha-button>
@@ -247,12 +290,30 @@ export class HuiDialogEditSection
`;
}
private _configChanged(ev: CustomEvent): void {
private _configChanged(
ev: ValueChangedEvent<LovelaceSectionRawConfig>
): void {
ev.stopPropagation();
this._config = ev.detail.value;
this._updateDirtyState(this._config!);
}
private _strategyConfigChanged(
ev: HASSDomEvent<ConfigChangedEvent<LovelaceStrategyConfig>>
): void {
ev.stopPropagation();
if (!this._config || !isStrategySection(this._config)) return;
this._strategyError = Boolean(
this._strategyEditor?.hasError ||
ev.detail.error ||
typeof ev.detail.config?.type !== "string" ||
!ev.detail.config.type
);
if (this._strategyError) return;
this._config = { ...this._config, strategy: ev.detail.config };
this._updateDirtyState(this._config);
}
private _handleTabChanged(ev: CustomEvent): void {
const newTab = ev.detail.name;
if (newTab === this._currTab) {
@@ -264,15 +325,19 @@ export class HuiDialogEditSection
private async _handleAction(ev: HaDropdownSelectEvent) {
const value = ev.detail.item.value;
switch (value) {
case "toggle-yaml":
this._yamlMode = !this._yamlMode;
break;
case "move-to-view":
this._openSelectView();
break;
}
}
private _toggleMode(): void {
if (this._yamlMode && this._yamlError) return;
withViewTransition(() => {
this._yamlMode = !this._yamlMode;
});
}
private _openSelectView(): void {
if (!this._params || !this.lovelace) {
return;
@@ -402,12 +467,21 @@ export class HuiDialogEditSection
}
};
private _viewYamlChanged(ev: CustomEvent) {
private _viewYamlChanged(
ev: ValueChangedEvent<LovelaceSectionRawConfig> &
HASSDomEvent<{ isValid: boolean }>
) {
ev.stopPropagation();
if (!ev.detail.isValid) {
this._yamlError =
!ev.detail.isValid ||
!ev.detail.value ||
typeof ev.detail.value !== "object" ||
Array.isArray(ev.detail.value);
if (this._yamlError) {
return;
}
this._config = ev.detail.value;
this._strategyError = false;
this._updateDirtyState(this._config!);
}
@@ -423,7 +497,12 @@ export class HuiDialogEditSection
}
private async _save(): Promise<void> {
if (!this._params || !this._config) {
if (
!this._params ||
!this._config ||
this._yamlError ||
this._strategyError
) {
return;
}
const newConfig = updateLovelaceContainer(
@@ -448,6 +527,9 @@ export class HuiDialogEditSection
ha-dialog.yaml-mode {
--dialog-content-padding: 0;
}
.gui-mode-button {
margin-inline-end: auto;
}
ha-tab-group-tab {
flex: 1;
}
@@ -0,0 +1,35 @@
import { css } from "lit";
import { customElement } from "lit/decorators";
import type { LovelaceStrategyConfig } from "../../../../data/lovelace/config/strategy";
import { getLovelaceStrategy } from "../../strategies/get-strategy";
import type { LovelaceStrategyEditor } from "../../strategies/types";
import { HuiTypedElementEditor } from "../hui-typed-element-editor";
@customElement("hui-section-strategy-element-editor")
export class HuiSectionStrategyElementEditor extends HuiTypedElementEditor<LovelaceStrategyConfig> {
static styles = [
HuiTypedElementEditor.styles,
css`
.gui-editor,
.yaml-editor {
padding: 0;
}
`,
];
protected async getConfigElement(): Promise<
LovelaceStrategyEditor | undefined
> {
const strategy = await getLovelaceStrategy(
"section",
this.configElementType!
);
return strategy.getConfigElement?.();
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-section-strategy-element-editor": HuiSectionStrategyElementEditor;
}
}
+2 -2
View File
@@ -324,7 +324,7 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
this._layoutElementType = config.type;
this._layoutElement.addEventListener("ll-create-card", (ev) => {
ev.stopPropagation();
if (!this.lovelace) return;
if (!this.lovelace || isStrategySection(this.config)) return;
showCreateCardDialog(this, {
lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig,
@@ -357,7 +357,7 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
});
this._layoutElement.addEventListener("ll-delete-card", (ev) => {
ev.stopPropagation();
if (!this.lovelace) return;
if (!this.lovelace || isStrategySection(this.config)) return;
performDeleteCard(this.hass, this.lovelace, ev.detail);
});
this._layoutElement.addEventListener("ll-duplicate-card", (ev) => {
+2 -1
View File
@@ -17,7 +17,8 @@ export interface LovelaceStrategy<T = any> {
newHass: HomeAssistant
): boolean;
registryDependencies?: readonly LovelaceStrategyDependency[];
getConfigElement?: () => LovelaceStrategyEditor;
getConfigElement?: () =>
LovelaceStrategyEditor | Promise<LovelaceStrategyEditor>;
noEditor?: boolean;
configRequired?: boolean;
}
@@ -7,7 +7,10 @@ import type { HomeAssistant } from "../../../../types";
import type { HeadingCardConfig } from "../../cards/types";
import type { Condition } from "../../common/validate-condition";
import { computeFavoriteCardConfig } from "../helpers/favorite-cards";
import type { LovelaceStrategyDependency } from "../types";
import type {
LovelaceStrategyDependency,
LovelaceStrategyEditor,
} from "../types";
const DEFAULT_LIMIT = 8;
@@ -30,6 +33,13 @@ export interface CommonControlsSectionStrategyConfig {
export class CommonControlsSectionStrategy extends ReactiveElement {
static registryDependencies: readonly LovelaceStrategyDependency[] = [];
public static async getConfigElement(): Promise<LovelaceStrategyEditor> {
await import("./hui-common-controls-section-strategy-editor");
return document.createElement(
"hui-common-controls-section-strategy-editor"
);
}
static async generate(
config: CommonControlsSectionStrategyConfig,
hass: HomeAssistant
@@ -0,0 +1,96 @@
import { mdiFormatListBulleted } from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-form/ha-form";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { ValueChangedEvent } from "../../../../types";
import type { LovelaceStrategyEditor } from "../types";
import type { CommonControlsSectionStrategyConfig } from "./common-controls-section-strategy";
const SCHEMA = [
{
name: "limit",
selector: { number: { min: 1, mode: "box" } },
},
{
name: "hide_empty",
selector: { boolean: {} },
},
{
name: "entities",
type: "expandable",
flatten: true,
iconPath: mdiFormatListBulleted,
schema: [
{
name: "include_entities",
selector: { entity: { multiple: true, reorder: true } },
},
{
name: "exclude_entities",
selector: { entity: { multiple: true } },
},
],
},
] as const satisfies readonly HaFormSchema[];
@customElement("hui-common-controls-section-strategy-editor")
export class HuiCommonControlsSectionStrategyEditor
extends LitElement
implements LovelaceStrategyEditor
{
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@state() private _config?: CommonControlsSectionStrategyConfig;
public setConfig(config: CommonControlsSectionStrategyConfig): void {
this._config = config;
}
protected render() {
if (!this._config) return nothing;
return html`
<ha-form
.data=${this._config}
.schema=${SCHEMA}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _computeLabel = (schema: SchemaUnion<typeof SCHEMA>) =>
this._localize(
`ui.panel.lovelace.editor.strategy.common_controls.${schema.name}`
);
private _computeHelper = (schema: SchemaUnion<typeof SCHEMA>) =>
schema.name === "entities" || schema.name === "hide_empty"
? ""
: this._localize(
`ui.panel.lovelace.editor.strategy.common_controls.${schema.name}_helper`
);
private _valueChanged(
ev: ValueChangedEvent<CommonControlsSectionStrategyConfig>
): void {
ev.stopPropagation();
fireEvent(this, "config-changed", {
config: { ...this._config, ...ev.detail.value },
});
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-common-controls-section-strategy-editor": HuiCommonControlsSectionStrategyEditor;
}
}
@@ -17,6 +17,7 @@ import "../../../components/ha-svg-icon";
import { maxColumnsContext } from "../common/context";
import type { LovelaceViewElement } from "../../../data/lovelace";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { isStrategySection } from "../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import type { HuiBadge } from "../badges/hui-badge";
@@ -290,6 +291,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
.lovelace=${this.lovelace}
.index=${idx}
.viewIndex=${this.index}
.isStrategy=${isStrategySection(section.config)}
>
${this._renderSection(
section,
+15 -12
View File
@@ -665,17 +665,14 @@
"options": {
"each": {
"label": "Each",
"row_label": "each of",
"description": "Triggers once for every target that changes"
},
"first": {
"label": "First",
"row_label": "first of",
"description": "Triggers the first time any target changes"
},
"all": {
"label": "All",
"row_label": "all of",
"description": "Triggers once all targets have changed"
}
}
@@ -684,12 +681,10 @@
"options": {
"any": {
"label": "Any",
"row_label": "any of",
"description": "Is true if any target matches the desired state"
},
"all": {
"label": "All",
"row_label": "all of",
"description": "Is true if all targets match the desired state"
}
}
@@ -5257,12 +5252,6 @@
"placeholder": "Optional description",
"add": "Add description"
},
"row_options": {
"for": "for {duration}",
"offset_before": "{offset} before",
"offset_after": "{offset} after",
"timeout": "timeout {duration}"
},
"note": {
"label": "Note",
"edit": "Edit note",
@@ -9780,6 +9769,8 @@
"success": "View moved successfully"
},
"section": {
"automatic": "Automatic section",
"edit_automatic": "Edit automatic section",
"add_badge": "Add badge",
"add_card": "[%key:ui::panel::lovelace::editor::edit_card::add%]",
"drop_card_create_section": "Drop card here to create a new section",
@@ -9797,8 +9788,9 @@
},
"edit_section": {
"header": "Edit section",
"tab_strategy": "Configuration",
"tab_visibility": "[%key:ui::panel::lovelace::editor::edit_view::tab_visibility%]",
"tab_settings": "[%key:ui::panel::lovelace::editor::edit_view::tab_settings%]",
"tab_appearance": "Appearance",
"edit_ui": "[%key:ui::panel::lovelace::editor::edit_view::edit_ui%]",
"edit_yaml": "[%key:ui::panel::lovelace::editor::edit_view::edit_yaml%]",
"settings": {
@@ -11136,6 +11128,16 @@
}
},
"strategy": {
"common_controls": {
"limit": "Maximum number of cards",
"limit_helper": "Defaults to 8 cards",
"entities": "Entities",
"include_entities": "Always include",
"include_entities_helper": "Shown first, in the order you choose, within the card limit",
"exclude_entities": "Exclude from suggestions",
"exclude_entities_helper": "These entities will not be suggested based on your activity",
"hide_empty": "Hide when empty"
},
"original-states": {
"areas": "Areas to display",
"hide_entities_without_area": "Hide entities without area",
@@ -11951,6 +11953,7 @@
"history": {
"sources": "Sources",
"sources_count": "Sources: {count}",
"add_targets": "Add targets",
"change_sources": "Change sources",
"no_targets": "No targets selected",
"start_search_title": "Nothing to show yet",
+1 -2
View File
@@ -1,4 +1,3 @@
import { resolveHassUrl } from "../common/url/hass-url";
import { isExternalAndroid } from "../data/external";
// 10 seconds gives the Android WebView download listener enough time
@@ -10,7 +9,7 @@ const BLOB_REVOKE_DELAY_MS = 10_000;
export const fileDownload = (href: string, filename = ""): void => {
const element = document.createElement("a");
element.target = "_blank";
element.href = resolveHassUrl(href);
element.href = href;
element.download = filename;
element.style.display = "none";
document.body.appendChild(element);
+2 -17
View File
@@ -1,10 +1,9 @@
import "@formatjs/intl-durationformat/polyfill-force";
import { describe, expect, it } from "vitest";
import { expect, describe, it } from "vitest";
import {
formatDuration,
formatDurationDigital,
formatDurationLong,
formatDurationNarrow,
formatDurationDigital,
formatNumericDuration,
} from "../../../src/common/datetime/format_duration";
import type { FrontendLocaleData } from "../../../src/data/translation";
@@ -64,20 +63,6 @@ describe("formatDurationLong", () => {
});
});
describe("formatDurationNarrow", () => {
it("formats narrow duration", () => {
const duration = { days: 1, hours: 2, minutes: 3, seconds: 4 };
expect(formatDurationNarrow(LOCALE, duration)).toBe("1d 2h 3m 4s");
});
it("only formats units that are present", () => {
expect(formatDurationNarrow(LOCALE, { hours: 1, minutes: 30 })).toBe(
"1h 30m"
);
expect(formatDurationNarrow(LOCALE, { seconds: 45 })).toBe("45s");
});
});
describe("formatDurationDigital", () => {
it("formats digital duration", () => {
const duration = { hours: 1, minutes: 2, seconds: 3 };
-104
View File
@@ -1,104 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HaAppIcon } from "../../src/components/ha-app-icon";
import "../../src/components/ha-app-icon";
let appIcon: HaAppIcon | undefined;
const mountAppIcon = async (properties: Partial<HaAppIcon> = {}) => {
appIcon = document.createElement("ha-app-icon");
Object.assign(appIcon, properties);
appIcon.append(document.createElement("ha-svg-icon"));
document.body.append(appIcon);
await appIcon.updateComplete;
return appIcon;
};
afterEach(() => {
appIcon?.remove();
appIcon = undefined;
vi.unstubAllGlobals();
});
describe("ha-app-icon", () => {
it("does not request an unavailable icon", async () => {
const element = await mountAppIcon({ slug: "example", hasIcon: false });
expect(element.shadowRoot!.querySelector("img")).toBeNull();
expect(element.shadowRoot!.querySelector("slot")).not.toBeNull();
});
it.each([true, undefined])(
"requests the icon when availability is %s",
async (hasIcon) => {
const element = await mountAppIcon({ slug: "example", hasIcon });
expect(
element.shadowRoot!.querySelector("img")!.getAttribute("src")
).toBe("/api/hassio/addons/example/icon");
}
);
it("requests the icon from the configured Home Assistant URL", async () => {
vi.stubGlobal("__HASS_URL__", "http://homeassistant.local:8123");
const element = await mountAppIcon({ slug: "example", hasIcon: true });
const image = element.shadowRoot!.querySelector("img")!;
expect(image.getAttribute("src")).toBe(
"http://homeassistant.local:8123/api/hassio/addons/example/icon"
);
expect(image.hasAttribute("crossorigin")).toBe(false);
});
it("renders the fallback after an image error", async () => {
const element = await mountAppIcon({ slug: "example", hasIcon: true });
element.shadowRoot!.querySelector("img")!.dispatchEvent(new Event("error"));
await element.updateComplete;
expect(element.shadowRoot!.querySelector("img")).toBeNull();
expect(element.shadowRoot!.querySelector("slot")).not.toBeNull();
});
it("tries a new source after the slug changes", async () => {
const element = await mountAppIcon({ slug: "first", hasIcon: true });
element.shadowRoot!.querySelector("img")!.dispatchEvent(new Event("error"));
await element.updateComplete;
element.slug = "second";
await element.updateComplete;
expect(element.shadowRoot!.querySelector("img")!.getAttribute("src")).toBe(
"/api/hassio/addons/second/icon"
);
});
it("ignores an error from a previous source", async () => {
const element = await mountAppIcon({ slug: "first", hasIcon: true });
const firstImage = element.shadowRoot!.querySelector("img")!;
element.slug = "second";
await element.updateComplete;
firstImage.dispatchEvent(new Event("error"));
await element.updateComplete;
expect(element.shadowRoot!.querySelector("img")!.getAttribute("src")).toBe(
"/api/hassio/addons/second/icon"
);
});
it("ignores an old error after returning to the same source", async () => {
const element = await mountAppIcon({ slug: "first", hasIcon: true });
const firstImage = element.shadowRoot!.querySelector("img")!;
element.slug = "second";
await element.updateComplete;
element.slug = "first";
await element.updateComplete;
firstImage.dispatchEvent(new Event("error"));
await element.updateComplete;
expect(element.shadowRoot!.querySelector("img")!.getAttribute("src")).toBe(
"/api/hassio/addons/first/icon"
);
});
});
-11
View File
@@ -36,7 +36,6 @@ describe("fileDownload", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
delete (window as any).externalApp;
delete (window as any).externalAppV2;
});
@@ -59,16 +58,6 @@ describe("fileDownload", () => {
expect(URL.revokeObjectURL).not.toHaveBeenCalled();
});
it("resolves relative URLs against the configured Home Assistant URL", async () => {
vi.stubGlobal("__HASS_URL__", "http://homeassistant.local:8123");
await loadFileDownload();
fileDownload("/api/hassio/core/logs/follow");
expect(createdElement.href).toBe(
"http://homeassistant.local:8123/api/hassio/core/logs/follow"
);
});
it("revokes blob URLs immediately outside Android", async () => {
await loadFileDownload();
fileDownload("blob:http://localhost/abc-123", "file.json");
+70 -150
View File
@@ -2294,28 +2294,6 @@ __metadata:
languageName: node
linkType: hard
"@cacheable/memory@npm:^2.2.0":
version: 2.2.0
resolution: "@cacheable/memory@npm:2.2.0"
dependencies:
"@cacheable/utils": "npm:^2.5.0"
"@keyv/bigmap": "npm:^1.3.1"
hookified: "npm:^1.15.1"
keyv: "npm:^5.6.0"
checksum: 10/97db83d7a74979accca9de56cf278e42c22b4273e752d7e23c44fbec4a47450716c4920903424fd19184a7bee6325064b6c453afc6f96bf92b39eecfe0b40648
languageName: node
linkType: hard
"@cacheable/utils@npm:^2.5.0":
version: 2.5.0
resolution: "@cacheable/utils@npm:2.5.0"
dependencies:
hashery: "npm:^1.5.1"
keyv: "npm:^5.6.0"
checksum: 10/a77a7e682926b984cb28b6993f4595638ce8799de3223dbdcdb796a077a02de763687bdc5669779019aba44d27693260ef92d860a19330d7eaa4dfcca3433c3b
languageName: node
linkType: hard
"@codemirror/autocomplete@npm:6.20.3, @codemirror/autocomplete@npm:^6.0.0":
version: 6.20.3
resolution: "@codemirror/autocomplete@npm:6.20.3"
@@ -2452,12 +2430,12 @@ __metadata:
languageName: node
linkType: hard
"@codemirror/state@npm:6.7.4, @codemirror/state@npm:^6.0.0, @codemirror/state@npm:^6.7.0":
version: 6.7.4
resolution: "@codemirror/state@npm:6.7.4"
"@codemirror/state@npm:6.7.2, @codemirror/state@npm:^6.0.0, @codemirror/state@npm:^6.7.0":
version: 6.7.2
resolution: "@codemirror/state@npm:6.7.2"
dependencies:
"@marijn/find-cluster-break": "npm:^1.0.0"
checksum: 10/429b7ff6b617d3934eea001165228c3ed77df1943c0b2fdaf019ef3a6b616ada482c344ec46edf5c83121afe6973e9ce4931446922cc2b35f1dad2525eff5300
checksum: 10/363a6217e6d34ce406abca93ae7b87181e93e308be5ee037bec911b9d8c7bf6559de7e85620460bc1b7c1a590c05d75bc64e3b09d76fd82110ab829237e3fde2
languageName: node
linkType: hard
@@ -2704,13 +2682,13 @@ __metadata:
languageName: node
linkType: hard
"@eslint/plugin-kit@npm:^0.7.3":
version: 0.7.3
resolution: "@eslint/plugin-kit@npm:0.7.3"
"@eslint/plugin-kit@npm:^0.7.2":
version: 0.7.2
resolution: "@eslint/plugin-kit@npm:0.7.2"
dependencies:
"@eslint/core": "npm:^1.2.1"
levn: "npm:^0.4.1"
checksum: 10/bd603f0438d6829a5b0c8cbb90383e3b0fda3aac3a87faa22c80124ce4121f5dcfe8ef9b933c3308cbed70a27c4f51036403e3f0cb64ca2743e595dd865ccef6
checksum: 10/ef9fc6f8ca28e132d4c81cfbaa92274800d1d73bb9d6ef2124613dd39b7f09e3592deb64bad10b183bff78db5465d4b100f522d994c8550424526b9ac4a072b0
languageName: node
linkType: hard
@@ -3369,25 +3347,6 @@ __metadata:
languageName: node
linkType: hard
"@keyv/bigmap@npm:^1.3.1":
version: 1.3.1
resolution: "@keyv/bigmap@npm:1.3.1"
dependencies:
hashery: "npm:^1.4.0"
hookified: "npm:^1.15.0"
peerDependencies:
keyv: ^5.6.0
checksum: 10/9745786a94729117460b5399a964ec151e94a89db0e5ed573419c4c620f0595f05d7ad87d596bb7e212a4f3ad2335271694fddcfaa8400013f7b64e11669a884
languageName: node
linkType: hard
"@keyv/serialize@npm:^1.1.1":
version: 1.1.1
resolution: "@keyv/serialize@npm:1.1.1"
checksum: 10/e3b2cb1377863342acedd5ff785af3e69269bee9b44707c617d1c8bc14eeb5ac763159d6455903ffe92f143c2238e1e783c4f113f9c8910eacccf172894472da
languageName: node
linkType: hard
"@lezer/common@npm:^1.0.0, @lezer/common@npm:^1.0.2, @lezer/common@npm:^1.1.0, @lezer/common@npm:^1.2.0, @lezer/common@npm:^1.3.0, @lezer/common@npm:^1.5.0":
version: 1.5.2
resolution: "@lezer/common@npm:1.5.2"
@@ -7569,12 +7528,12 @@ __metadata:
languageName: node
linkType: hard
"baseline-browser-mapping@npm:^2.11.20":
version: 2.11.21
resolution: "baseline-browser-mapping@npm:2.11.21"
"baseline-browser-mapping@npm:^2.11.12":
version: 2.11.14
resolution: "baseline-browser-mapping@npm:2.11.14"
bin:
baseline-browser-mapping: dist/cli.cjs
checksum: 10/b4d223df8dbb8deeaa3dae3b9b60ad48b24c57a5473827b5a6ee1c21917e7c37af2185224c5366f2932654ec5fb1a9fee4f95fe7213cc29f4347550f25e4234b
checksum: 10/b1745ec32246d3d1182bfeab2c2efeaf29925e6f617da59f28fde032fce6fa4eaa4632bcf2f9edeca83ceeb7cdfd72d6361ebd7c10e1d4fcb117259ec7059165
languageName: node
linkType: hard
@@ -7759,18 +7718,18 @@ __metadata:
languageName: node
linkType: hard
"browserslist@npm:4.28.9, browserslist@npm:^4.24.0, browserslist@npm:^4.28.2, browserslist@npm:^4.28.7":
version: 4.28.9
resolution: "browserslist@npm:4.28.9"
"browserslist@npm:4.28.8, browserslist@npm:^4.24.0, browserslist@npm:^4.28.2, browserslist@npm:^4.28.7":
version: 4.28.8
resolution: "browserslist@npm:4.28.8"
dependencies:
baseline-browser-mapping: "npm:^2.11.20"
caniuse-lite: "npm:^1.0.30001810"
electron-to-chromium: "npm:^1.5.420"
node-releases: "npm:^2.0.54"
update-browserslist-db: "npm:^1.3.2"
baseline-browser-mapping: "npm:^2.11.12"
caniuse-lite: "npm:^1.0.30001809"
electron-to-chromium: "npm:^1.5.402"
node-releases: "npm:^2.0.53"
update-browserslist-db: "npm:^1.3.0"
bin:
browserslist: cli.js
checksum: 10/493e5e3c11d4bdd34ce8cb7ed5edc98bc5948457e6a3cc1268b253d0ff9a616033b10980515e0e6faeddae745fb9c330cd4510189d681ad86f9ce0b2da3c840e
checksum: 10/9df1d87f915639ebe8f85caf8d58bc0627863dff5e921b6e0394a92f82d279f1b2b324594267d025c80482397150cb4bf5a3ab55fde5d2c1c0f13c064f48b5e9
languageName: node
linkType: hard
@@ -7851,19 +7810,6 @@ __metadata:
languageName: node
linkType: hard
"cacheable@npm:^2.5.0":
version: 2.5.0
resolution: "cacheable@npm:2.5.0"
dependencies:
"@cacheable/memory": "npm:^2.2.0"
"@cacheable/utils": "npm:^2.5.0"
hookified: "npm:^1.15.0"
keyv: "npm:^5.6.0"
qified: "npm:^0.10.1"
checksum: 10/216a896e17dfa9c598a69e2fff31216126e94286f6c0ceb1bfab1d34e6c1a8791e8dc34dbd28660ec72a84f1d370c3733a9fc481e00a5512fd4e0cfc35775db6
languageName: node
linkType: hard
"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2":
version: 1.0.2
resolution: "call-bind-apply-helpers@npm:1.0.2"
@@ -7929,10 +7875,10 @@ __metadata:
languageName: node
linkType: hard
"caniuse-lite@npm:^1.0.30001810":
version: 1.0.30001810
resolution: "caniuse-lite@npm:1.0.30001810"
checksum: 10/47d7db627c3f3d1a10e06eef9efbb84295c6ef9d959b585b64032cc293637ca7b9b6af7e5d5752972b3e690d9561f14b9561db03890fbc6e38dc1aaa54e51d97
"caniuse-lite@npm:^1.0.30001809":
version: 1.0.30001809
resolution: "caniuse-lite@npm:1.0.30001809"
checksum: 10/1034b9673e2393bf0c12f5188e0957817d423f71909809eafd69e511c91dedd880d36dd7ee1041605d73fcd3f89a42ba4e235eb942e6bb9f23acdca2fc98f927
languageName: node
linkType: hard
@@ -8898,10 +8844,10 @@ __metadata:
languageName: node
linkType: hard
"electron-to-chromium@npm:^1.5.420":
version: 1.5.422
resolution: "electron-to-chromium@npm:1.5.422"
checksum: 10/a4cc1bd7204bdb3da063f1ad8103f9990e03da3d0dae0aaf75e1669f2c36a9ae807498c5b27eed61fa98d6b10baf7fe05ff6038a99a4a85a0285e623825253d6
"electron-to-chromium@npm:^1.5.402":
version: 1.5.407
resolution: "electron-to-chromium@npm:1.5.407"
checksum: 10/9c4507ef13786d3e4fe16d04929b662b500ac814b2f09f8bda94858ae0bc9962f9a26bfb00bf519b94724eb97bcdc6409cf40cf1b9b540c3443d8f2850fa066a
languageName: node
linkType: hard
@@ -9385,16 +9331,16 @@ __metadata:
languageName: node
linkType: hard
"eslint@npm:10.10.0":
version: 10.10.0
resolution: "eslint@npm:10.10.0"
"eslint@npm:10.9.1":
version: 10.9.1
resolution: "eslint@npm:10.9.1"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.8.0"
"@eslint-community/regexpp": "npm:^4.12.2"
"@eslint/config-array": "npm:^0.23.5"
"@eslint/config-helpers": "npm:^0.7.0"
"@eslint/core": "npm:^1.2.1"
"@eslint/plugin-kit": "npm:^0.7.3"
"@eslint/plugin-kit": "npm:^0.7.2"
"@humanfs/node": "npm:^0.16.6"
"@humanwhocodes/module-importer": "npm:^1.0.1"
"@humanwhocodes/retry": "npm:^0.4.2"
@@ -9409,7 +9355,7 @@ __metadata:
esquery: "npm:^1.7.0"
esutils: "npm:^2.0.2"
fast-deep-equal: "npm:^3.1.3"
file-entry-cache: "npm:11.1.5 || >11.1.6 <12"
file-entry-cache: "npm:^8.0.0"
find-up: "npm:^5.0.0"
glob-parent: "npm:^6.0.2"
ignore: "npm:^5.2.0"
@@ -9426,7 +9372,7 @@ __metadata:
optional: true
bin:
eslint: bin/eslint.js
checksum: 10/02fe7e79bf9f81d939ef3ad0c3151c0588f3441eef00bdc468797f2a9f8b047bea97c57a793fc97cf1e4886400b50f049b8e66ec1371820e9df33b54a8bc90d8
checksum: 10/4cee237c7d2c445a05330e595b18521d7452fd3f419241bf1ccf81c769a6761c10ca5a06988197482d4842ed1a26b1dcb51b9526408990cee84559e6c08cb3ab
languageName: node
linkType: hard
@@ -9689,12 +9635,12 @@ __metadata:
languageName: node
linkType: hard
"file-entry-cache@npm:11.1.5 || >11.1.6 <12":
version: 11.1.5
resolution: "file-entry-cache@npm:11.1.5"
"file-entry-cache@npm:^8.0.0":
version: 8.0.0
resolution: "file-entry-cache@npm:8.0.0"
dependencies:
flat-cache: "npm:^6.1.23"
checksum: 10/a4a6c45c9561fbceb6511b3483e299e636e5c67e225ea4445c8cbdbe6c571da8505b29b29fc4c62fe9cf28ffe33314f401fad3da09e4b62321c92f0d3a113135
flat-cache: "npm:^4.0.0"
checksum: 10/afe55c4de4e0d226a23c1eae62a7219aafb390859122608a89fa4df6addf55c7fd3f1a2da6f5b41e7cdff496e4cf28bbd215d53eab5c817afa96d2b40c81bfb0
languageName: node
linkType: hard
@@ -9793,18 +9739,17 @@ __metadata:
languageName: node
linkType: hard
"flat-cache@npm:^6.1.23":
version: 6.1.23
resolution: "flat-cache@npm:6.1.23"
"flat-cache@npm:^4.0.0":
version: 4.0.1
resolution: "flat-cache@npm:4.0.1"
dependencies:
cacheable: "npm:^2.5.0"
flatted: "npm:^3.4.2"
hookified: "npm:^1.15.0"
checksum: 10/63ad3682f399e3e7b7255493b07469f80c418034a10e12e1c7043b41d666ff69b20be924984a0b91cd767a4f71540e4fb4421651d15adc736fca1c04b8505758
flatted: "npm:^3.2.9"
keyv: "npm:^4.5.4"
checksum: 10/58ce851d9045fffc7871ce2bd718bc485ad7e777bf748c054904b87c351ff1080c2c11da00788d78738bfb51b71e4d5ea12d13b98eb36e3358851ffe495b62dc
languageName: node
linkType: hard
"flatted@npm:^3.4.2":
"flatted@npm:^3.2.9":
version: 3.4.4
resolution: "flatted@npm:3.4.4"
checksum: 10/5a6682ab87ed15e125419c61352c4bef26f310adceece03eae5f22c119d64ed47bfc0ee1948a77d0122559eaa86395ea06ef45b59bfe3db4fb13fb4cdff876de
@@ -10370,15 +10315,6 @@ __metadata:
languageName: node
linkType: hard
"hashery@npm:^1.4.0, hashery@npm:^1.5.1":
version: 1.5.1
resolution: "hashery@npm:1.5.1"
dependencies:
hookified: "npm:^1.15.0"
checksum: 10/34eebad3e4d4041ae5c2457f129169b7be8d067fce4717ec6b903017e82b420263808a4d89f15cc77fa21822ae36d16841dc06d945392b6833b49082d3653e82
languageName: node
linkType: hard
"hasown@npm:^2.0.2, hasown@npm:^2.0.3, hasown@npm:^2.0.4":
version: 2.0.4
resolution: "hasown@npm:2.0.4"
@@ -10414,7 +10350,7 @@ __metadata:
"@codemirror/language": "npm:6.12.4"
"@codemirror/lint": "npm:6.9.7"
"@codemirror/search": "npm:6.7.2"
"@codemirror/state": "npm:6.7.4"
"@codemirror/state": "npm:6.7.2"
"@codemirror/view": "npm:6.43.11"
"@date-fns/tz": "npm:1.5.0"
"@egjs/hammerjs": "npm:2.0.17"
@@ -10487,7 +10423,7 @@ __metadata:
babel-loader: "npm:10.1.1"
babel-plugin-polyfill-corejs3: "npm:1.0.0"
barcode-detector: "npm:3.2.2"
browserslist: "npm:4.28.9"
browserslist: "npm:4.28.8"
browserslist-useragent-regexp: "npm:4.1.4"
cally: "npm:0.9.2"
color-name: "npm:2.1.1"
@@ -10502,7 +10438,7 @@ __metadata:
echarts: "npm:6.1.0"
echarts-extension-chart2music: "npm:0.1.1"
element-internals-polyfill: "npm:3.0.2"
eslint: "npm:10.10.0"
eslint: "npm:10.9.1"
eslint-config-prettier: "npm:10.1.8"
eslint-import-resolver-webpack: "npm:0.13.11"
eslint-plugin-import-x: "npm:4.17.1"
@@ -10598,20 +10534,6 @@ __metadata:
languageName: node
linkType: hard
"hookified@npm:^1.15.0, hookified@npm:^1.15.1":
version: 1.15.1
resolution: "hookified@npm:1.15.1"
checksum: 10/ecaee63506d9a213e8b78dfdb92346227f951d3bcd8ef60d14105c5590f61d466f130a908274e1ec87ee3b3668bd87d9250cad64547cf15cfa69932bcdbbea8f
languageName: node
linkType: hard
"hookified@npm:^2.1.1":
version: 2.2.0
resolution: "hookified@npm:2.2.0"
checksum: 10/36be80a9645161c9747c90a0a26f75f074470984cf221fb73cf0e2f367667e0d4680797f7b4f27b83d1f4cd4a3b5913f685c91132b5d6eeb05522674081657a8
languageName: node
linkType: hard
"hosted-git-info@npm:^10.1.0, hosted-git-info@npm:^10.1.1":
version: 10.1.1
resolution: "hosted-git-info@npm:10.1.1"
@@ -11582,6 +11504,13 @@ __metadata:
languageName: node
linkType: hard
"json-buffer@npm:3.0.1":
version: 3.0.1
resolution: "json-buffer@npm:3.0.1"
checksum: 10/82876154521b7b68ba71c4f969b91572d1beabadd87bd3a6b236f85fbc7dc4695089191ed60bb59f9340993c51b33d479f45b6ba9f3548beb519705281c32c3c
languageName: node
linkType: hard
"json-parse-even-better-errors@npm:^5.0.0":
version: 5.0.0
resolution: "json-parse-even-better-errors@npm:5.0.0"
@@ -11714,12 +11643,12 @@ __metadata:
languageName: node
linkType: hard
"keyv@npm:^5.6.0":
version: 5.6.0
resolution: "keyv@npm:5.6.0"
"keyv@npm:^4.5.4":
version: 4.5.4
resolution: "keyv@npm:4.5.4"
dependencies:
"@keyv/serialize": "npm:^1.1.1"
checksum: 10/f1de999fdf635d703d091981a0a3844fb20081e01d45c6743ada7d01f3a6570dc47d1882e10d0a98d31075b342db8e62b4ebe4f5bf473dcd0903064d718709c1
json-buffer: "npm:3.0.1"
checksum: 10/167eb6ef64cc84b6fa0780ee50c9de456b422a1e18802209234f7c2cf7eae648c7741f32e50d7e24ccb22b24c13154070b01563d642755b156c357431a191e75
languageName: node
linkType: hard
@@ -12719,10 +12648,10 @@ __metadata:
languageName: node
linkType: hard
"node-releases@npm:^2.0.54":
version: 2.0.54
resolution: "node-releases@npm:2.0.54"
checksum: 10/36380abbe4ce6f5bfec7dab13fa8174e67d2c316a9cbedcd01196bd37dbec99ca7214caf7a91f05d27ae17657d5cb55f39c53db216a7992fd9a2e29966ebd2ba
"node-releases@npm:^2.0.53":
version: 2.0.53
resolution: "node-releases@npm:2.0.53"
checksum: 10/24cb89ef08ec15b53fe50dad852dd0444e30abae425c6b9c457dcd3892405cbe8cf7b041a6eeefb4d6be8ce461fe03778501b25916bcc1539a6bc7debc1954e5
languageName: node
linkType: hard
@@ -13721,15 +13650,6 @@ __metadata:
languageName: node
linkType: hard
"qified@npm:^0.10.1":
version: 0.10.1
resolution: "qified@npm:0.10.1"
dependencies:
hookified: "npm:^2.1.1"
checksum: 10/65eda075defad24a3c53a0bcb0056fdaffb8355ca5dbe33bffaf3143e767dd1f0ddb403a4d9402e3bd0bc54b8efdec934055fb13eb5b6e7070835a80bf517f79
languageName: node
linkType: hard
"qr-creator@npm:^1.0.0":
version: 1.0.0
resolution: "qr-creator@npm:1.0.0"
@@ -16024,9 +15944,9 @@ __metadata:
languageName: node
linkType: hard
"update-browserslist-db@npm:^1.3.2":
version: 1.3.2
resolution: "update-browserslist-db@npm:1.3.2"
"update-browserslist-db@npm:^1.3.0":
version: 1.3.1
resolution: "update-browserslist-db@npm:1.3.1"
dependencies:
escalade: "npm:^3.2.0"
picocolors: "npm:^1.1.1"
@@ -16034,7 +15954,7 @@ __metadata:
browserslist: ">= 4.21.0"
bin:
update-browserslist-db: cli.js
checksum: 10/ca883e9d0fca1b5bfb9d5e7d9468c5ddd1d66ea827566f474875171381ad33b84aa555c138094fb2c6297c680e06482ad8b4c1ad931e8bfab9a4cf36ea9cf006
checksum: 10/24ba3c17529c4319c6c29e59fb2cb5dadb101ea2170bc6cfca4dd3e0a2fa6686e00d0f5cadb5ba22da3ec3f1a71e36a1785b71717b6f42237cfff1fb160b8bd5
languageName: node
linkType: hard