Compare commits

..

10 Commits

Author SHA1 Message Date
Wendelin
c27d127fed Refactor ha-switch component to integrate webawesome switch and enhance styling options 2026-04-10 15:36:18 +02:00
renovate[bot]
4ceb4c3c2c Update dependency marked to v18 (#51499)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-10 08:58:35 +02:00
renovate[bot]
cebdb46989 Update dependency jsdom to v29.0.2 (#51498)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-10 08:42:18 +02:00
Paulus Schoutsen
5aeae9ffa5 Fix duplicate "Add custom path" entry in navigation picker (#51496)
The navigation picker's _getItems was adding an "Add custom path" item,
but ha-picker-combo-box already adds one when allowCustomValue is set
and there's a search string. Remove the duplicate from _getItems since
the combo box handles it via the customValueLabel prop.

https://claude.ai/code/session_01NAB8bo1B6HuGFwKZVbvL1S

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-10 08:19:58 +03:00
Aidan Timson
2ce62841cf Settings dashboard repairs and updates design update (#51491)
* Reuse headings for config dashboard repairs and updates

* Keep headings internal to card and remove icons

* Merge headings into components

* Remove extra component for heading

* Use correct back links

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2026-04-09 20:29:56 +02:00
Timothy
63c9b85e6c Android externalAppV2 (#51446) 2026-04-09 16:34:32 +02:00
Petar Petrov
03ace97a7e Enable zoom and pan on sankey charts (#51488) 2026-04-09 16:32:12 +02:00
renovate[bot]
9edcfaf6b3 Update dependency @lokalise/node-api to v15.7.1 (#51490)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-09 14:53:58 +01:00
Paul Bottein
5cb7fdbfed Add search bar to integration page (#51485)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-04-09 15:16:01 +02:00
Petar Petrov
5a0e1e89e6 Add click-to-open-more-info to energy and water sankey cards (#51487) 2026-04-09 11:03:28 +00:00
24 changed files with 708 additions and 256 deletions

View File

@@ -3,37 +3,68 @@ title: Switch / Toggle
---
<style>
ha-switch {
display: block;
.wrapper {
display: flex;
gap: 24px;
align-items: center;
}
</style>
# Switch `<ha-switch>`
A toggle switch can represent two states: on and off.
A toggle switch representing two states: on and off.
## Examples
## Implementation
Switch in on state
### Example usage
<div class="wrapper">
<ha-switch checked></ha-switch>
<ha-switch></ha-switch>
<ha-switch disabled></ha-switch>
<ha-switch disabled checked></ha-switch>
</div>
```html
<ha-switch checked></ha-switch>
Switch in off state
<ha-switch></ha-switch>
Disabled switch
<ha-switch disabled></ha-switch>
## CSS variables
<ha-switch disabled checked></ha-switch>
```
For the switch / toggle there are always two variables, one for the on / checked state and one for the off / unchecked state.
### API
The track element (background rounded rectangle that the round circular handle travels on) is set to being half transparent, so the final color will also be impacted by the color behind the track.
This component is based on the webawesome switch component.
Check the [webawesome documentation](https://webawesome.com/docs/components/switch/) for more details.
`switch-checked-color` / `switch-unchecked-color`
Set both the color of the round handle and the track behind it. If you want to control them separately, use the variables below instead.
**Properties/Attributes**
`switch-checked-button-color` / `switch-unchecked-button-color`
Color of the round handle
| Name | Type | Default | Description |
| -------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------- |
| checked | Boolean | false | The checked state of the switch. |
| disabled | Boolean | false | Disables the switch and prevents user interaction. |
| required | Boolean | false | Makes the switch a required field. |
| haptic | Boolean | false | Enables haptic vibration on toggle. Only use when the new state is applied immediately (not when save is required). |
`switch-checked-track-color` / `switch-unchecked-track-color`
Color of the track behind the round handle
**CSS Custom Properties**
- `--ha-switch-size` - The size of the switch track height. Defaults to `14px`.
- `--ha-switch-thumb-size` - The size of the thumb. Defaults to `20px`.
- `--ha-switch-width` - The width of the switch track. Defaults to `36px`.
- `--ha-switch-box-shadow` - The box shadow of the thumb. Defaults to `var(--ha-box-shadow-s)`.
- `--ha-switch-background-color` - Background color of the unchecked track.
- `--ha-switch-border-color` - Border color of the unchecked track and thumb.
- `--ha-switch-thumb-background-color` - Background color of the unchecked thumb.
- `--ha-switch-background-color-hover` - Background color of the unchecked track on hover.
- `--ha-switch-thumb-background-color-hover` - Background color of the unchecked thumb on hover.
- `--ha-switch-checked-background-color` - Background color of the checked track.
- `--ha-switch-checked-border-color` - Border color of the checked track.
- `--ha-switch-checked-thumb-background-color` - Background color of the checked thumb.
- `--ha-switch-checked-thumb-border-color` - Border color of the checked thumb.
- `--ha-switch-checked-background-color-hover` - Background color of the checked track on hover.
- `--ha-switch-checked-thumb-background-color-hover` - Background color of the checked thumb on hover.
- `--ha-switch-required-marker` - The marker shown after the label for required fields. Defaults to `"*"`.
- `--ha-switch-required-marker-offset` - Offset of the required marker. Defaults to `0.1rem`.

View File

@@ -109,7 +109,7 @@
"lit": "3.3.2",
"lit-html": "3.3.2",
"luxon": "3.7.2",
"marked": "17.0.6",
"marked": "18.0.0",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -140,7 +140,7 @@
"@eslint/eslintrc": "3.3.5",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.59.0",
"@lokalise/node-api": "15.6.1",
"@lokalise/node-api": "15.7.1",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
@@ -189,7 +189,7 @@
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "29.0.1",
"jsdom": "29.0.2",
"jszip": "3.10.1",
"lint-staged": "16.4.0",
"lit-analyzer": "2.0.3",

View File

@@ -174,6 +174,7 @@ export class HaChartBase extends LitElement {
if (!this.options?.dataZoom) {
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
}
this._updateSankeyRoam();
// drag to zoom
this.chart?.dispatchAction({
type: "takeGlobalCursor",
@@ -192,6 +193,7 @@ export class HaChartBase extends LitElement {
if (!this.options?.dataZoom) {
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
}
this._updateSankeyRoam();
this.chart?.dispatchAction({
type: "takeGlobalCursor",
key: "dataZoomSelect",
@@ -267,6 +269,9 @@ export class HaChartBase extends LitElement {
}
if (Object.keys(chartOptions).length > 0) {
this._setChartOptions(chartOptions);
if (chartOptions.series) {
this._updateSankeyRoam();
}
}
}
@@ -451,6 +456,22 @@ export class HaChartBase extends LitElement {
this.chart.on("click", (e: ECElementEvent) => {
fireEvent(this, "chart-click", e);
});
this.chart.on("sankeyroam", () => {
const option = this.chart!.getOption();
const series = option.series as any[];
const sankeySeries = series?.find((s: any) => s.type === "sankey");
const zoomed = sankeySeries.zoom !== 1;
this._isZoomed = zoomed;
if (!zoomed) {
// Reset center when fully zoomed out
this.chart!.setOption({
series: [{ id: sankeySeries.id, center: null }],
});
}
fireEvent(this, "chart-sankeyroam", { zoom: sankeySeries.zoom });
// Clear cached emphasis states so labels don't revert to pre-zoom sizes
this.chart!.dispatchAction({ type: "downplay" });
});
if (!this.options?.dataZoom) {
this.chart.getZr().on("dblclick", this._handleClickZoom);
@@ -549,6 +570,7 @@ export class HaChartBase extends LitElement {
...this._createOptions(),
series: this._getSeries(),
});
this._updateSankeyRoam();
} finally {
this._loading = false;
}
@@ -988,6 +1010,26 @@ export class HaChartBase extends LitElement {
if (!this.chart) {
return;
}
// Handle sankey chart double-click zoom
const option = this.chart.getOption();
const allSeries = option.series as any[];
const sankeySeries = allSeries?.filter((s: any) => s.type === "sankey");
if (sankeySeries?.length) {
if (this._isZoomed) {
this._handleZoomReset();
} else {
this.chart.setOption({
series: sankeySeries.map((s: any) => ({
id: s.id,
zoom: 2,
})),
});
this._isZoomed = true;
}
if (sankeySeries.length === allSeries?.length) {
return;
}
}
const range = this._isZoomed
? [0, 100]
: [
@@ -1012,6 +1054,37 @@ export class HaChartBase extends LitElement {
private _handleZoomReset() {
this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 });
// Reset sankey roam zoom
const option = this.chart?.getOption();
const sankeySeries = (option?.series as any[])?.filter(
(s: any) => s.type === "sankey"
);
if (sankeySeries?.length) {
this.chart?.setOption({
series: sankeySeries.map((s: any) => ({
id: s.id,
zoom: 1,
center: null,
})),
});
this._isZoomed = false;
fireEvent(this, "chart-sankeyroam", { zoom: 1 });
}
}
private _updateSankeyRoam() {
const option = this.chart?.getOption();
const sankeySeries = (option?.series as any[])?.filter(
(s: any) => s.type === "sankey"
);
if (sankeySeries?.length) {
this.chart?.setOption({
series: sankeySeries.map((s: any) => ({
id: s.id,
roam: this._modifierPressed || this._isTouchDevice ? true : "move",
})),
});
}
}
private _handleDataZoomEvent(e: any) {
@@ -1382,5 +1455,6 @@ declare global {
start: number;
end: number;
};
"chart-sankeyroam": { zoom: number };
}
}

View File

@@ -64,6 +64,8 @@ export class HaSankeyChart extends LitElement {
public chart?: EChartsType;
private _currentZoom = 1;
@state() private _sizeController = new ResizeController(this, {
callback: (entries) => entries[0]?.contentRect,
});
@@ -84,11 +86,13 @@ export class HaSankeyChart extends LitElement {
} as ECOption;
return html`<ha-chart-base
.hass=${this.hass}
.data=${this._createData(this.data, this._sizeController.value?.width)}
.options=${options}
height="100%"
.extraComponents=${[SankeyChart]}
@chart-click=${this._handleChartClick}
@chart-sankeyroam=${this._handleChartSankeyRoam}
></ha-chart-base>`;
}
@@ -109,6 +113,10 @@ export class HaSankeyChart extends LitElement {
return null;
};
private _handleChartSankeyRoam = (ev: CustomEvent) => {
this._currentZoom = ev.detail.zoom;
};
private _handleChartClick = (ev: CustomEvent<ECElementEvent>) => {
const detail = ev.detail;
// Only handle node clicks (not links)
@@ -180,6 +188,7 @@ export class HaSankeyChart extends LitElement {
})),
links,
draggable: false,
scaleLimit: { min: 1, max: 4 },
orient: this.vertical ? "vertical" : "horizontal",
nodeWidth: 15,
nodeGap: NODE_GAP,
@@ -210,7 +219,7 @@ export class HaSankeyChart extends LitElement {
""
);
const wordWidth = measureTextWidth(longestWord, FONT_SIZE);
const availableWidth = params.rect.width + 6;
const availableWidth = (params.rect.width + 6) * this._currentZoom;
const fontSize = Math.min(
FONT_SIZE,
(availableWidth / wordWidth) * FONT_SIZE
@@ -223,7 +232,7 @@ export class HaSankeyChart extends LitElement {
};
}
const availableHeight = params.rect.height + 8; // account for the margin
const availableHeight = (params.rect.height + 8) * this._currentZoom; // account for the margin
const fontSize = Math.min(
(availableHeight / params.labelRect.height) * FONT_SIZE,
FONT_SIZE

View File

@@ -1,5 +1,5 @@
import Fuse from "fuse.js";
import { mdiDevices, mdiPlus, mdiTextureBox } from "@mdi/js";
import { mdiDevices, mdiTextureBox } from "@mdi/js";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -235,22 +235,6 @@ export class HaNavigationPicker extends LitElement {
addGroup("views", views);
addGroup("other_routes", otherRoutes);
if (
searchString &&
!this._navigationItems.some((navItem) => navItem.id === searchString)
) {
items.push({
id: searchString,
primary: this.hass.localize(
"ui.components.navigation-picker.add_custom_path"
),
secondary: `"${searchString}"`,
icon_path: mdiPlus,
sorting_label: searchString,
group: "other_routes",
});
}
return items;
};

View File

@@ -1,49 +1,195 @@
import { SwitchBase } from "@material/mwc-switch/deprecated/mwc-switch-base";
import { styles } from "@material/mwc-switch/deprecated/mwc-switch.css";
import { css } from "lit";
import Switch from "@home-assistant/webawesome/dist/components/switch/switch";
import { css, type CSSResultGroup, type PropertyValues } from "lit";
import { customElement, property } from "lit/decorators";
import { forwardHaptic } from "../data/haptics";
/**
* Home Assistant switch component
*
* @element ha-switch
* @extends {Switch}
*
* @summary
* A toggle switch component supporting Home Assistant theming, based on the webawesome switch.
* Represents two states: on and off.
*
* @cssprop --ha-switch-size - The size of the switch track height. Defaults to `14px`.
* @cssprop --ha-switch-thumb-size - The size of the thumb. Defaults to `20px`.
* @cssprop --ha-switch-width - The width of the switch track. Defaults to `36px`.
* @cssprop --ha-switch-box-shadow - The box shadow of the thumb. Defaults to `var(--ha-box-shadow-s)`.
* @cssprop --ha-switch-background-color - Background color of the unchecked track.
* @cssprop --ha-switch-border-color - Border color of the unchecked track and thumb.
* @cssprop --ha-switch-thumb-background-color - Background color of the unchecked thumb.
* @cssprop --ha-switch-background-color-hover - Background color of the unchecked track on hover.
* @cssprop --ha-switch-thumb-background-color-hover - Background color of the unchecked thumb on hover.
* @cssprop --ha-switch-checked-background-color - Background color of the checked track.
* @cssprop --ha-switch-checked-border-color - Border color of the checked track.
* @cssprop --ha-switch-checked-thumb-background-color - Background color of the checked thumb.
* @cssprop --ha-switch-checked-thumb-border-color - Border color of the checked thumb.
* @cssprop --ha-switch-checked-background-color-hover - Background color of the checked track on hover.
* @cssprop --ha-switch-checked-thumb-background-color-hover - Background color of the checked thumb on hover.
* @cssprop --ha-switch-required-marker - The marker shown after the label for required fields. Defaults to `"*"`.
* @cssprop --ha-switch-required-marker-offset - Offset of the required marker. Defaults to `0.1rem`.
*
* @attr {boolean} checked - The checked state of the switch.
* @attr {boolean} disabled - Disables the switch and prevents user interaction.
* @attr {boolean} required - Makes the switch a required field.
* @attr {boolean} haptic - Enables haptic vibration on toggle. Only use when the new state is applied immediately (not when a save action is required).
*/
@customElement("ha-switch")
export class HaSwitch extends SwitchBase {
// Generate a haptic vibration.
// Only set to true if the new value of the switch is applied right away when toggling.
// Do not add haptic when a user is required to press save.
export class HaSwitch extends Switch {
/**
* Enables haptic vibration on toggle.
* Only set to true if the new value of the switch is applied right away when toggling.
* Do not add haptic when a user is required to press save.
*/
@property({ type: Boolean }) public haptic = false;
protected firstUpdated() {
super.firstUpdated();
this.addEventListener("change", () => {
public updated(changedProperties: PropertyValues<typeof this>) {
super.updated(changedProperties);
if (changedProperties.has("haptic")) {
if (this.haptic) {
forwardHaptic(this, "light");
this.addEventListener("change", this._forwardHaptic);
} else {
this.removeEventListener("change", this._forwardHaptic);
}
});
}
}
static override styles = [
styles,
css`
:host {
--mdc-theme-secondary: var(--switch-checked-color);
}
.mdc-switch.mdc-switch--checked .mdc-switch__thumb {
background-color: var(--switch-checked-button-color);
border-color: var(--switch-checked-button-color);
}
.mdc-switch.mdc-switch--checked .mdc-switch__track {
background-color: var(--switch-checked-track-color);
border-color: var(--switch-checked-track-color);
}
.mdc-switch:not(.mdc-switch--checked) .mdc-switch__thumb {
background-color: var(--switch-unchecked-button-color);
border-color: var(--switch-unchecked-button-color);
}
.mdc-switch:not(.mdc-switch--checked) .mdc-switch__track {
background-color: var(--switch-unchecked-track-color);
border-color: var(--switch-unchecked-track-color);
}
`,
];
public disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("change", this._forwardHaptic);
}
private _forwardHaptic = () => {
forwardHaptic(this, "light");
};
static get styles(): CSSResultGroup {
return [
Switch.styles,
css`
:host {
--wa-form-control-toggle-size: var(--ha-switch-size, 14px);
--wa-form-control-required-content: var(
--ha-switch-required-marker,
var(--ha-input-required-marker, "*")
);
--wa-form-control-required-content-offset: var(
--ha-switch-required-marker-offset,
0.1rem
);
--thumb-size: var(--ha-switch-thumb-size, 20px);
--width: var(--ha-switch-width, 36px);
}
label {
height: max(var(--thumb-size), var(--wa-form-control-toggle-size));
padding: 0 3px;
}
.switch {
background-color: var(
--ha-switch-background-color,
var(--ha-color-form-background)
);
border-color: var(
--ha-switch-border-color,
var(--ha-color-border-neutral-normal)
);
}
.switch .thumb {
background-color: var(
--ha-switch-thumb-background-color,
var(--ha-color-form-background)
);
border-color: var(
--ha-switch-border-color,
var(--ha-color-border-neutral-normal)
);
border-style: var(--wa-form-control-border-style);
border-width: var(--wa-form-control-border-width);
box-shadow: var(--ha-switch-box-shadow, var(--ha-box-shadow-s));
}
label:not(.disabled):hover .switch,
label:not(.disabled) .input:focus-visible ~ .switch,
label:not(.disabled):active .switch {
background-color: var(
--ha-switch-background-color-hover,
var(
--ha-switch-background-color,
var(--ha-color-fill-neutral-normal-hover)
)
);
}
label:not(.disabled):hover .switch .thumb,
label:not(.disabled) .input:focus-visible ~ .switch .thumb,
label:not(.disabled):active .switch .thumb {
background-color: var(
--ha-switch-thumb-background-color-hover,
var(
--ha-switch-thumb-background-color,
var(--ha-color-form-background-hover)
)
);
}
.checked .switch {
background-color: var(
--ha-switch-checked-background-color,
var(--ha-color-fill-primary-normal-resting)
);
border-color: var(
--ha-switch-checked-border-color,
var(--ha-color-border-primary-loud)
);
}
.checked .switch .thumb {
background-color: var(
--ha-switch-checked-thumb-background-color,
var(--ha-color-fill-primary-loud-resting)
);
border-color: var(
--ha-switch-checked-thumb-border-color,
var(--ha-color-fill-primary-loud-resting)
);
}
label:not(.disabled).checked:hover .switch,
label:not(.disabled).checked .input:focus-visible ~ .switch,
label:not(.disabled).checked:active .switch {
background-color: var(
--ha-switch-checked-background-color-hover,
var(
--ha-switch-checked-background-color,
var(--ha-color-fill-primary-normal-hover)
)
);
}
label:not(.disabled).checked:hover .switch .thumb,
label:not(.disabled).checked .input:focus-visible ~ .switch .thumb,
label:not(.disabled).checked:active .switch .thumb {
background-color: var(
--ha-switch-checked-thumb-background-color-hover,
var(
--ha-switch-checked-thumb-background-color,
var(--ha-color-fill-primary-loud-hover)
)
);
}
label.disabled {
opacity: 0.5;
cursor: not-allowed;
}
`,
];
}
}
declare global {

View File

@@ -1,4 +1,6 @@
export const isExternal =
window.externalAppV2 ||
window.externalApp ||
window.webkit?.messageHandlers?.getExternalAuth ||
location.search.includes("external_auth=1");
export const isExternalAndroid = window.externalApp || window.externalAppV2;

View File

@@ -5,6 +5,11 @@ import { Auth } from "home-assistant-js-websocket";
import type { EMMessage } from "./external_messaging";
import { ExternalMessaging } from "./external_messaging";
/**
* WARNING: These constants should not be changed, as the native app relies on
* these exact string values to know which callback to call.
* This happens after getting a response from the native app.
*/
const CALLBACK_SET_TOKEN = "externalAuthSetToken";
const CALLBACK_REVOKE_TOKEN = "externalAuthRevokeToken";
@@ -28,6 +33,9 @@ declare global {
revokeExternalAuth(payload: string);
externalBus(payload: string);
};
externalAppV2?: {
postMessage(payload: string): void;
};
webkit?: {
messageHandlers: {
getExternalAuth: {
@@ -44,9 +52,9 @@ declare global {
}
}
if (!window.externalApp && !window.webkit) {
if (!window.externalApp && !window.webkit && !window.externalAppV2) {
throw new Error(
"External auth requires either externalApp or webkit defined on Window object."
"External auth requires either externalApp, externalAppV2, or webkit defined on Window object."
);
}
@@ -95,7 +103,11 @@ export class ExternalAuth extends Auth {
// we sleep 1 microtask to get the promise to actually set it on the window object.
await Promise.resolve();
if (window.externalApp) {
if (window.externalAppV2) {
window.externalAppV2.postMessage(
JSON.stringify({ type: "getExternalAuth", payload })
);
} else if (window.externalApp) {
window.externalApp.getExternalAuth(JSON.stringify(payload));
} else {
window.webkit!.messageHandlers.getExternalAuth.postMessage(payload);
@@ -119,7 +131,11 @@ export class ExternalAuth extends Auth {
// we sleep 1 microtask to get the promise to actually set it on the window object.
await Promise.resolve();
if (window.externalApp) {
if (window.externalAppV2) {
window.externalAppV2.postMessage(
JSON.stringify({ type: "revokeExternalAuth", payload })
);
} else if (window.externalApp) {
window.externalApp.revokeExternalAuth(JSON.stringify(payload));
} else {
window.webkit!.messageHandlers.revokeExternalAuth.postMessage(payload);
@@ -132,6 +148,7 @@ export class ExternalAuth extends Auth {
export const createExternalAuth = async (hassUrl: string) => {
const auth = new ExternalAuth(hassUrl);
if (
window.externalAppV2 ||
window.externalApp?.externalBus ||
(window.webkit && window.webkit.messageHandlers.externalBus)
) {

View File

@@ -187,6 +187,11 @@ interface EMOutgoingMessageFocusElement extends EMMessage {
};
}
// These types are handled internally by the Android app via postMessage.
// They are not sent by the frontend and should not be used directly.
// They are intentionally listed here to prevent anyone from using them unintentionally.
type RejectedEMMessageType = "onHomeAssistantSetTheme" | "handleBlob";
type EMOutgoingMessageWithoutAnswer =
| EMMessageResultError
| EMMessageResultSuccess
@@ -393,8 +398,16 @@ export class ExternalMessaging {
* Send message to external app that expects a response.
* @param msg message to send
*/
public sendMessage<T extends keyof EMOutgoingMessageWithAnswer>(
msg: EMOutgoingMessageWithAnswer[T]["request"]
public sendMessage<
T extends keyof EMOutgoingMessageWithAnswer,
TType extends string = EMOutgoingMessageWithAnswer[T]["request"]["type"],
>(
msg: EMOutgoingMessageWithAnswer[T]["request"] & {
type: TType &
(TType extends RejectedEMMessageType
? "ERROR: message type is rejected"
: {});
}
): Promise<EMOutgoingMessageWithAnswer[T]["response"]> {
const msgId = ++this.msgId;
msg.id = msgId;
@@ -412,7 +425,14 @@ export class ExternalMessaging {
* Send message to external app without expecting a response.
* @param msg message to send
*/
public fireMessage(msg: EMOutgoingMessageWithoutAnswer) {
public fireMessage<T extends string>(
msg: EMOutgoingMessageWithoutAnswer & {
type: T &
(T extends RejectedEMMessageType
? "ERROR: message type is rejected"
: {});
}
) {
if (!msg.id) {
msg.id = ++this.msgId;
}
@@ -473,7 +493,11 @@ export class ExternalMessaging {
// eslint-disable-next-line no-console
console.log("Sending message to external app", msg);
}
if (window.externalApp) {
if (window.externalAppV2) {
window.externalAppV2.postMessage(
JSON.stringify({ type: "externalBus", payload: msg })
);
} else if (window.externalApp) {
window.externalApp.externalBus(JSON.stringify(msg));
} else {
window.webkit!.messageHandlers.externalBus.postMessage(msg);

View File

@@ -122,11 +122,15 @@ class HaConfigSectionUpdates extends LitElement {
? html`
<ha-card outlined>
<div class="card-content">
<div class="title" role="heading" aria-level="2">
${this.hass.localize("ui.panel.config.updates.title", {
count: canInstallUpdates.length,
})}
</div>
<ha-config-updates
.hass=${this.hass}
.narrow=${this.narrow}
.updateEntities=${canInstallUpdates}
.isInstallable=${true}
showAll
></ha-config-updates>
</div>
@@ -137,11 +141,18 @@ class HaConfigSectionUpdates extends LitElement {
? html`
<ha-card outlined>
<div class="card-content">
<div class="title" role="heading" aria-level="2">
${this.hass.localize(
"ui.panel.config.updates.title_not_installable",
{
count: notInstallableUpdates.length,
}
)}
</div>
<ha-config-updates
.hass=${this.hass}
.narrow=${this.narrow}
.updateEntities=${notInstallableUpdates}
.isInstallable=${false}
showAll
></ha-config-updates>
</div>
@@ -236,6 +247,11 @@ class HaConfigSectionUpdates extends LitElement {
padding: 0;
}
.title {
padding: var(--ha-space-4) var(--ha-space-4) 0;
font-size: var(--ha-font-size-l);
}
.no-updates {
padding: 16px;
}

View File

@@ -11,7 +11,6 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import "../../../components/chips/ha-assist-chip";
import "../../../components/ha-card";
import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
@@ -293,62 +292,60 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
full-width
>
${repairsIssues.length || canInstallUpdates.length
? html`<ha-card outlined>
? html`<div class="dashboard-alerts">
${repairsIssues.length
? html`
<ha-config-repairs
.hass=${this.hass}
.narrow=${this.narrow}
.total=${totalRepairIssues}
.repairsIssues=${repairsIssues}
></ha-config-repairs>
${totalRepairIssues > repairsIssues.length
? html`
<ha-assist-chip
href="/config/repairs"
.label=${this.hass.localize(
"ui.panel.config.repairs.more_repairs",
{
count:
totalRepairIssues - repairsIssues.length,
}
)}
>
</ha-assist-chip>
`
: ""}
<ha-card outlined class="dashboard-alert-card">
<div
class="dashboard-alert-title"
role="heading"
aria-level="2"
>
<a href="/config/repairs?historyBack=1">
${this.hass.localize(
"ui.panel.config.repairs.title",
{
count: totalRepairIssues,
}
)}
<ha-icon-next></ha-icon-next>
</a>
</div>
<ha-config-repairs
.hass=${this.hass}
.narrow=${this.narrow}
.repairsIssues=${repairsIssues}
></ha-config-repairs>
</ha-card>
`
: ""}
${repairsIssues.length && canInstallUpdates.length
? html`<hr />`
: ""}
${canInstallUpdates.length
? html`
<ha-config-updates
.hass=${this.hass}
.narrow=${this.narrow}
.total=${totalUpdates}
.updateEntities=${canInstallUpdates}
.isInstallable=${true}
></ha-config-updates>
${totalUpdates > canInstallUpdates.length
? html`
<ha-assist-chip
href="/config/updates"
label=${this.hass.localize(
"ui.panel.config.updates.more_updates",
{
count:
totalUpdates - canInstallUpdates.length,
}
)}
>
</ha-assist-chip>
`
: ""}
<ha-card outlined class="dashboard-alert-card">
<div
class="dashboard-alert-title"
role="heading"
aria-level="2"
>
<a href="/config/updates?historyBack=1">
${this.hass.localize(
"ui.panel.config.updates.title",
{
count: totalUpdates,
}
)}
<ha-icon-next></ha-icon-next>
</a>
</div>
<ha-config-updates
.hass=${this.hass}
.narrow=${this.narrow}
.updateEntities=${canInstallUpdates}
></ha-config-updates>
</ha-card>
`
: ""}
</ha-card>`
</div>`
: ""}
${this._pages(
this.cloudStatus,
@@ -427,10 +424,6 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
return [
haStyle,
css`
:host(:not([narrow])) ha-card:last-child {
margin-bottom: 24px;
}
ha-config-section {
margin: auto;
margin-top: -32px;
@@ -439,20 +432,34 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
ha-card {
overflow: hidden;
margin-bottom: 0;
}
ha-card a {
text-decoration: none;
color: var(--primary-text-color);
}
ha-assist-chip {
margin: 8px 16px 16px 16px;
.dashboard-alerts {
display: flex;
flex-direction: column;
gap: var(--ha-space-4);
}
.title {
.dashboard-alert-title {
padding: var(--ha-space-4) var(--ha-space-4) 0;
font-size: var(--ha-font-size-l);
padding: 16px;
padding-bottom: 0;
}
.dashboard-alert-title a {
display: inline-flex;
align-items: center;
gap: var(--ha-space-2);
}
.dashboard-alert-title ha-icon-next {
color: var(--secondary-text-color);
width: 20px;
height: 20px;
}
@media all and (max-width: 600px) {
@@ -477,16 +484,6 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
.keep-together {
display: inline-block;
}
hr {
height: 1px;
background-color: var(
--ha-card-border-color,
var(--divider-color, #e0e0e0)
);
border: none;
margin-top: 0;
}
`,
];
}

View File

@@ -30,10 +30,6 @@ class HaConfigUpdates extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public updateEntities?: UpdateEntity[];
@property({ type: Number }) public total?: number;
@property({ attribute: false }) public isInstallable = true;
@state() private _devices?: DeviceRegistryEntry[];
@state() private _entities?: EntityRegistryEntry[];
@@ -90,18 +86,6 @@ class HaConfigUpdates extends SubscribeMixin(LitElement) {
const updates = this.updateEntities;
return html`
<div class="title" role="heading" aria-level="2">
${this.isInstallable
? this.hass.localize("ui.panel.config.updates.title", {
count: this.total || this.updateEntities.length,
})
: this.hass.localize(
"ui.panel.config.updates.title_not_installable",
{
count: this.total || this.updateEntities.length,
}
)}
</div>
<ha-md-list>
${updates.map((entity) => {
const entityEntry = this.getEntityEntry(entity.entity_id);
@@ -181,11 +165,6 @@ class HaConfigUpdates extends SubscribeMixin(LitElement) {
static get styles(): CSSResultGroup[] {
return [
css`
.title {
font-size: var(--ha-font-size-l);
padding: 16px;
padding-bottom: 0;
}
.skipped {
background: var(--secondary-background-color);
}

View File

@@ -277,8 +277,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
const deviceClasses: string[][] = OVERRIDE_DEVICE_CLASSES[domain];
if (!deviceClasses || this._hideDeviceClassOverride(domain)) {
this._deviceClassOptions = undefined;
if (!deviceClasses) {
return;
}
@@ -292,16 +291,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
}
private _hideDeviceClassOverride(domain: string): boolean {
// Template binary sensor device_class should be edited via template options,
// not the entity registry override UI used by other binary sensors.
return (
domain === "binary_sensor" &&
this.entry.platform === "template" &&
!!this.entry.config_entry_id
);
}
private _precisionLabel(precision?: number, stateValue?: string) {
const stateValueNumber = Number(stateValue);
const value = !isNaN(stateValueNumber) ? stateValue! : 0;
@@ -536,7 +525,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
`
: nothing} `
: nothing}
${this._deviceClassOptions && !this._hideDeviceClassOverride(domain)
${this._deviceClassOptions
? html`
<ha-select
.label=${this.hass.localize(

View File

@@ -24,12 +24,15 @@ import {
protocolIntegrationPicked,
} from "../../../common/integrations/protocolIntegrationPicked";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { nextRender } from "../../../common/util/render-status";
import "../../../components/ha-button";
import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-md-list";
import "../../../components/ha-md-list-item";
import "../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../components/input/ha-input-search";
import { getSignedPath } from "../../../data/auth";
import type { ConfigEntry, SubEntry } from "../../../data/config_entries";
import {
@@ -64,6 +67,7 @@ import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-error-screen";
import "../../../layouts/hass-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { multiTermSearch } from "../../../resources/fuseMultiTerm";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
@@ -74,7 +78,6 @@ import type { HaConfigEntryRow } from "./ha-config-entry-row";
import type { DataEntryFlowProgressExtended } from "./ha-config-integrations";
import { showAddIntegrationDialog } from "./show-add-integration-dialog";
import { showPickConfigEntryDialog } from "./show-pick-config-entry-dialog";
import type { LocalizeFunc } from "../../../common/translations/localize";
export interface SubEntryData {
subEntry: SubEntry;
@@ -156,6 +159,8 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
window.location.hash.substring(1)
);
@state() private _filter = "";
@state() private _subEntries: Record<string, SubEntry[]> = {};
private _subEntriesFetchId = 0;
@@ -218,6 +223,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
this.hass.loadBackendTranslation("config", [this.domain]);
this.hass.loadBackendTranslation("config_subentries", [this.domain]);
this._extraConfigEntries = undefined;
this._filter = "";
this._subEntries = {};
this._fetchManifest();
this._fetchDiagnostics();
@@ -333,7 +339,16 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
this.hass.locale.language,
this.hass.localize
);
const filteredNormalData = this._filterNormalTree(
normalData,
this._filter,
this.hass.areas
);
const filteredAttentionData = this._filterAttentionTree(
attentionData,
this._filter,
this.hass.areas
);
const devicesRegs = this._getDevices(configEntries, this.hass.devices);
const entities = this._getEntities(configEntries, this._entities);
let numberOfEntities = entities.length;
@@ -652,6 +667,13 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
</div>
</div>
${normalData.length + attentionData.length > 0
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
></ha-input-search>`
: nothing}
${this._logInfo?.level === LogSeverity.DEBUG
? html`<div class="section">
<ha-alert alert-type="warning">
@@ -698,7 +720,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
</div>
`
: nothing}
${attentionFlows.length || attentionData.length
${attentionFlows.length || filteredAttentionData.length
? html`
<div class="section">
<h3 class="section-header">
@@ -738,7 +760,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
})}
</ha-md-list>`
: nothing}
${attentionData.map(
${filteredAttentionData.map(
(data) =>
html`<ha-config-entry-row
class="attention"
@@ -765,22 +787,26 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
`ui.panel.config.integrations.integration_page.entries`
)}
</h3>
${normalData.length === 0
${filteredNormalData.length === 0
? html`<div class="card-content no-entries">
${this._manifest &&
!this._manifest.config_flow &&
this.hass.config.components.find(
(comp) => comp.split(".")[0] === this.domain
)
${this._filter
? this.hass.localize(
"ui.panel.config.integrations.integration_page.yaml_entry"
"ui.panel.config.integrations.none_found"
)
: this.hass.localize(
"ui.panel.config.integrations.integration_page.no_entries"
)}
: this._manifest &&
!this._manifest.config_flow &&
this.hass.config.components.find(
(comp) => comp.split(".")[0] === this.domain
)
? this.hass.localize(
"ui.panel.config.integrations.integration_page.yaml_entry"
)
: this.hass.localize(
"ui.panel.config.integrations.integration_page.no_entries"
)}
</div>`
: html`
${normalData.map(
${filteredNormalData.map(
(data) =>
html`<ha-config-entry-row
.hass=${this.hass}
@@ -968,6 +994,110 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
private _buildAttentionEntryData = memoizeOne(this._buildEntryData);
private _filterTree = memoizeOne(
(
data: ConfigEntryData[],
filter: string,
areas: HomeAssistant["areas"]
): ConfigEntryData[] => {
if (!filter) {
return data;
}
const DEVICE_KEYS = [
"name",
"manufacturer",
"model",
"sw_version",
"area",
];
const buildDeviceSearchable = (device: DeviceRegistryEntry) => ({
device,
name: device.name_by_user || device.name || "",
manufacturer: device.manufacturer || "",
model: device.model || "",
sw_version: device.sw_version || "",
area: device.area_id ? areas[device.area_id]?.name || "" : "",
});
const matchDevices = (devices: DeviceRegistryEntry[]) =>
multiTermSearch(
devices.map(buildDeviceSearchable),
filter,
DEVICE_KEYS,
undefined,
{ keys: DEVICE_KEYS }
).map((r) => r.device);
const TITLE_KEYS = ["title"];
const titleMatches = (title: string) =>
multiTermSearch([{ title }], filter, TITLE_KEYS, undefined, {
keys: TITLE_KEYS,
}).length > 0;
const result: ConfigEntryData[] = [];
for (const entryData of data) {
if (titleMatches(entryData.entry.title)) {
result.push(entryData);
continue;
}
const filteredDevices = matchDevices(entryData.devices);
const filteredServices = matchDevices(entryData.services);
const filteredSubEntries = entryData.subEntries
.map((subData): SubEntryData | null => {
if (titleMatches(subData.subEntry.title)) {
return subData;
}
const subDevices = matchDevices(subData.devices);
const subServices = matchDevices(subData.services);
if (subDevices.length || subServices.length) {
return {
subEntry: subData.subEntry,
devices: subDevices,
services: subServices,
};
}
return null;
})
.filter((s): s is SubEntryData => s !== null);
if (
filteredDevices.length ||
filteredServices.length ||
filteredSubEntries.length
) {
result.push({
entry: entryData.entry,
devices: filteredDevices,
services: filteredServices,
subEntries: filteredSubEntries,
});
}
}
return result;
}
);
private _filterNormalTree = memoizeOne(
(data: ConfigEntryData[], filter: string, areas: HomeAssistant["areas"]) =>
this._filterTree(data, filter, areas)
);
private _filterAttentionTree = memoizeOne(
(data: ConfigEntryData[], filter: string, areas: HomeAssistant["areas"]) =>
this._filterTree(data, filter, areas)
);
private _handleSearchChange(ev: InputEvent) {
this._filter = (ev.target as HaInputSearch).value ?? "";
}
private async _handleEnableDebugLogging() {
const integration = this.domain;
await setIntegrationLogLevel(
@@ -1198,6 +1328,10 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
flex-wrap: wrap;
gap: var(--ha-space-2);
}
ha-input-search {
width: 100%;
margin-bottom: var(--ha-space-4);
}
.section {
width: 100%;
}

View File

@@ -121,6 +121,11 @@ class HaConfigRepairsDashboard extends SubscribeMixin(LitElement) {
<div class="card-content">
${issues.length
? html`
<div class="title" role="heading" aria-level="2">
${this.hass.localize("ui.panel.config.repairs.title", {
count: issues.length,
})}
</div>
<ha-config-repairs
.hass=${this.hass}
.narrow=${this.narrow}
@@ -193,6 +198,11 @@ class HaConfigRepairsDashboard extends SubscribeMixin(LitElement) {
padding: 0;
}
.title {
padding: var(--ha-space-4) var(--ha-space-4) 0;
font-size: var(--ha-font-size-l);
}
.no-repairs {
padding: 16px;
}

View File

@@ -32,9 +32,6 @@ class HaConfigRepairs extends LitElement {
@property({ attribute: false })
public repairsIssues?: RepairsIssue[];
@property({ type: Number })
public total?: number;
protected render() {
if (!this.repairsIssues?.length) {
return nothing;
@@ -43,11 +40,6 @@ class HaConfigRepairs extends LitElement {
const issues = this.repairsIssues;
return html`
<div class="title" role="heading" aria-level="2">
${this.hass.localize("ui.panel.config.repairs.title", {
count: this.total || this.repairsIssues.length,
})}
</div>
<ha-md-list>
${issues.map((issue) => {
const domainName = domainToName(this.hass.localize, issue.domain);
@@ -191,11 +183,6 @@ class HaConfigRepairs extends LitElement {
:host {
--mdc-list-vertical-padding: 0;
}
.title {
font-size: var(--ha-font-size-l);
padding: 16px;
padding-bottom: 0;
}
.ignored {
opacity: var(--light-secondary-opacity);
}

View File

@@ -5,6 +5,7 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { EnergyData } from "../../../../data/energy";
import {
computeConsumptionData,
@@ -16,6 +17,7 @@ import {
import {
calculateStatisticSumGrowth,
getStatisticLabel,
isExternalStatistic,
} from "../../../../data/recorder";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../types";
@@ -286,6 +288,9 @@ class HuiEnergySankeyCard
color: getGraphColorByIndex(idx, computedStyle),
index: 4,
parent: device.included_in_stat,
entityId: isExternalStatistic(device.stat_consumption)
? undefined
: device.stat_consumption,
};
if (node.parent) {
parentLinks[node.id] = node.parent;
@@ -418,9 +423,11 @@ class HuiEnergySankeyCard
<div class="card-content">
${hasData
? html`<ha-sankey-chart
.hass=${this.hass}
.data=${{ nodes, links }}
.vertical=${vertical}
.valueFormatter=${this._valueFormatter}
@node-click=${this._handleNodeClick}
></ha-sankey-chart>`
: html`${this.hass.localize(
"ui.panel.lovelace.cards.energy.no_data_period"
@@ -435,6 +442,13 @@ class HuiEnergySankeyCard
${formatNumber(value, this.hass.locale, value < 0.1 ? { maximumFractionDigits: 3 } : undefined)}
kWh</div>`;
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
const { node } = ev.detail;
if (node.entityId) {
fireEvent(this, "hass-more-info", { entityId: node.entityId });
}
}
protected _groupByFloorAndArea(deviceNodes: Node[]) {
const areas: Record<string, { value: number; devices: Node[] }> = {
no_area: {

View File

@@ -565,6 +565,7 @@ class HuiPowerSankeyCard
<div class="card-content">
${hasData
? html`<ha-sankey-chart
.hass=${this.hass}
.data=${{ nodes, links }}
.vertical=${vertical}
.valueFormatter=${this._valueFormatter}

View File

@@ -5,6 +5,7 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { EnergyData } from "../../../../data/energy";
import {
getEnergyDataCollection,
@@ -13,6 +14,7 @@ import {
import {
calculateStatisticSumGrowth,
getStatisticLabel,
isExternalStatistic,
} from "../../../../data/recorder";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../types";
@@ -222,6 +224,9 @@ class HuiWaterSankeyCard
color: getGraphColorByIndex(idx, computedStyle),
index: 4,
parent: device.included_in_stat,
entityId: isExternalStatistic(device.stat_consumption)
? undefined
: device.stat_consumption,
};
if (node.parent) {
parentLinks[node.id] = node.parent;
@@ -354,9 +359,11 @@ class HuiWaterSankeyCard
<div class="card-content">
${hasData
? html`<ha-sankey-chart
.hass=${this.hass}
.data=${{ nodes, links }}
.vertical=${vertical}
.valueFormatter=${this._valueFormatter}
@node-click=${this._handleNodeClick}
></ha-sankey-chart>`
: html`${this.hass.localize(
"ui.panel.lovelace.cards.energy.no_data_period"
@@ -369,6 +376,13 @@ class HuiWaterSankeyCard
private _valueFormatter = (value: number) =>
`${formatNumber(value, this.hass.locale, value < 0.1 ? { maximumFractionDigits: 3 } : undefined)} ${this._data!.waterUnit}`;
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
const { node } = ev.detail;
if (node.entityId) {
fireEvent(this, "hass-more-info", { entityId: node.entityId });
}
}
protected _groupByFloorAndArea(deviceNodes: Node[]) {
const areas: Record<string, { value: number; devices: Node[] }> = {
no_area: {

View File

@@ -34,6 +34,7 @@ class EntityIdPickerRow extends LitElement {
>
<ha-switch
slot="end"
haptic
.checked=${!!this.coreUserData &&
!!this.coreUserData.showEntityIdPicker}
.disabled=${this.coreUserData === undefined}

View File

@@ -266,11 +266,6 @@ export const colorStyles = css`
--sidebar-selected-text-color: var(--primary-color);
--sidebar-selected-icon-color: var(--primary-color);
--sidebar-icon-color: rgba(var(--rgb-primary-text-color), 0.6);
--switch-checked-color: var(--primary-color);
--switch-checked-button-color: var(--switch-checked-color, var(--primary-background-color));
--switch-checked-track-color: var(--switch-checked-color, #000000);
--switch-unchecked-button-color: var(--switch-unchecked-color, var(--primary-background-color));
--switch-unchecked-track-color: var(--switch-unchecked-color, #000000);
--slider-color: var(--primary-color);
--slider-secondary-color: var(--light-primary-color);
--slider-track-color: var(--scrollbar-thumb-color);
@@ -352,8 +347,6 @@ export const darkColorStyles = css`
--primary-text-color: #e1e1e1;
--secondary-text-color: #9b9b9b;
--disabled-text-color: #6f6f6f;
--switch-unchecked-button-color: #999999;
--switch-unchecked-track-color: #9b9b9b;
--divider-color: rgba(225, 225, 225, 0.12);
--outline-color: rgba(225, 225, 225, 0.12);
--outline-hover-color: rgba(225, 225, 225, 0.24);

View File

@@ -1,3 +1,5 @@
import { isExternalAndroid } from "../data/external";
// 10 seconds gives the Android WebView download listener enough time
// to open the blob before it is revoked, while still freeing memory
// promptly. Revoking immediately would invalidate the URL before the
@@ -17,7 +19,7 @@ export const fileDownload = (href: string, filename = ""): void => {
if (href.startsWith("blob:")) {
// Revoke blob URLs after a delay on Android so the WebView download
// listener has time to fetch the blob before it becomes invalid.
if (window.externalApp) {
if (isExternalAndroid) {
setTimeout(() => URL.revokeObjectURL(href), BLOB_REVOKE_DELAY_MS);
} else {
URL.revokeObjectURL(href);

View File

@@ -1,11 +1,23 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fileDownload } from "../../src/util/file_download";
describe("fileDownload", () => {
let appendChildSpy: ReturnType<typeof vi.spyOn>;
let removeChildSpy: ReturnType<typeof vi.spyOn>;
let dispatchEventSpy: ReturnType<typeof vi.spyOn>;
let createdElement: HTMLAnchorElement;
let fileDownload: (href: string, filename?: string) => void;
/**
* Because isExternalAndroid is a module-level constant, we must set
* window.externalApp / externalAppV2 *before* importing the module.
* This helper resets the module registry and re-imports file_download
* so the constant is evaluated with the current window state.
*/
async function loadFileDownload() {
vi.resetModules();
const mod = await import("../../src/util/file_download");
fileDownload = mod.fileDownload;
}
beforeEach(() => {
vi.useFakeTimers();
@@ -25,9 +37,11 @@ describe("fileDownload", () => {
afterEach(() => {
vi.restoreAllMocks();
delete (window as any).externalApp;
delete (window as any).externalAppV2;
});
it("sets href, download, and triggers a click", () => {
it("sets href, download, and triggers a click", async () => {
await loadFileDownload();
fileDownload("https://example.com/file.json", "file.json");
expect(createdElement.href).toBe("https://example.com/file.json");
@@ -37,26 +51,42 @@ describe("fileDownload", () => {
expect(removeChildSpy).toHaveBeenCalledWith(createdElement);
});
it("defaults filename to empty string", () => {
it("defaults filename to empty string", async () => {
await loadFileDownload();
fileDownload("https://example.com/file.json");
expect(createdElement.download).toBe("");
});
it("does not revoke non-blob URLs", () => {
it("does not revoke non-blob URLs", async () => {
await loadFileDownload();
fileDownload("https://example.com/file.json", "file.json");
vi.runAllTimers();
expect(URL.revokeObjectURL).not.toHaveBeenCalled();
});
it("revokes blob URLs immediately outside Android", () => {
it("revokes blob URLs immediately outside Android", async () => {
await loadFileDownload();
fileDownload("blob:http://localhost/abc-123", "file.json");
expect(URL.revokeObjectURL).toHaveBeenCalledWith(
"blob:http://localhost/abc-123"
);
});
it("revokes blob URL after delay on Android", () => {
it("revokes blob URL after delay on Android (externalApp)", async () => {
(window as any).externalApp = {};
await loadFileDownload();
fileDownload("blob:http://localhost/abc-123", "file.json");
vi.advanceTimersByTime(9_999);
expect(URL.revokeObjectURL).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(URL.revokeObjectURL).toHaveBeenCalledWith(
"blob:http://localhost/abc-123"
);
});
it("revokes blob URL after delay on Android (externalAppV2)", async () => {
(window as any).externalAppV2 = { postMessage: vi.fn() };
await loadFileDownload();
fileDownload("blob:http://localhost/abc-123", "file.json");
vi.advanceTimersByTime(9_999);
expect(URL.revokeObjectURL).not.toHaveBeenCalled();

View File

@@ -18,29 +18,27 @@ __metadata:
languageName: node
linkType: hard
"@asamuzakjp/css-color@npm:^5.0.1":
version: 5.0.1
resolution: "@asamuzakjp/css-color@npm:5.0.1"
"@asamuzakjp/css-color@npm:^5.1.5":
version: 5.1.6
resolution: "@asamuzakjp/css-color@npm:5.1.6"
dependencies:
"@csstools/css-calc": "npm:^3.1.1"
"@csstools/css-color-parser": "npm:^4.0.2"
"@csstools/css-parser-algorithms": "npm:^4.0.0"
"@csstools/css-tokenizer": "npm:^4.0.0"
lru-cache: "npm:^11.2.6"
checksum: 10/941ee630cd037b35d1d95db03ea9e483958e0a444bde61b1f4a7f84a787df5abfa83be7b4dd33f742811c6f194aeedf81ce70fa5ca2713d5c9eeacac0930e64b
checksum: 10/5151369d9369e478e03c0eee0f171b8f86306ebbdf5b352544cd745c360d97343f437bdd0690ff658e47d2876b466bffc8811fcef7f0347cb243c6483a7e95a0
languageName: node
linkType: hard
"@asamuzakjp/dom-selector@npm:^7.0.3":
version: 7.0.3
resolution: "@asamuzakjp/dom-selector@npm:7.0.3"
"@asamuzakjp/dom-selector@npm:^7.0.6":
version: 7.0.7
resolution: "@asamuzakjp/dom-selector@npm:7.0.7"
dependencies:
"@asamuzakjp/nwsapi": "npm:^2.3.9"
bidi-js: "npm:^1.0.3"
css-tree: "npm:^3.2.1"
is-potential-custom-element-name: "npm:^1.0.1"
lru-cache: "npm:^11.2.7"
checksum: 10/2b030f912035426707efd0d6fe5bb4eda1cc4a1c5d5d0d90333d3bbc93719d795048b988ba00ce130d260254dcda5d95316f2b330c0245ee98d8faa7b345c349
checksum: 10/18f40def8c775c6008c8fcd75d7d049ff92d99a494929ab2bf742341b348c78cbf4808d29c13b9cd87ca4fd272773cf5aa9e58fee48603c286df48148be8cb67
languageName: node
linkType: hard
@@ -2266,10 +2264,10 @@ __metadata:
languageName: node
linkType: hard
"@lokalise/node-api@npm:15.6.1":
version: 15.6.1
resolution: "@lokalise/node-api@npm:15.6.1"
checksum: 10/0533046b1271b299d64d1eb9d7561a2d71e8dfead329c9f3fffa7141c069072ce975e4fe1935e5605b5fbbb9abb915b02e3150435b0d852541d7ae01d6ee0026
"@lokalise/node-api@npm:15.7.1":
version: 15.7.1
resolution: "@lokalise/node-api@npm:15.7.1"
checksum: 10/a950c379778de0a5da29eedfb9ed92d96b2a10b68818d96a982ad3be0a699ab0d386ef9600403c1195d164390b034e88c51d66e674c94fb5013ea0ba55e6a5cc
languageName: node
linkType: hard
@@ -8893,7 +8891,7 @@ __metadata:
"@lit-labs/virtualizer": "npm:2.1.1"
"@lit/context": "npm:1.1.6"
"@lit/reactive-element": "npm:2.1.2"
"@lokalise/node-api": "npm:15.6.1"
"@lokalise/node-api": "npm:15.7.1"
"@material/chips": "npm:=14.0.0-canary.53b3cad2f.0"
"@material/data-table": "npm:=14.0.0-canary.53b3cad2f.0"
"@material/mwc-base": "npm:0.27.0"
@@ -8986,7 +8984,7 @@ __metadata:
idb-keyval: "npm:6.2.2"
intl-messageformat: "npm:11.2.0"
js-yaml: "npm:4.1.1"
jsdom: "npm:29.0.1"
jsdom: "npm:29.0.2"
jszip: "npm:3.10.1"
leaflet: "npm:1.9.4"
leaflet-draw: "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch"
@@ -8999,7 +8997,7 @@ __metadata:
lodash.template: "npm:4.5.0"
luxon: "npm:3.7.2"
map-stream: "npm:0.0.7"
marked: "npm:17.0.6"
marked: "npm:18.0.0"
memoize-one: "npm:6.0.0"
node-vibrant: "npm:4.0.4"
object-hash: "npm:3.0.0"
@@ -10032,12 +10030,12 @@ __metadata:
languageName: node
linkType: hard
"jsdom@npm:29.0.1":
version: 29.0.1
resolution: "jsdom@npm:29.0.1"
"jsdom@npm:29.0.2":
version: 29.0.2
resolution: "jsdom@npm:29.0.2"
dependencies:
"@asamuzakjp/css-color": "npm:^5.0.1"
"@asamuzakjp/dom-selector": "npm:^7.0.3"
"@asamuzakjp/css-color": "npm:^5.1.5"
"@asamuzakjp/dom-selector": "npm:^7.0.6"
"@bramus/specificity": "npm:^2.4.2"
"@csstools/css-syntax-patches-for-csstree": "npm:^1.1.1"
"@exodus/bytes": "npm:^1.15.0"
@@ -10062,7 +10060,7 @@ __metadata:
peerDependenciesMeta:
canvas:
optional: true
checksum: 10/93d6b4e17f8ed6428beab09aecb43737325be658b58f474df4fcc46ef391826e98b3fbf30c51cd5ead5d05de2c85a932be87fdeada064adb5617322e0c8476ea
checksum: 10/3ad1d9a5b6aba067427bc43be98e1c51fab489bf689a6530e596278c6326fe053c94fc47a9c133f126fbe914f421283ae723fb92214dfe4959ca6cf2ee1666f6
languageName: node
linkType: hard
@@ -10597,7 +10595,7 @@ __metadata:
languageName: node
linkType: hard
"lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1, lru-cache@npm:^11.2.6, lru-cache@npm:^11.2.7":
"lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1, lru-cache@npm:^11.2.7":
version: 11.2.7
resolution: "lru-cache@npm:11.2.7"
checksum: 10/fbff4b8dee8189dde9b52cdfb3ea89b4c9cec094c1538cd30d1f47299477ff312efdb35f7994477ec72328f8e754e232b26a143feda1bd1f79ff22da6664d2c5
@@ -10691,12 +10689,12 @@ __metadata:
languageName: node
linkType: hard
"marked@npm:17.0.6":
version: 17.0.6
resolution: "marked@npm:17.0.6"
"marked@npm:18.0.0":
version: 18.0.0
resolution: "marked@npm:18.0.0"
bin:
marked: bin/marked.js
checksum: 10/46dac9481c028b6ab36f093084842f5c020329eb5529fa96ed22112f1f3a79b51b2f2c169a8b104b28c3ed09842ef372260dee08946fb1f7b1c70a7fb6f5cdc0
checksum: 10/eb746a1f6e9b570ccc174cbf339504a432681bb76a1419d8b8b036c487235a55cbf94f4fa2c1e3c347a1d3f5f3666d4395bb74ea4ea4960c89b66d3a948c96d0
languageName: node
linkType: hard