Compare commits

..
Author SHA1 Message Date
Maarten Lakerveld 91549f554e Add the color temp brightness picker gallery page to the e2e smoke list
The gallery smoke test only covers pages registered here, so the new
picker page was not exercised in CI.
2026-10-12 21:36:32 +02:00
Maarten Lakerveld 247d560aa0 Show dual-axis view for color lights that support color temperature 2026-10-12 21:36:32 +02:00
Maarten Lakerveld e15861a8fb Add dual-axis temperature/brightness view to light more-info dialog 2026-10-12 21:36:32 +02:00
Maarten Lakerveld 67376887b9 Add ha-color-temp-brightness-picker 2D pad component with gallery page 2026-10-12 14:17:17 +02:00
496 changed files with 4959 additions and 17444 deletions

No files matched your search

+1 -1
View File
@@ -58,7 +58,7 @@ Readiness and invalidation:
Repository-owned contracts:
- Consult the public [frontend contributor documentation](https://contributors.home-assistant.io/frontend/) for documented architecture, data flow, design, and development workflows.
- Consult the public [frontend developer documentation](https://developers.home-assistant.io/docs/frontend/) for documented architecture, data flow, design, and development workflows.
- For new leaf components, load `ha-frontend-contexts` and verify they consume narrow contexts instead of introducing a broad `hass` property; containers and external APIs may still require `hass`.
- Verify backend assumptions against the owning Core, Supervisor, or WebSocket implementation, and component assumptions against the exported component contract.
- Prefer canonical repository helpers and test setup over duplicate local implementations.
+1 -1
View File
@@ -105,4 +105,4 @@ To help with the load of incoming pull requests:
Below, some useful links you could explore:
-->
[docs-repository]: https://github.com/home-assistant/home-assistant.io
[perfect-pr]: https://contributors.home-assistant.io/start/pull-requests/#before-you-open-it
[perfect-pr]: https://developers.home-assistant.io/docs/review-process/#creating-the-perfect-pr
@@ -192,8 +192,8 @@ export default async function checkPullRequestStandards({
`\n\n` +
`Please complete the ` +
`[PR template](https://github.com/home-assistant/frontend/blob/dev/.github/PULL_REQUEST_TEMPLATE.md?plain=1) ` +
`and see the [contributor docs](https://contributors.home-assistant.io/start/pull-requests/#fill-out-the-template) ` +
`for more on creating a great pull request.`;
`and see the [developer docs](https://developers.home-assistant.io/docs/review-process) ` +
`for more on creating a great pull request (see point 6).`;
if (existing) {
await withRetry("comment update", () =>
+2 -2
View File
@@ -134,7 +134,7 @@ jobs:
is-test: true
rspack-cache: readonly
- name: Upload bundle stats
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend-bundle-stats
path: build/stats/*.json
@@ -142,7 +142,7 @@ jobs:
- name: Check entrypoint bundle size budget
run: pnpm run check-bundlesize
- name: Upload frontend build
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend-build
path: hass_frontend/
+2 -2
View File
@@ -32,12 +32,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
category: "/language:javascript-typescript"
+17 -17
View File
@@ -38,7 +38,7 @@ jobs:
name: Prepare container dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.64.0-noble
options: --user 1001
defaults:
run:
@@ -78,7 +78,7 @@ jobs:
is-test: true
- name: Upload demo build
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: demo-dist
path: demo/dist/
@@ -109,7 +109,7 @@ jobs:
is-test: true
- name: Upload e2e test app build
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
@@ -140,7 +140,7 @@ jobs:
is-test: true
- name: Upload gallery build
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: gallery-dist
path: gallery/dist/
@@ -155,7 +155,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.64.0-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -182,7 +182,7 @@ jobs:
node-modules-cache-key: node-modules-container-v1
- name: Download demo build
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: demo-dist
path: demo/dist/
@@ -192,7 +192,7 @@ jobs:
timeout-minutes: 15
- name: Upload demo blob report
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: blob-report-demo-${{ matrix.shardIndex }}
@@ -207,7 +207,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.64.0-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -236,7 +236,7 @@ jobs:
node-modules-cache-key: node-modules-container-v1
- name: Download e2e test app build
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
@@ -246,7 +246,7 @@ jobs:
timeout-minutes: 15
- name: Upload app blob report
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: blob-report-app-${{ matrix.shardIndex }}
@@ -261,7 +261,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.64.0-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -290,7 +290,7 @@ jobs:
node-modules-cache-key: node-modules-container-v1
- name: Download gallery build
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: gallery-dist
path: gallery/dist/
@@ -300,7 +300,7 @@ jobs:
timeout-minutes: 15
- name: Upload gallery blob report
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: blob-report-gallery-${{ matrix.shardIndex }}
@@ -331,21 +331,21 @@ jobs:
node-modules-cache: true
- name: Download demo blob reports
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
continue-on-error: true
with:
pattern: blob-report-demo-*
path: test/e2e/reports/demo/
- name: Download app blob reports
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
continue-on-error: true
with:
pattern: blob-report-app-*
path: test/e2e/reports/app/
- name: Download gallery blob reports
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
continue-on-error: true
with:
pattern: blob-report-gallery-*
@@ -359,7 +359,7 @@ jobs:
- name: Upload merged HTML report
id: upload-report
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
path: test/e2e/reports/combined/
+2 -2
View File
@@ -104,7 +104,7 @@ jobs:
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
- name: Upload build artifacts
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: wheels
path: dist/home_assistant_frontend*.whl
@@ -137,7 +137,7 @@ jobs:
run: tar -czvf translations.tar.gz translations
- name: Upload translations
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: translations
path: translations.tar.gz
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@cd6f3635a740c84d8a14f6121a90068b8ece6bf6 # master
uses: home-assistant/actions/helpers/verify-version@06749dd8c0b54f350bc69c8752456cee498808a3 # master
- name: Setup Node and install
uses: ./.github/actions/setup
+2 -3
View File
@@ -15,12 +15,11 @@ jobs:
if: github.repository == 'home-assistant/frontend'
runs-on: ubuntu-latest
steps:
- name: Stale policy
- name: 90 days stale policy
uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-issue-stale: 90
days-before-pr-stale: 60
days-before-stale: 90
days-before-close: 7
operations-per-run: 25
remove-stale-when-updated: true
+1 -1
View File
@@ -1 +1 @@
24.22.0
24.21.0
+1 -1
View File
@@ -41,5 +41,5 @@ Contributions that do not follow this policy will be closed. Repeated violations
---
The canonical version of this policy is published at
<https://contributors.home-assistant.io/start/ai-policy/>. In case of differences,
<https://developers.home-assistant.io/docs/ai_policy>. In case of differences,
the published version applies.
+2 -2
View File
@@ -6,12 +6,12 @@ This is the repository for the official [Home Assistant](https://home-assistant.
- [View demo of Home Assistant](https://demo.home-assistant.io/)
- [More information about Home Assistant](https://home-assistant.io)
- [Frontend development instructions](https://contributors.home-assistant.io/frontend/development/)
- [Frontend development instructions](https://developers.home-assistant.io/docs/frontend/development/)
## Development
- Initial setup: `script/setup`
- Development: [Instructions](https://contributors.home-assistant.io/frontend/development/)
- Development: [Instructions](https://developers.home-assistant.io/docs/frontend/development/)
- Production build: `pnpm build`
- Gallery: `cd gallery && script/develop_gallery`
+1 -4
View File
@@ -115,10 +115,7 @@ const createRspackConfig = ({
dontHash = new Set();
}
const ignorePackages = bundle.ignorePackages({ latestBuild });
// pnpm links node_modules/lit-html into its store; rspack compares real paths
const litHtmlRoot = fs.realpathSync(
path.resolve(__dirname, "../node_modules/lit-html")
);
const litHtmlRoot = path.resolve(__dirname, "../node_modules/lit-html");
const litHtmlDevelopmentRoot = path.join(litHtmlRoot, "development");
const litDisableDevModeLoader = path.join(
__dirname,
+1 -1
View File
@@ -4,7 +4,7 @@ import { castContext } from "../cast_context";
export const castDemoLovelace: () => LovelaceConfig = () => {
const touchSupported =
castContext.getDeviceCapabilities()?.touch_input_supported;
castContext.getDeviceCapabilities().touch_input_supported;
return {
views: [
{
+2 -2
View File
@@ -19,7 +19,7 @@ const playerStylesAdded = false;
let controls: HTMLElement | null;
const setTouchControlsVisibility = (visible: boolean) => {
if (!castContext.getDeviceCapabilities()?.touch_input_supported) {
if (!castContext.getDeviceCapabilities().touch_input_supported) {
return;
}
controls =
@@ -51,7 +51,7 @@ const playDummyMedia = (viewTitle?: string) => {
clearTimeout(timeOut);
timeOut = undefined;
}
if (castContext.getDeviceCapabilities()?.touch_input_supported) {
if (castContext.getDeviceCapabilities().touch_input_supported) {
timeOut = window.setTimeout(() => playDummyMedia(viewTitle), 540000); // repeat every 9 minutes to keep it active (gets deactivated after 10 minutes)
}
};
+2 -2
View File
@@ -27,7 +27,6 @@ import type {
LovelaceConfig,
LovelaceDashboardStrategyConfig,
} from "../../../../src/data/lovelace/config/types";
import { normalizeLovelaceConfig } from "../../../../src/data/lovelace/config/normalize";
import { isStrategyDashboard } from "../../../../src/data/lovelace/config/types";
import { fetchResources } from "../../../../src/data/lovelace/resource";
import { loadLovelaceResources } from "../../../../src/panels/lovelace/common/load-resources";
@@ -35,6 +34,7 @@ import { HassElement } from "../../../../src/state/hass-element";
import { castContext } from "../cast_context";
import "./hc-launch-screen";
import { getPanelTitleFromUrlPath } from "../../../../src/data/panel";
import { checkLovelaceConfig } from "../../../../src/panels/lovelace/common/check-lovelace-config";
const DEFAULT_CONFIG: LovelaceDashboardStrategyConfig = {
strategy: {
@@ -359,7 +359,7 @@ export class HcMain extends HassElement {
this._urlPath || "lovelace"
);
castContext.setApplicationState(title || "");
this._lovelaceConfig = normalizeLovelaceConfig(
this._lovelaceConfig = checkLovelaceConfig(
lovelaceConfig
) as LovelaceConfig;
}
+2 -68
View File
@@ -142,45 +142,7 @@ export class HaDemo extends HomeAssistantAppEl {
(localize?: LocalizeFunc) => localize ?? this.hass!.localize
);
mockLovelace(hass, localizePromise, [
{
id: "music",
url_path: "music",
require_admin: false,
show_in_sidebar: true,
icon: "mdi:music",
title: "Music",
mode: "storage",
},
{
id: "cameras",
url_path: "cameras",
require_admin: false,
show_in_sidebar: true,
icon: "mdi:cctv",
title: "Cameras",
mode: "storage",
},
{
id: "garden",
url_path: "garden",
require_admin: false,
show_in_sidebar: true,
icon: "mdi:flower",
title: "Garden",
mode: "storage",
},
{
id: "lovelace-advanced",
url_path: "lovelace-advanced",
require_admin: false,
show_in_sidebar: false,
icon: "mdi:cog",
title: "Advanced",
mode: "yaml",
filename: "lovelace-advanced.yaml",
},
]);
mockLovelace(hass, localizePromise);
mockAuth(hass);
mockTranslations(hass, localizePromise);
mockHistory(hass);
@@ -210,35 +172,7 @@ export class HaDemo extends HomeAssistantAppEl {
mockAreaRegistry(hass);
mockDeviceRegistry(hass, demoDevices);
mockFloorRegistry(hass);
mockLabelRegistry(hass, [
{
label_id: "kitchen",
name: "Kitchen",
icon: null,
color: "red",
description: "Everything in the kitchen",
created_at: 1735689600,
modified_at: 1735689600,
},
{
label_id: "garden",
name: "Garden",
icon: "mdi:flower",
color: "green",
description: null,
created_at: 1735689600,
modified_at: 1735689600,
},
{
label_id: "battery-powered",
name: "Battery powered",
icon: null,
color: "orange",
description: "Runs on batteries",
created_at: 1735689600,
modified_at: 1735689600,
},
]);
mockLabelRegistry(hass);
mockUsagePrediction(hass);
mockEntityRegistryDisplay(hass);
mockEntityRegistry(hass, [
-3
View File
@@ -39,9 +39,6 @@
html {
background-color: var(--primary-background-color, #fafafa);
color: var(--primary-text-color, #212121);
/* Prevent iOS Safari from inflating text when rotating to landscape */
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
@media (prefers-color-scheme: dark) {
html {
-1
View File
@@ -39,7 +39,6 @@ const backupInfo: BackupInfo = {
},
],
agent_errors: {},
agent_error_translations: {},
last_attempted_automatic_backup: recent,
last_completed_automatic_backup: recent,
last_action_event: { manager_state: "idle" },
-8
View File
@@ -42,12 +42,4 @@ export const mockBlueprint = (hass: MockHomeAssistant) => {
hass.mockWS("blueprint/list", (msg: { domain: BlueprintDomain }) =>
msg.domain === "script" ? scriptBlueprints : automationBlueprints
);
hass.mockWS(
"blueprint/delete",
(msg: { domain: BlueprintDomain; path: string }) => {
const blueprints =
msg.domain === "script" ? scriptBlueprints : automationBlueprints;
delete blueprints[msg.path];
}
);
};
-2
View File
@@ -1,4 +1,3 @@
import { lorawanFixtures } from "./lorawan/fixtures";
import { bluetoothFixtures } from "./bluetooth/fixtures";
import { infraredFixtures } from "./infrared/fixtures";
import { matterFixtures } from "./matter/fixtures";
@@ -16,7 +15,6 @@ import { zwaveJsFixtures } from "./zwave_js/fixtures";
// data to mock. Each owns its own fixtures, so they can be added and removed
// one at a time.
const INTEGRATIONS: ConnectivityFixtures[] = [
lorawanFixtures,
bluetoothFixtures,
serialFixtures,
modbusFixtures,
-2
View File
@@ -1,4 +1,3 @@
import { mockLoRaWAN } from "./lorawan/mock";
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
import { mockBluetooth } from "./bluetooth/mock";
import { mockMatter } from "./matter/mock";
@@ -12,7 +11,6 @@ import { mockZwaveJs } from "./zwave_js/mock";
// The WebSocket mocks, code-split into the config panel chunk.
const MOCKS = [
mockLoRaWAN,
mockBluetooth,
mockSerial,
mockModbus,
@@ -1,135 +0,0 @@
import { manifest } from "../../manifest";
import {
configEntry,
device,
registryEntry,
withRegistryLinks,
} from "../helpers";
import type { ConnectivityFixtures } from "../types";
export const CHIRPSTACK_ENTRY = "mock-lorawan-chirpstack";
export const TTN_ENTRY = "mock-lorawan-ttn";
export const SENSECAP_ENTRY = "mock-lorawan-sensecap";
const REGISTRY_ENTRIES = [
registryEntry(
"sensor.lorawan_greenhouse_temperature",
"lorawan-greenhouse",
SENSECAP_ENTRY,
"sensecap_lorawan",
"Temperature"
),
registryEntry(
"sensor.lorawan_greenhouse_humidity",
"lorawan-greenhouse",
SENSECAP_ENTRY,
"sensecap_lorawan",
"Humidity"
),
registryEntry(
"sensor.lorawan_study_carbon_dioxide",
"lorawan-study",
SENSECAP_ENTRY,
"sensecap_lorawan",
"Carbon dioxide"
),
];
export const lorawanFixtures: ConnectivityFixtures = {
components: [
"lorawan",
"sensecap_lorawan",
"chirpstack",
"thethingsnetwork_lorawan",
],
commands: ["lorawan/"],
manifests: [
manifest("lorawan", "LoRaWAN", { integration_type: "helper" }),
manifest("sensecap_lorawan", "SenseCAP", {
integration_type: "device",
dependencies: ["lorawan"],
}),
manifest("chirpstack", "ChirpStack", { integration_type: "hub" }),
manifest("thethingsnetwork_lorawan", "The Things Network", {
integration_type: "hub",
}),
],
configEntries: [
{
type: "hub",
entry: configEntry(CHIRPSTACK_ENTRY, "chirpstack", "Home ChirpStack"),
},
{
type: "hub",
entry: configEntry(
TTN_ENTRY,
"thethingsnetwork_lorawan",
"The Things Network"
),
},
{
type: "device",
entry: configEntry(SENSECAP_ENTRY, "sensecap_lorawan", "SenseCAP"),
},
],
devices: [
device(
"lorawan-greenhouse",
"Greenhouse",
"Seeed Studio",
"S2101",
SENSECAP_ENTRY,
{
config_entries: [SENSECAP_ENTRY, CHIRPSTACK_ENTRY],
identifiers: [
["sensecap_lorawan", `${CHIRPSTACK_ENTRY}:0000000000000001`],
],
}
),
device(
"lorawan-study",
"Study air quality",
"Seeed Studio",
"S2103",
SENSECAP_ENTRY,
{
config_entries: [SENSECAP_ENTRY, TTN_ENTRY],
identifiers: [["sensecap_lorawan", `${TTN_ENTRY}:0000000000000002`]],
}
),
],
entityRegistryEntries: REGISTRY_ENTRIES,
entities: () =>
withRegistryLinks(REGISTRY_ENTRIES, {
"sensor.lorawan_greenhouse_temperature": {
entity_id: "sensor.lorawan_greenhouse_temperature",
state: "24.3",
attributes: {
friendly_name: "Greenhouse temperature",
device_class: "temperature",
state_class: "measurement",
unit_of_measurement: "°C",
},
},
"sensor.lorawan_greenhouse_humidity": {
entity_id: "sensor.lorawan_greenhouse_humidity",
state: "58",
attributes: {
friendly_name: "Greenhouse humidity",
device_class: "humidity",
state_class: "measurement",
unit_of_measurement: "%",
},
},
"sensor.lorawan_study_carbon_dioxide": {
entity_id: "sensor.lorawan_study_carbon_dioxide",
state: "652",
attributes: {
friendly_name: "Study carbon dioxide",
device_class: "carbon_dioxide",
state_class: "measurement",
unit_of_measurement: "ppm",
},
},
}),
};
-124
View File
@@ -1,124 +0,0 @@
import type { LoRaWANDevice } from "../../../../../src/data/lorawan";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import { minutesAgo } from "../helpers";
import { emitInitial } from "../subscription";
import { CHIRPSTACK_ENTRY, TTN_ENTRY } from "./fixtures";
const devices = (): LoRaWANDevice[] => {
const base: LoRaWANDevice = {
dev_eui: "0000000000000001",
name: "Greenhouse",
manufacturer: "Seeed Studio",
model: "S2101",
application_id: "home-sensors",
device_id: "lorawan-greenhouse",
connection: {
entry_id: CHIRPSTACK_ENTRY,
domain: "chirpstack",
title: "Home ChirpStack",
configuration_url: "https://www.chirpstack.io/",
},
support: "supported",
last_uplink: minutesAgo(2),
last_join: minutesAgo(2400),
last_status: minutesAgo(64),
receptions: [{ gateway_id: "home-indoor", rssi: -67, snr: 9.5 }],
battery_level: 87,
external_power_source: false,
downlink_margin: 12,
};
return [
base,
{
...base,
dev_eui: "0000000000000002",
name: "Study air quality",
model: "S2103",
device_id: "lorawan-study",
application_id: "demo-home",
connection: {
entry_id: TTN_ENTRY,
domain: "thethingsnetwork_lorawan",
title: "The Things Network",
configuration_url: "https://eu1.cloud.thethings.network/console",
},
receptions: [
{ gateway_id: "brooklyn-rooftop", rssi: -103, snr: -2.5 },
{ gateway_id: "brooklyn-community", rssi: -110, snr: -5.2 },
],
battery_level: 64,
last_uplink: minutesAgo(4),
last_status: minutesAgo(140),
},
{
...base,
dev_eui: "24E124128E123456",
name: "Irrigation controller",
manufacturer: "Milesight",
model: "UC511",
device_id: null,
support: "integration_not_configured",
battery_level: 93,
last_uplink: minutesAgo(7),
receptions: [{ gateway_id: "home-indoor", rssi: -83, snr: 6.8 }],
},
{
...base,
dev_eui: "A84041FFFF123456",
name: "Garage power meter",
manufacturer: "",
model: "",
device_id: null,
support: "missing_identity",
battery_level: null,
external_power_source: true,
last_uplink: minutesAgo(1),
receptions: [{ gateway_id: "home-indoor", rssi: -75, snr: 8.1 }],
},
{
...base,
dev_eui: "2CF7F1C071500199",
name: "New soil sensor",
model: "S2105",
device_id: null,
support: "unsupported_model",
last_uplink: null,
last_join: null,
last_status: null,
receptions: [],
battery_level: null,
external_power_source: null,
downlink_margin: null,
},
{
...base,
dev_eui: "2CF7F1C071500166",
name: "Garden shed",
device_id: null,
support: "integration_not_configured",
battery_level: 18,
last_uplink: minutesAgo(180),
last_status: minutesAgo(600),
receptions: [{ gateway_id: "home-indoor", rssi: -112, snr: -7.5 }],
},
];
};
export const mockLoRaWAN = (hass: MockHomeAssistant) => {
const deviceList = devices();
const snapshot = {
devices: deviceList,
connections: [
...new Map(
deviceList.map((device) => [
device.connection.entry_id,
device.connection,
])
).values(),
],
};
hass.mockWS("lorawan/devices/list", () => snapshot);
hass.mockWS("lorawan/devices/subscribe", (_msg, _hass, onChange) =>
emitInitial(() => onChange?.(snapshot))
);
};
-33
View File
@@ -190,39 +190,6 @@ export const removeEntityRegistryEntry = (
hass.mockEvent("entity_registry_updated");
};
export const removeLabelFromEntityRegistry = (
hass: MockHomeAssistant,
labelId: string
) => {
const withoutLabel = (labels: string[]) =>
labels.filter((id) => id !== labelId);
for (const [key, entry] of retiredEntries) {
if (entry.labels.includes(labelId)) {
retiredEntries.set(key, { ...entry, labels: withoutLabel(entry.labels) });
}
}
if (!entries.some((entry) => entry.labels.includes(labelId))) {
return;
}
entries = entries.map((entry) =>
entry.labels.includes(labelId)
? { ...entry, labels: withoutLabel(entry.labels) }
: entry
);
const entities = { ...hass.entities };
for (const entry of entries) {
const entity = entities[entry.entity_id];
if (entity?.labels.includes(labelId)) {
entities[entry.entity_id] = {
...entity,
labels: withoutLabel(entity.labels),
};
}
}
hass.updateHass({ entities });
hass.mockEvent("entity_registry_updated");
};
// Adds an entry like core does when an integration creates an entity
export const addEntityRegistryEntry = (
hass: MockHomeAssistant,
+2 -19
View File
@@ -1,6 +1,5 @@
import type { LabelRegistryEntry } from "../../../src/data/label/label_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { removeLabelFromEntityRegistry } from "./entity_registry";
let labels: LabelRegistryEntry[] = [];
@@ -10,22 +9,6 @@ export const mockLabelRegistry = (
hass: MockHomeAssistant,
data: LabelRegistryEntry[] = []
) => {
labels = [...data];
hass.mockWS("config/label_registry/list", () =>
labels.map((label) => ({ ...label }))
);
hass.mockWS(
"config/label_registry/delete",
(msg: { label_id: string }, currentHass: MockHomeAssistant) => {
const index = labels.findIndex(
(label) => label.label_id === msg.label_id
);
if (index === -1) {
throw new Error(`Label ${msg.label_id} not found`);
}
labels.splice(index, 1);
removeLabelFromEntityRegistry(currentHass, msg.label_id);
hass.mockEvent("label_registry_updated");
}
);
labels = data;
hass.mockWS("config/label_registry/list", () => data);
};
+2 -16
View File
@@ -1,5 +1,4 @@
import type { LocalizeFunc } from "../../../src/common/translations/localize";
import type { LovelaceDashboard } from "../../../src/data/lovelace/dashboard";
import type { LovelaceInfo } from "../../../src/data/lovelace/resource";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import {
@@ -12,14 +11,10 @@ import "../custom-cards/ha-demo-card";
import "../custom-cards/ha-demo-next-card";
import { mockMap } from "./map";
let dashboards: LovelaceDashboard[] = [];
export const mockLovelace = (
hass: MockHomeAssistant,
localizePromise: Promise<LocalizeFunc>,
data: LovelaceDashboard[] = []
localizePromise: Promise<LocalizeFunc>
) => {
dashboards = data;
hass.mockWS("lovelace/config", ({ url_path }) => {
if (url_path === "map") {
mockMap(hass);
@@ -39,16 +34,7 @@ export const mockLovelace = (
);
hass.mockWS("lovelace/config/save", () => Promise.resolve());
hass.mockWS("lovelace/resources", () => Promise.resolve([]));
hass.mockWS("lovelace/dashboards/list", () =>
dashboards.map((dashboard) => ({ ...dashboard }))
);
hass.mockWS("lovelace/dashboards/delete", (msg: { dashboard_id: string }) => {
const index = dashboards.findIndex((d) => d.id === msg.dashboard_id);
if (index === -1) {
throw new Error(`Dashboard ${msg.dashboard_id} not found`);
}
dashboards.splice(index, 1);
});
hass.mockWS("lovelace/dashboards/list", () => Promise.resolve([]));
};
customElements.whenDefined("hui-root").then(() => {
+1 -8
View File
@@ -221,14 +221,7 @@ const personEntity = (person: DemoPerson, now: number): EntityInput => {
const personHistory = (person: DemoPerson, now: number): EntityHistoryState[] =>
person.timeline.map(([hoursAgo, state]) => {
const time = (now - hoursAgo * HOUR) / 1000;
const zoneId =
state === "home" ? "home" : zones.find((zone) => zone.name === state)?.id;
return {
s: state,
a: { in_zones: zoneId ? [`zone.${zoneId}`] : [] },
lu: time,
lc: time,
};
return { s: state, a: {}, lu: time, lc: time };
});
export const mockMap = (hass: MockHomeAssistant) => {
+18 -36
View File
@@ -1,10 +1,8 @@
import { TZDate } from "@date-fns/tz";
import {
addDays,
addHours,
addMinutes,
addMonths,
constructFrom,
differenceInHours,
endOfDay,
} from "date-fns";
@@ -15,18 +13,6 @@ import type {
} from "../../../src/data/recorder";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const getFirstDate = (
start: Date,
period: "5minute" | "hour" | "day" | "month"
): Date => {
if (period === "day" || period === "month") {
return constructFrom(start, start);
}
// Core returns UTC-aligned buckets whose start is at or after the request.
const interval = (period === "hour" ? 60 : 5) * 60 * 1000;
return constructFrom(start, Math.ceil(start.getTime() / interval) * interval);
};
const getNextDate = (
currentDate: Date,
period: "5minute" | "hour" | "day" | "month"
@@ -47,7 +33,8 @@ const generateMeanStatistics = (
maxDiff: number
): StatisticValue[] => {
const statistics: StatisticValue[] = [];
let currentDate = getFirstDate(start, period);
let currentDate = new Date(start);
currentDate.setMinutes(0, 0, 0);
const now = new Date();
while (end > currentDate && currentDate < now) {
const delta = Math.random() * maxDiff;
@@ -76,7 +63,8 @@ const generateSumStatistics = (
maxDiff: number | ((start: Date, end: Date) => number)
): StatisticValue[] => {
const statistics: StatisticValue[] = [];
let currentDate = getFirstDate(start, period);
let currentDate = new Date(start);
currentDate.setMinutes(0, 0, 0);
let sum = initValue;
const now = new Date();
while (end > currentDate && currentDate < now) {
@@ -110,7 +98,8 @@ const generateCurvedStatistics = (
metered: boolean
): StatisticValue[] => {
const statistics: StatisticValue[] = [];
let currentDate = getFirstDate(start, period);
let currentDate = new Date(start);
currentDate.setMinutes(0, 0, 0);
let sum = initValue;
const hours = differenceInHours(end, start) - 1;
let i = 0;
@@ -173,9 +162,9 @@ const statisticsFunctions: Record<
period === "day" ? 17 : 504
);
}
const morningEnd = addHours(start, 10);
const morningEnd = new Date(start.getTime() + 10 * 60 * 60 * 1000);
const morningLow = generateSumStatistics(start, morningEnd, period, 0, 0.7);
const eveningStart = addHours(start, 20);
const eveningStart = new Date(start.getTime() + 20 * 60 * 60 * 1000);
const morningFinalVal = morningLow.length
? morningLow[morningLow.length - 1].sum!
: 0;
@@ -210,8 +199,8 @@ const statisticsFunctions: Record<
period === "day" ? 17 : 504
);
}
const morningEnd = addHours(start, 9);
const eveningStart = addHours(start, 20);
const morningEnd = new Date(start.getTime() + 9 * 60 * 60 * 1000);
const eveningStart = new Date(start.getTime() + 20 * 60 * 60 * 1000);
const highTarif = generateSumStatistics(
morningEnd,
eveningStart,
@@ -250,12 +239,9 @@ const statisticsFunctions: Record<
period === "day" ? 17 : 504
);
}
const productionStart = addHours(start, 9);
const productionEnd = addHours(start, 21);
const dayEnd = constructFrom(
end,
Math.min(endOfDay(productionEnd).getTime(), end.getTime())
);
const productionStart = new Date(start.getTime() + 9 * 60 * 60 * 1000);
const productionEnd = new Date(start.getTime() + 21 * 60 * 60 * 1000);
const dayEnd = new Date(endOfDay(productionEnd));
const production = generateCurvedStatistics(
productionStart,
productionEnd,
@@ -294,12 +280,9 @@ const statisticsFunctions: Record<
period === "day" ? 17 : 504
);
}
const productionStart = addHours(start, 7);
const productionEnd = addHours(start, 23);
const dayEnd = constructFrom(
end,
Math.min(endOfDay(productionEnd).getTime(), end.getTime())
);
const productionStart = new Date(start.getTime() + 7 * 60 * 60 * 1000);
const productionEnd = new Date(start.getTime() + 23 * 60 * 60 * 1000);
const dayEnd = new Date(endOfDay(productionEnd));
const production = generateCurvedStatistics(
productionStart,
productionEnd,
@@ -346,9 +329,8 @@ export const mockRecorder = (mockHass: MockHomeAssistant) => {
mockHass.mockWS(
"recorder/statistics_during_period",
({ statistic_ids, start_time, end_time, period }, hass): Statistics => {
// Calendar buckets follow the server timezone, independent of the browser.
const start = new TZDate(start_time, hass.config.time_zone);
const end = new TZDate(end_time ?? Date.now(), hass.config.time_zone);
const start = new Date(start_time);
const end = end_time ? new Date(end_time) : new Date();
const statistics: Record<string, StatisticValue[]> = {};
const cache: Record<string, StatisticValue[]> = {};
+1
View File
@@ -59,6 +59,7 @@ export default [
"ha-control-select",
"ha-control-select-menu",
"ha-hs-color-picker",
"ha-color-temp-brightness-picker",
],
},
{
+4 -1
View File
@@ -6,10 +6,12 @@ import "../../../src/components/ha-settings-row";
import "../../../src/components/ha-switch";
import type { HaSwitch } from "../../../src/components/ha-switch";
import "../../../src/components/ha-theme-settings";
import type { ThemeSettings } from "../../../src/types";
import type { HomeAssistant, ThemeSettings } from "../../../src/types";
@customElement("gallery-settings")
class GallerySettings extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public themeSettings!: ThemeSettings;
@property({ type: Boolean }) public narrow = false;
@@ -24,6 +26,7 @@ class GallerySettings extends LitElement {
Configure how the gallery renders component previews and examples.
</div>
<ha-theme-settings
.hass=${this.hass}
.selectedTheme=${this.themeSettings}
.narrow=${this.narrow}
.heading=${"Theme"}
-10
View File
@@ -151,16 +151,6 @@ export const createMediaPlayerEntities = () => [
supported_features: 289,
},
},
{
entity_id: "media_player.theater_off_assumed",
state: "off",
attributes: {
friendly_name: "TV Off (assumed state, cannot be switched on)",
assumed_state: true,
// Off + Next + Pause
supported_features: 289,
},
},
{
entity_id: "media_player.theater_on_static",
state: "on",
+1
View File
@@ -242,6 +242,7 @@ class HaGallery extends LitElement {
${
isSettingsPage
? html`<gallery-settings
.hass=${this._galleryHass}
.themeSettings=${this._themeSettings}
.narrow=${this._narrow}
.rtl=${this._rtl}
-5
View File
@@ -12,11 +12,6 @@
<script type="module" src="<%= entry %>"></script>
<% } %>
<style>
html {
/* Prevent iOS Safari from inflating text when rotating to landscape */
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
font-family: Roboto, Noto, sans-serif;
margin: 0;
@@ -9,6 +9,7 @@ import "../../../../src/components/ha-form/ha-form";
import "../../../../src/components/ha-icon-button";
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
const SCHEMA: HaFormSchema[] = [
{ type: "string", name: "Name", default: "", autofocus: true },
@@ -30,8 +31,11 @@ type DialogType =
export class DemoHaAdaptiveDialog extends LitElement {
@state() private _openDialog: DialogType = false;
@state() private _hass?: HomeAssistant;
protected firstUpdated() {
provideHass(this);
const hass = provideHass(this);
this._hass = hass;
}
protected render() {
@@ -85,6 +89,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-card>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "basic"}
header-title="Basic adaptive dialog"
@closed=${this._handleClosed}
@@ -93,6 +98,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "basic-subtitle-below"}
header-title="Adaptive dialog with subtitle"
header-subtitle="This is an adaptive dialog with a subtitle below"
@@ -102,6 +108,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "basic-subtitle-above"}
header-title="Adaptive dialog with subtitle above"
header-subtitle="This is an adaptive dialog with a subtitle above"
@@ -112,6 +119,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "small"}
width="small"
header-title="Small adaptive dialog"
@@ -121,6 +129,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "large"}
width="large"
header-title="Large adaptive dialog"
@@ -130,6 +139,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "form"}
header-title="Adaptive dialog with form"
header-subtitle="This is an adaptive dialog with a form"
@@ -153,6 +163,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.allowModeChange=${this._openDialog === "allow-mode-change"}
header-title="Adaptive dialog with allow mode change"
header-subtitle="Resize the window while this dialog is open"
@@ -165,6 +176,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "actions"}
header-title="Adaptive dialog with actions"
header-subtitle="This is an adaptive dialog with header actions"
@@ -383,6 +395,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
<h3>Example usage</h3>
<pre><code>&lt;ha-adaptive-dialog
.hass=\${this.hass}
open
header-title="Dialog title"
header-subtitle="Dialog subtitle"
@@ -594,7 +607,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
<p>Example:</p>
<pre><code>&lt;ha-adaptive-dialog open&gt;
<pre><code>&lt;ha-adaptive-dialog .hass=\${this.hass} open&gt;
&lt;ha-form autofocus .schema=\${schema}&gt;&lt;/ha-form&gt;
&lt;/ha-adaptive-dialog&gt;</code></pre>
</div>
@@ -9,6 +9,7 @@ import "../../../../src/components/ha-form/ha-form";
import "../../../../src/components/ha-icon-button";
import type { HASSDomCurrentTargetEvent } from "../../../../src/common/dom/fire_event";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
const SCHEMA: HaFormSchema[] = [
@@ -22,10 +23,12 @@ type PopoverType = false | "basic" | "small" | "large" | "form" | "actions";
export class DemoHaAdaptivePopover extends LitElement {
@state() private _openPopover: PopoverType = false;
@state() private _hass?: HomeAssistant;
@state() private _dialogAnchor?: HTMLElement;
protected firstUpdated() {
provideHass(this);
this._hass = provideHass(this);
}
protected render() {
@@ -69,6 +72,7 @@ export class DemoHaAdaptivePopover extends LitElement {
</ha-card>
<ha-adaptive-popover
.hass=${this._hass}
.open=${this._openPopover === "basic"}
.dialogAnchor=${this._dialogAnchor}
header-title="Basic adaptive popover"
@@ -82,6 +86,7 @@ export class DemoHaAdaptivePopover extends LitElement {
</ha-adaptive-popover>
<ha-adaptive-popover
.hass=${this._hass}
.open=${this._openPopover === "small"}
.dialogAnchor=${this._dialogAnchor}
width="small"
@@ -92,6 +97,7 @@ export class DemoHaAdaptivePopover extends LitElement {
</ha-adaptive-popover>
<ha-adaptive-popover
.hass=${this._hass}
.open=${this._openPopover === "large"}
.dialogAnchor=${this._dialogAnchor}
width="large"
@@ -102,6 +108,7 @@ export class DemoHaAdaptivePopover extends LitElement {
</ha-adaptive-popover>
<ha-adaptive-popover
.hass=${this._hass}
.open=${this._openPopover === "form"}
.dialogAnchor=${this._dialogAnchor}
header-title="Adaptive popover with form"
@@ -126,6 +133,7 @@ export class DemoHaAdaptivePopover extends LitElement {
</ha-adaptive-popover>
<ha-adaptive-popover
.hass=${this._hass}
.open=${this._openPopover === "actions"}
.dialogAnchor=${this._dialogAnchor}
header-title="Adaptive popover with actions"
@@ -51,12 +51,11 @@ The badge component is a small component that displays a number or status inform
**Properties/Attributes**
| Name | Type | Default | Description |
| --------------- | ----------------------- | ----------- | ------------------------------------------------------------- |
| type | `"badge"` or `"button"` | `"badge"` | If it's button it shows a ripple effect |
| label | string | `undefined` | Text label for the badge, only visible if `iconOnly = false` |
| iconOnly | boolean | `false` | Only show label |
| accessibleLabel | string | `undefined` | Accessible name of the button, only used if `iconOnly = true` |
| Name | Type | Default | Description |
| -------- | ----------------------- | ----------- | ------------------------------------------------------------ |
| type | `"badge"` or `"button"` | `"badge"` | If it's button it shows a ripple effect |
| label | string | `undefined` | Text label for the badge, only visible if `iconOnly = false` |
| iconOnly | boolean | `false` | Only show label |
**CSS Custom Properties**
@@ -0,0 +1,4 @@
---
title: Color temperature and brightness picker
subtitle: Dual-axis pad controlling color temperature and brightness together
---
@@ -0,0 +1,102 @@
import "../../../../src/components/ha-color-temp-brightness-picker";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-slider";
const MIN_KELVIN = 2000;
const MAX_KELVIN = 6500;
@customElement("demo-components-ha-color-temp-brightness-picker")
export class DemoHaColorTempBrightnessPicker extends LitElement {
@state()
value: [number, number] = [3000, 50];
@state()
liveValue?: [number, number];
private _valueCursor(ev) {
this.liveValue = ev.detail.value;
}
private _valueChanged(ev) {
this.value = ev.detail.value;
}
private _kelvinChanged(ev) {
this.value = [Number(ev.target.value), this.value[1]];
}
private _brightnessChanged(ev) {
this.value = [this.value[0], Number(ev.target.value)];
}
protected render(): TemplateResult {
const [kelvin, brightness] = this.liveValue ?? this.value;
return html`
<ha-card>
<div class="card-content">
<p class="value">${kelvin} K - ${brightness}%</p>
<ha-color-temp-brightness-picker
.value=${this.value}
.minKelvin=${MIN_KELVIN}
.maxKelvin=${MAX_KELVIN}
@value-changed=${this._valueChanged}
@cursor-moved=${this._valueCursor}
></ha-color-temp-brightness-picker>
<p>Color temperature: ${this.value[0]} K</p>
<ha-slider
labeled
step="1"
.min=${MIN_KELVIN}
.max=${MAX_KELVIN}
.value=${this.value[0]}
@change=${this._kelvinChanged}
>
</ha-slider>
<p>Brightness: ${this.value[1]}%</p>
<ha-slider
labeled
step="1"
min="1"
max="100"
.value=${this.value[1]}
@change=${this._brightnessChanged}
>
</ha-slider>
</div>
</ha-card>
`;
}
static styles = css`
ha-card {
max-width: 600px;
margin: 24px auto;
}
.card-content {
display: flex;
align-items: center;
flex-direction: column;
}
ha-color-temp-brightness-picker {
width: 320px;
height: 320px;
}
.value {
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-bold);
margin: 0 0 12px 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-color-temp-brightness-picker": DemoHaColorTempBrightnessPicker;
}
}
@@ -63,25 +63,26 @@ means supplying one.
### Properties/Attributes
| Name | Type | Default | Description |
| ------------------ | ------------------ | --------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| getItems | Function | - | Required. Returns the items, optionally filtered by search string and section. |
| getAdditionalItems | Function | - | Extra items appended to search results, for example "add new". |
| value | String | - | Id of the current item. Marks its row `selected`, and the virtualized list opens with that row in view. |
| label | String | - | Placeholder for the search field. Falls back to a localized "Search". |
| mode | "popover"/"dialog" | "popover" | Adjusts padding for the surface the list sits in. |
| shown | Boolean | true | Whether the surface finished animating. `ha-generic-picker` sets it so the virtualizer does not measure rows mid-animation. |
| sections | Array | - | Filter chips. Section headers are plain strings returned by `getItems`, and the sticky title shows the one above the first visible row. |
| selectedSection | String | - | Section filter selected when the list is initialized. |
| searchKeys | Array | - | Fuse weighted keys for fuzzy search. |
| searchFn | Function | - | Post-processes filtered results. |
| rowRenderer | Function | - | Replaces the default row template. |
| allowCustomValue | Boolean | false | Offers the typed string as an item. |
| customValueLabel | String | - | Label for that custom item. |
| notFoundLabel | String/Function | - | Shown when a search matches nothing. |
| emptyLabel | String | - | Shown when there are no items at all. |
| noSort | Boolean | false | Keeps the order `getItems` returned. |
| clearable | Boolean | false | Adjusts search field padding for a clear affordance. |
| Name | Type | Default | Description |
| -------------------- | ------------------ | --------- | --------------------------------------------------------------------------------------------------------------------------- |
| getItems | Function | - | Required. Returns the items, optionally filtered by search string and section. |
| getAdditionalItems | Function | - | Extra items appended to search results, for example "add new". |
| value | String | - | Id of the current item. Marks its row `selected`, and the virtualized list opens with that row in view. |
| label | String | - | Placeholder for the search field. Falls back to a localized "Search". |
| mode | "popover"/"dialog" | "popover" | Adjusts padding for the surface the list sits in. |
| shown | Boolean | true | Whether the surface finished animating. `ha-generic-picker` sets it so the virtualizer does not measure rows mid-animation. |
| sections | Array | - | Filter chips. Section headers are plain strings returned by `getItems`. |
| selectedSection | String | - | Section filter selected when the list is initialized. |
| sectionTitleFunction | Function | - | Builds the sticky section title from the visible range. |
| searchKeys | Array | - | Fuse weighted keys for fuzzy search. |
| searchFn | Function | - | Post-processes filtered results. |
| rowRenderer | Function | - | Replaces the default row template. |
| allowCustomValue | Boolean | false | Offers the typed string as an item. |
| customValueLabel | String | - | Label for that custom item. |
| notFoundLabel | String/Function | - | Shown when a search matches nothing. |
| emptyLabel | String | - | Shown when there are no items at all. |
| noSort | Boolean | false | Keeps the order `getItems` returned. |
| clearable | Boolean | false | Adjusts search field padding for a clear affordance. |
### Events
+1 -1
View File
@@ -33,4 +33,4 @@ We want to make it as easy for designers to contribute as it is for developers.
## Developers
Everything you need to get started developing can be found in our <a href="https://contributors.home-assistant.io/frontend/" rel="noopener noreferrer" target="_blank">Home Assistant contributor docs</a>.
Everything you need to get started developing can be found in our <a href="https://developers.home-assistant.io" rel="noopener noreferrer" target="_blank">Home Assistant Developer Docs</a>.
+1 -1
View File
@@ -271,7 +271,7 @@ const CONFIGS = [
### ha-alert
You can use our [\`ha-alert\`](https://contributors.home-assistant.io/design/components/alert/) component in markdown content rendered in the Home Assistant Frontend.
You can use our [\`ha-alert\`](https://design.home-assistant.io/#components/ha-alert) component in markdown content rendered in the Home Assistant Frontend.
<ha-alert alert-type="error">This is an error alert — check it out!</ha-alert>
<ha-alert alert-type="warning">This is a warning alert — check it out!</ha-alert>
@@ -102,13 +102,6 @@ const CONFIGS = [
entity: "media_player.theater_off_static",
},
},
{
heading: "Player Off (assumed state, cannot be switched on)",
config: {
type: "media-control",
entity: "media_player.theater_off_assumed",
},
},
{
heading: "Player On (cannot be switched off)",
config: {
@@ -290,6 +290,7 @@ export class DemoIntegrationCard extends LitElement {
class=${classMap({
highlight: info.highlight !== undefined,
})}
.hass=${this.hass}
domain="esphome"
.items=${info.items}
.manifest=${createManifest(
@@ -312,6 +313,7 @@ export class DemoIntegrationCard extends LitElement {
<!-- One that is standalone to see how it increases height if height
not defined by other cards. -->
<ha-integration-card
.hass=${this.hass}
domain="esphome"
.items=${[
loadedEntry,
+1 -1
View File
@@ -32,7 +32,7 @@ It is similar to the core frontend dev.
- `"mounts": ["source=/path/to/hass/frontend,target=/workspaces/frontend,type=bind,consistency=cached"]`
- use the dev container
- start the dev server with following optional env vars:
- `SUPERVISOR_HOST` to have real supervisor data, you can [setup a supervisor remote API access](https://contributors.home-assistant.io/os/supervisor/core-connection/#get-access-to-the-supervisor-api) and set the host of your supervisor. e.g.: `SUPERVISOR_HOST=192.168.0.20:8888`
- `SUPERVISOR_HOST` to have real supervisor data, you can [setup a supervisor remote API access](https://developers.home-assistant.io/docs/supervisor/development/#supervisor-api-access) and set the host of your supervisor. e.g.: `SUPERVISOR_HOST=192.168.0.20:8888`
- `SUPERVISOR_TOKEN` the supervisor api token you get from the Remote API proxy Addon Logs
- `FRONTEND_PATH` the path inside your container should be `/workspaces/frontend`
- example: `SUPERVISOR_TOKEN=abc123 SUPERVISOR_HOST=192.168.0.20:8888 FRONTEND_PATH=/workspaces/frontend go run main.go http.go mdns.go`
+1 -1
View File
@@ -6,7 +6,7 @@ export default {
],
"*.{json,css,md,markdown,html,ya?ml}": "prettier --cache --write",
"translations/*/*.json": (files) =>
'printf "%s\n" "Translation files should not be added or modified here. Instead, make the necessary modifications in src/translations/en.json. Other languages are managed externally. Please see https://contributors.home-assistant.io/translate/ for details." ' +
'printf "%s\n" "Translation files should not be added or modified here. Instead, make the necessary modifications in src/translations/en.json. Other languages are managed externally. Please see https://developers.home-assistant.io/docs/translations/ for details." ' +
files.join(" ") +
" >&2 && exit 1",
"pnpm-lock.yaml": () => "pnpm dedupe",
+23 -24
View File
@@ -45,7 +45,6 @@
"@codemirror/autocomplete": "6.20.3",
"@codemirror/commands": "6.11.1",
"@codemirror/lang-jinja": "6.0.1",
"@codemirror/lang-python": "6.2.1",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.13.1",
"@codemirror/lint": "6.9.7",
@@ -63,12 +62,12 @@
"@formatjs/intl-numberformat": "9.4.3",
"@formatjs/intl-pluralrules": "6.3.15",
"@formatjs/intl-relativetimeformat": "12.3.15",
"@fullcalendar/core": "6.1.22",
"@fullcalendar/daygrid": "6.1.22",
"@fullcalendar/interaction": "6.1.22",
"@fullcalendar/list": "6.1.22",
"@fullcalendar/luxon3": "6.1.22",
"@fullcalendar/timegrid": "6.1.22",
"@fullcalendar/core": "6.1.21",
"@fullcalendar/daygrid": "6.1.21",
"@fullcalendar/interaction": "6.1.21",
"@fullcalendar/list": "6.1.21",
"@fullcalendar/luxon3": "6.1.21",
"@fullcalendar/timegrid": "6.1.21",
"@home-assistant/webawesome": "3.14.0-ha.0",
"@lezer/common": "1.5.3",
"@lezer/highlight": "1.2.5",
@@ -86,9 +85,9 @@
"@swc/helpers": "0.5.23",
"@tsparticles/engine": "4.4.0",
"@tsparticles/preset-links": "4.4.0",
"@versatiles/style": "6.4.1",
"@versatiles/style": "6.3.1",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.201.1",
"@vvo/tzdb": "6.198.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"barcode-detector": "3.2.2",
"cally": "0.9.2",
@@ -114,8 +113,8 @@
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"maplibre-gl": "6.14.0",
"marked": "18.1.1",
"maplibre-gl": "6.13.0",
"marked": "18.1.0",
"memoize-one": "6.0.0",
"nanoid": "6.0.2",
"node-vibrant": "4.0.4",
@@ -150,16 +149,16 @@
"@bundle-stats/plugin-webpack-filter": "4.22.3",
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.67.0",
"@html-eslint/eslint-plugin": "0.66.1",
"@lokalise/node-api": "16.6.0",
"@octokit/auth-oauth-device": "8.0.5",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.64.1",
"@playwright/test": "1.64.0",
"@rsdoctor/rspack-plugin": "1.6.5",
"@rspack/core": "2.2.9",
"@rspack/core": "2.2.8",
"@rspack/dev-server": "2.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.27",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0",
"@types/culori": "4.0.1",
@@ -169,12 +168,12 @@
"@types/leaflet.markercluster": "1.5.6",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.7.6",
"@types/node": "24.19.2",
"@types/node": "24.19.1",
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@typescript/native": "npm:[email protected]",
"@vitest/coverage-v8": "5.0.5",
"@vitest/coverage-v8": "5.0.3",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist": "4.29.3",
@@ -186,7 +185,7 @@
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
"eslint-plugin-lit": "2.3.1",
"eslint-plugin-lit-a11y": "5.1.3",
"eslint-plugin-lit-a11y": "5.1.1",
"eslint-plugin-unused-imports": "4.4.1",
"eslint-plugin-wc": "3.1.0",
"fancy-log": "2.0.0",
@@ -199,7 +198,7 @@
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "30.1.3",
"jsdom": "30.1.2",
"jszip": "3.10.2",
"license-checker-rseidelsohn": "5.0.1",
"lightningcss": "1.33.0",
@@ -210,7 +209,7 @@
"map-stream": "0.0.7",
"minify-literals": "2.2.0",
"pinst": "3.0.0",
"prettier": "3.9.10",
"prettier": "3.9.9",
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"sinon": "22.1.0",
@@ -218,16 +217,16 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.71.2",
"typescript-eslint": "8.71.1",
"vite": "8.3.4",
"vite-tsconfig-paths": "6.1.1",
"vitest": "5.0.5",
"vitest": "5.0.3",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "7.4.1"
},
"packageManager": "[email protected]4.0",
"packageManager": "[email protected]1.0",
"volta": {
"node": "24.22.0"
"node": "24.21.0"
}
}
+477 -494
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -26,7 +26,7 @@ overrides:
lit-html: 3.3.3
clean-css: 5.3.3
"@lit/reactive-element": 2.1.2
"@fullcalendar/daygrid": 6.1.22
"@fullcalendar/daygrid": 6.1.21
tslib: 2.8.1
patchedDependencies:
@@ -1,9 +0,0 @@
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="28" rx="8" fill="white"/>
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
<rect x="8" y="8" width="78" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="12" y="13.25" width="6" height="1.5" rx="0.75" fill="black" fill-opacity="0.48"/>
<rect x="37" y="12.5" width="20" height="3" rx="1.5" fill="black" fill-opacity="0.32"/>
<rect x="76" y="13.25" width="6" height="1.5" rx="0.75" fill="black" fill-opacity="0.48"/>
<rect x="78.25" y="11" width="1.5" height="6" rx="0.75" fill="black" fill-opacity="0.48"/>
</svg>

Before

Width:  |  Height:  |  Size: 691 B

@@ -1,9 +0,0 @@
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="28" rx="8" fill="#1C1C1C"/>
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="white" stroke-opacity="0.24"/>
<rect x="8" y="8" width="78" height="12" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="12" y="13.25" width="6" height="1.5" rx="0.75" fill="white" fill-opacity="0.72"/>
<rect x="37" y="12.5" width="20" height="3" rx="1.5" fill="white" fill-opacity="0.48"/>
<rect x="76" y="13.25" width="6" height="1.5" rx="0.75" fill="white" fill-opacity="0.72"/>
<rect x="78.25" y="11" width="1.5" height="6" rx="0.75" fill="white" fill-opacity="0.72"/>
</svg>

Before

Width:  |  Height:  |  Size: 693 B

@@ -1,7 +0,0 @@
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="28" rx="8" fill="white"/>
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
<rect x="8" y="8" width="78" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="8" y="8" width="46" height="12" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="49" y="10.5" width="2" height="7" rx="1" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 478 B

@@ -1,7 +0,0 @@
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="28" rx="8" fill="#1C1C1C"/>
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="white" stroke-opacity="0.24"/>
<rect x="8" y="8" width="78" height="12" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="8" y="8" width="46" height="12" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="49" y="10.5" width="2" height="7" rx="1" fill="#1C1C1C"/>
</svg>

Before

Width:  |  Height:  |  Size: 482 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 223 KiB

@@ -1,141 +0,0 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import { mainWindow } from "../dom/get_main_window";
import { replaceCurrentUrl } from "../navigate";
import { constructUrlCurrentPath } from "../url/construct-url";
import { currentPath } from "../url/current-path";
import {
updateQueryString,
type QueryParamConfig,
type QueryParamValues,
} from "../url/query-params";
const DEBOUNCE_WAIT = 300;
export interface QueryParamsControllerOptions {
/** Whether the host keeps its state in the URL. */
enabled: () => boolean;
/**
* Called when the page of the host is navigated to while the host is
* connected, like a second link to an open page or coming back to it. Read
* the URL with `searchParams()` and apply it, then `write()` the state back.
*/
navigated: () => void;
}
/**
* Keeps part of a page's state in the query string of the URL, so it can be
* shared, bookmarked and reloaded. Writes replace the current history entry,
* so Back leaves the page instead of undoing each change, and only touch the
* config's params. The controller only acts while the URL is still the
* page the host was connected on.
*/
export class QueryParamsController<
C extends QueryParamConfig,
> implements ReactiveController {
private _path?: string;
private _pending?: QueryParamValues<C>;
private _pendingTimeout?: number;
private _hostUpdated = false;
constructor(
private _host: ReactiveControllerHost,
private _config: C,
private _options: QueryParamsControllerOptions
) {
this._host.addController(this);
}
public hostConnected() {
this._path = currentPath();
mainWindow.addEventListener("location-changed", this._navigated);
mainWindow.addEventListener("popstate", this._navigated);
// Before the first update the host reads the URL itself, after its
// initial state is set
if (this._hostUpdated) {
this._navigated();
}
}
public hostUpdated() {
this._hostUpdated = true;
}
public hostDisconnected() {
mainWindow.removeEventListener("location-changed", this._navigated);
mainWindow.removeEventListener("popstate", this._navigated);
this._cancelPending();
}
/** The current search params, or undefined when not on the host's page. */
public searchParams(): URLSearchParams | undefined {
if (!this._isActive()) {
return undefined;
}
return new URLSearchParams(mainWindow.location.search);
}
/**
* Write values to the URL. Pass `debounced` for values that change while
* typing; any later write replaces a pending one.
*/
public write(values: QueryParamValues<NoInfer<C>>, debounced = false) {
this._cancelPending();
if (debounced) {
this._pending = values;
this._pendingTimeout = window.setTimeout(
() => this.flush(),
DEBOUNCE_WAIT
);
return;
}
this._write(values);
}
/**
* Write a pending debounced write now, like when typing ends, before
* something like a dialog adds a history entry it would land on.
*/
public flush() {
const values = this._pending;
this._cancelPending();
if (values) {
this._write(values);
}
}
private _cancelPending() {
clearTimeout(this._pendingTimeout);
this._pendingTimeout = undefined;
this._pending = undefined;
}
private _isActive(): boolean {
return this._options.enabled() && currentPath() === this._path;
}
private _navigated = () => {
this._cancelPending();
// Leaving the page gets here too, before the router swaps the page
if (this._isActive()) {
this._options.navigated();
}
};
private _write(values: QueryParamValues<C>) {
if (!this._isActive()) {
return;
}
const current = mainWindow.location.search;
const queryString = updateQueryString(current, values, this._config);
if (current !== (queryString ? `?${queryString}` : "")) {
replaceCurrentUrl(constructUrlCurrentPath(queryString));
}
}
}
-1
View File
@@ -13,7 +13,6 @@ export const canOverrideAlphanumericInput = (composedPath: EventTarget[]) => {
if (
el.tagName === "TEXTAREA" ||
(el as HTMLElement).isContentEditable ||
el.parentElement?.tagName === "HA-SELECT" ||
el.parentElement?.tagName === "HA-DROPDOWN"
) {
-27
View File
@@ -1,27 +0,0 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { computeDomain } from "./compute_domain";
export const ALERT_DOMAINS = [
"alarm_control_panel",
"binary_sensor",
"cover",
"lock",
];
export const DEFAULT_ALERT_COLOR = "red";
export const computeAlertActiveStates = (entityId: string): string[] => {
switch (computeDomain(entityId)) {
case "alarm_control_panel":
return ["triggered"];
case "cover":
return ["open"];
case "lock":
return ["jammed", "unlocked", "open"];
default:
return ["on"];
}
};
export const isAlertActive = (stateObj: HassEntity): boolean =>
computeAlertActiveStates(stateObj.entity_id).includes(stateObj.state);
+23 -1
View File
@@ -1,7 +1,12 @@
import memoizeOne from "memoize-one";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import type {
EntityRegistryDisplayEntry,
EntityRegistryEntry,
} from "../../data/entity/entity_registry";
import type { HomeAssistant } from "../../types";
import { getDuplicates } from "../string/get_duplicates";
import { computeStateName } from "./compute_state_name";
export const computeDeviceName = (
device: DeviceRegistryEntry
@@ -9,15 +14,32 @@ export const computeDeviceName = (
export const computeDeviceNameDisplay = (
device: DeviceRegistryEntry,
localize: HomeAssistant["localize"]
localize: HomeAssistant["localize"],
hassStates: HomeAssistant["states"],
entities?: EntityRegistryEntry[] | EntityRegistryDisplayEntry[] | string[]
) =>
computeDeviceName(device) ||
(entities && fallbackDeviceName(hassStates, entities)) ||
localize("ui.panel.config.devices.unnamed_device", {
type: localize(
`ui.panel.config.devices.type.${device.entry_type || "device"}`
),
});
export const fallbackDeviceName = (
hassStates: HomeAssistant["states"],
entities: EntityRegistryEntry[] | EntityRegistryDisplayEntry[] | string[]
) => {
for (const entity of entities || []) {
const entityId = typeof entity === "string" ? entity : entity.entity_id;
const stateObj = hassStates[entityId];
if (stateObj) {
return computeStateName(stateObj);
}
}
return undefined;
};
export const getDuplicatedDeviceNames = memoizeOne(
(devices: HomeAssistant["devices"]): Set<string> => {
const names = Object.values(devices)
+1 -1
View File
@@ -7,7 +7,7 @@ export interface EntityLocation {
gpsAccuracy?: number;
}
export const findFirstActiveZone = (
const findFirstActiveZone = (
inZones: readonly string[],
states: HassEntities
): HassEntity | undefined => {
+5 -4
View File
@@ -4,7 +4,6 @@ import type { HomeAssistant } from "../../types";
import { stringCompare } from "../string/compare";
import { computeDomain } from "./compute_domain";
import { computeStateDomain } from "./compute_state_domain";
import { computeZoneTrackerState } from "../../data/zone";
export const FIXED_DOMAIN_STATES = {
alarm_control_panel: [
@@ -276,10 +275,12 @@ export const getStatesDomain = (
result.push(
...Object.entries(hass.states)
.filter(
([entityId]) =>
computeDomain(entityId) === "zone" && entityId !== "zone.home"
([entityId, stateObj]) =>
computeDomain(entityId) === "zone" &&
entityId !== "zone.home" &&
stateObj.attributes.friendly_name
)
.map(([_entityId, stateObj]) => computeZoneTrackerState(stateObj))
.map(([_entityId, stateObj]) => stateObj.attributes.friendly_name!)
.sort((zone1, zone2) =>
stringCompare(zone1, zone2, hass.locale.language)
)
+21
View File
@@ -0,0 +1,21 @@
/**
* Sort function to help sort states by name
*
* Usage:
* const states = [state1, state2]
* states.sort(statessortStatesByName);
*/
import type { HassEntity } from "home-assistant-js-websocket";
import { computeStateName } from "./compute_state_name";
export const sortStatesByName = (entityA: HassEntity, entityB: HassEntity) => {
const nameA = computeStateName(entityA);
const nameB = computeStateName(entityB);
if (nameA < nameB) {
return -1;
}
if (nameA > nameB) {
return 1;
}
return 0;
};
+1 -3
View File
@@ -4,8 +4,6 @@ import { theme2hex } from "../color/convert-color";
/** The zone marker: a colored circle with the zone's icon or initials, shared by the map and the zone editor */
export const ZONE_CIRCLE_SIZE = 36;
// Same as an entity marker's icon
const ZONE_ICON_SIZE = 24;
// WCAG minimum contrast for icons and other graphics
const MIN_CONTENT_CONTRAST = 3;
@@ -70,6 +68,6 @@ export const zoneMarkerStyles = `
overflow: hidden;
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
--mdc-icon-size: ${ZONE_ICON_SIZE}px;
--mdc-icon-size: ${ZONE_CIRCLE_SIZE / 2}px;
}
`;
-106
View File
@@ -1,106 +0,0 @@
import type {
DataTableColumnContainer,
SortingDirection,
} from "../../components/data-table/ha-data-table";
import {
decodeQueryParams,
hasQueryParams,
type QueryParamConfig,
type QueryParamValues,
type SearchParamsSource,
} from "./query-params";
export const dataTableQueryParamConfig = {
string: ["search", "sort", "direction", "group"],
} as const satisfies QueryParamConfig;
export type DataTableQueryParams = QueryParamValues<
typeof dataTableQueryParamConfig
>;
// How a data table is searched, sorted and grouped
export interface DataTableQueryState {
search: string;
sorting?: { column: string; direction: "asc" | "desc" };
groupColumn?: string;
}
// Sorting uses the column id unless the column has a name for the URL
const sortName = (columns: DataTableColumnContainer, columnId: string) =>
columns[columnId].urlName ?? columnId;
/**
* Params for the table's state. Empty and default values are left out, and
* grouping is only written for columns that have a name for the URL, so
* internal column ids don't end up in links.
*/
export const dataTableQueryParamsFromState = (
columns: DataTableColumnContainer,
search: string,
sortColumn: string | undefined,
sortDirection: SortingDirection,
groupColumn: string | undefined
): DataTableQueryParams => {
const params: DataTableQueryParams = {};
if (search) {
params.search = search;
}
if (sortColumn && sortDirection && columns[sortColumn]?.sortable) {
params.sort = sortName(columns, sortColumn);
if (sortDirection === "desc") {
params.direction = "desc";
}
}
if (groupColumn && columns[groupColumn]?.groupable) {
const group = columns[groupColumn].urlName;
if (group) {
params.group = group;
}
}
return params;
};
/**
* The table's state a link asks for, undefined when it has no table params so
* what was picked before stays. A link replaces the search, a search left
* behind would hide part of what it shows. Sorting and grouping it leaves out,
* or that don't match a column, keep what was picked before.
*/
export const dataTableQueryStateFromUrl = (
searchParams: SearchParamsSource,
columns: DataTableColumnContainer
): DataTableQueryState | undefined => {
if (!hasQueryParams(searchParams, dataTableQueryParamConfig)) {
return undefined;
}
const { search, sort, direction, group } = decodeQueryParams(
searchParams,
dataTableQueryParamConfig
);
const state: DataTableQueryState = { search: search ?? "" };
const sortColumn = sort
? Object.keys(columns).find(
(id) => columns[id].sortable && sortName(columns, id) === sort
)
: undefined;
if (sortColumn) {
state.sorting = {
column: sortColumn,
direction: direction === "desc" ? "desc" : "asc",
};
}
const groupColumn = group
? Object.keys(columns).find(
(id) => columns[id].groupable && columns[id].urlName === group
)
: undefined;
if (groupColumn) {
state.groupColumn = groupColumn;
}
return state;
};
@@ -1,26 +0,0 @@
import {
createQueryString,
decodeQueryParams,
type QueryParamConfig,
type QueryParamValues,
type SearchParamsSource,
} from "./query-params";
// The filters of the Marketplace tables. Search, sorting and grouping are kept
// by the data table, see data-table-query-params.
export const marketplaceFilterQueryParamConfig = {
list: ["status", "type", "source"],
} as const satisfies QueryParamConfig;
export type MarketplaceFilterQueryParams = QueryParamValues<
typeof marketplaceFilterQueryParamConfig
>;
export const decodeMarketplaceFilterQueryParams = (
searchParams: SearchParamsSource
): MarketplaceFilterQueryParams =>
decodeQueryParams(searchParams, marketplaceFilterQueryParamConfig);
export const createMarketplaceFilterQueryString = (
values: MarketplaceFilterQueryParams
): string => createQueryString(values, marketplaceFilterQueryParamConfig);
-45
View File
@@ -134,51 +134,6 @@ export function createQueryString(
return searchParams.toString();
}
const queryParamKeys = (config: QueryParamConfig): string[] => [
...(config.list ?? []),
...(config.date ?? []),
...(config.boolean ?? []).map(({ key }) => key),
...(config.string ?? []),
];
/** Whether the search params hold any of the config's params. */
export const hasQueryParams = (
searchParams: SearchParamsSource,
config: QueryParamConfig
): boolean =>
queryParamKeys(config).some(
(key) => getSearchParam(searchParams, key) !== null
);
/**
* Replace the config's params in a query string with the given values,
* keeping every other param exactly as it is written.
*/
export function updateQueryString<C extends QueryParamConfig>(
current: string,
values: QueryParamValues<NoInfer<C>>,
config: C
): string;
export function updateQueryString(
current: string,
values: Record<string, QueryParamValue | undefined>,
config: QueryParamConfig
): string {
const keys = queryParamKeys(config);
const kept = current
.replace(/^\?/, "")
.split("&")
.filter((part) => {
if (!part) {
return false;
}
const [key] = new URLSearchParams(part).keys();
return !keys.includes(key);
});
const updated = createQueryString(values, config);
return [...kept, ...(updated ? [updated] : [])].join("&");
}
export const serviceTargetFromQueryParams = <
Key extends keyof HassServiceTarget & string,
>(
-68
View File
@@ -1,68 +0,0 @@
/**
* Keeps a locally edited value from being overwritten by entity state updates
* while the change is waiting to be sent or the service call is in flight.
*
* Call `schedule()` when the user changes the value and a (debounced) service
* call is queued, wrap the service call in `run()`, and only copy entity state
* into the local value when `shouldSync()` returns true. After a successful
* call the local value is kept until the next state update syncs it. When the
* call fails, the `resync` callback restores the entity state.
*
* Only the latest call is tracked, as it supersedes any earlier ones.
*/
export class PendingValueSync {
private _scheduled = false;
private _latestCall?: symbol;
public get pending(): boolean {
return this._scheduled || this._latestCall !== undefined;
}
/** Mark that a service call has been scheduled for a local change. */
public schedule(): void {
this._scheduled = true;
}
/**
* Start over, e.g. when switching entities, so a pending change is not
* shown for (or sent to) a different entity. Calls already in flight are
* not aborted, but no longer block state updates or trigger `resync`.
*/
public reset(): void {
this._scheduled = false;
this._latestCall = undefined;
}
/** Whether an entity state update may be copied into the local value. */
public shouldSync(): boolean {
return !this.pending;
}
/**
* Run the service call for the scheduled change. If the latest call fails
* and no new change is scheduled, `resync` is called. Errors are not
* rethrown, as `callService` already notifies the user when the call fails.
*/
public async run(
call: () => Promise<unknown>,
resync: () => void
): Promise<void> {
const thisCall = Symbol("call");
this._scheduled = false;
this._latestCall = thisCall;
let failed = false;
try {
await call();
} catch (_err) {
failed = true;
}
if (this._latestCall !== thisCall) {
return;
}
this._latestCall = undefined;
if (failed && !this._scheduled) {
resync();
}
}
}
@@ -1,25 +1,18 @@
import type { ContextType } from "@lit/context";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consume } from "../../common/decorators/consume";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../../common/translations/localize";
import { debounce } from "../../common/util/debounce";
import type { Condition } from "../../data/automation";
import { subscribeCondition } from "../../data/automation";
import { connectionContext } from "../../data/context";
import type { HomeAssistant } from "../../types";
import "../ha-tooltip";
import "./ha-automation-row-live-test";
import type { LiveTestState } from "./ha-automation-row-live-test";
@customElement("ha-automation-condition-live-test")
export class HaAutomationConditionLiveTest extends LitElement {
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@consume({ context: connectionContext, subscribe: true })
private _connection!: ContextType<typeof connectionContext>;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public condition!: Condition;
@@ -58,7 +51,7 @@ export class HaAutomationConditionLiveTest extends LitElement {
<slot></slot>
<ha-automation-row-live-test
.state=${this._liveTestResult.state}
.label=${this._localize(
.label=${this.hass.localize(
`ui.panel.config.automation.editor.conditions.live_test_state.${this._liveTestResult.state}`
)}
></ha-automation-row-live-test>
@@ -76,7 +69,7 @@ export class HaAutomationConditionLiveTest extends LitElement {
private _resetSubscription() {
this._liveTestResult = {
state: "unknown",
message: this._localize(
message: this.hass.localize(
"ui.panel.config.automation.editor.conditions.live_test_state.unknown"
),
};
@@ -99,14 +92,14 @@ export class HaAutomationConditionLiveTest extends LitElement {
}
const conditionUnsub = subscribeCondition(
this._connection.connection,
this.hass.connection,
(result) => {
if (result.error) {
this._handleLiveTestError(result.error);
} else {
this._liveTestResult = {
state: result.result ? "pass" : "fail",
message: this._localize(
message: this.hass.localize(
`ui.panel.config.automation.editor.conditions.testing_${result.result ? "pass" : "error"}`
),
};
@@ -128,7 +121,7 @@ export class HaAutomationConditionLiveTest extends LitElement {
typeof error !== "string" && error.code === "invalid_format";
this._liveTestResult = {
state: invalid ? "invalid" : "unknown",
message: this._localize(
message: this.hass.localize(
`ui.panel.config.automation.editor.conditions.${invalid ? "invalid_condition" : "live_test_state.unknown"}`
),
};
@@ -7,7 +7,6 @@ import memoizeOne from "memoize-one";
import { consume } from "../../common/decorators/consume";
import { truncateWithEllipsis } from "../../common/string/truncate-with-ellipsis";
import type { Condition } from "../../data/automation";
import { getRowNote } from "../../data/automation";
import type { ConditionDescription } from "../../data/condition";
import { internationalizationContext } from "../../data/context";
import "../../panels/config/automation/ha-automation-row-options";
@@ -43,7 +42,7 @@ export class HaAutomationConditionSummary extends LitElement {
this.condition?.condition === "numeric_state";
const targetRequired =
(descriptionHasTarget || hasEntityTarget) && !this.isNew;
const note = getRowNote(this.condition?.note);
const note = this.condition?.note?.trim();
let target: HassServiceTarget | undefined;
if (this.condition) {
+47 -29
View File
@@ -280,7 +280,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
private _pendingUpdate?: Set<PropertyKey>;
private _legendItemCount?: number;
private _pendingOptions?: HaECOption;
private _pendingZoom?: [number, number, boolean];
@@ -292,6 +292,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
this._tooltipHiddenWhilePanning = false;
this._pendingSetup = false;
this._pendingUpdate = undefined;
this._pendingOptions = undefined;
this._pendingZoom = undefined;
// The observers are about to be torn down, so nothing would correct a stale
// value if this element is reattached inside a hidden container.
@@ -444,6 +445,11 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
if (!this._pendingUpdate) {
this._pendingUpdate = new Set();
}
if (prop === "options" && !this._pendingUpdate.has(prop)) {
// The one previous value a replay reads, and it has to stay the options
// the chart currently renders, not those of a later deferred change.
this._pendingOptions = changedProps.get(prop) as HaECOption | undefined;
}
this._pendingUpdate.add(prop);
}
}
@@ -460,8 +466,10 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
return;
}
const pending = this._pendingUpdate!;
const previousOptions = this._pendingOptions;
this._pendingUpdate = undefined;
this._applyChartUpdate(pending);
this._pendingOptions = undefined;
this._applyChartUpdate(pending, previousOptions);
}
public willUpdate(changedProps: PropertyValues): void {
@@ -477,6 +485,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
if (invisible) {
this._pendingSetup = true;
this._pendingUpdate = undefined;
this._pendingOptions = undefined;
} else {
this._setupChart();
}
@@ -494,10 +503,16 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
}
return;
}
this._applyChartUpdate(changedProps);
this._applyChartUpdate(
changedProps,
changedProps.get("options") as HaECOption | undefined
);
}
private _applyChartUpdate(changedProps: { has: (prop: string) => boolean }) {
private _applyChartUpdate(
changedProps: { has: (prop: string) => boolean },
previousOptions: HaECOption | undefined
) {
let chartOptions: ECOption = {};
if (changedProps.has("data") || changedProps.has("_hiddenDatasets")) {
chartOptions.series = this._getSeries();
@@ -517,28 +532,20 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
if (changedProps.has("options")) {
chartOptions = { ...chartOptions, ...this._createOptions() };
this._disposeSonification();
if (this._compareCustomLegendOptions(previousOptions, this.options)) {
// custom legend changes may require a resize to layout properly
this._shouldResizeChart = true;
this._resizeAnimationDuration = 250;
}
} else if (this._isTouchDevice && changedProps.has("_isZoomed")) {
chartOptions.dataZoom = this._getDataZoomConfig();
}
let legendChanged = false;
if (changedProps.has("options") || changedProps.has("data")) {
const legendItemCount = this._getLegendItems()?.length;
legendChanged = legendItemCount !== this._legendItemCount;
this._legendItemCount = legendItemCount;
}
if (Object.keys(chartOptions).length > 0) {
this._setChartOptions(chartOptions);
if (chartOptions.series || changedProps.has("_isZoomed")) {
this._updateSankeyRoam();
}
}
if (legendChanged) {
// Within a fixed height the host does not resize when the legend grows.
// Without animation, setOption has already fired "finished" by now.
this._shouldResizeChart = true;
this._resizeAnimationDuration = 250;
this.updateComplete.then(() => this._resizeChartIfNeeded());
}
}
protected render() {
@@ -825,6 +832,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
this._loading = true;
this._pendingSetup = false;
this._pendingUpdate = undefined;
this._pendingOptions = undefined;
try {
// The connection holds a reference to the chart instance, so it cannot
// outlive it. Focusing the chart again reconnects.
@@ -875,14 +883,13 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
const sankeySeries = series?.find((s: any) => s.type === "sankey");
const zoomed = Math.abs(sankeySeries.zoom - 1) > 1e-6;
this._isZoomed = zoomed;
// Before setOption, which lays the labels out again
fireEvent(this, "chart-sankeyroam", { zoom: sankeySeries.zoom });
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" });
});
@@ -995,7 +1002,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
...this._createOptions(),
series: this._getSeries(),
});
this._legendItemCount = this._getLegendItems()?.length;
this._updateSankeyRoam();
if (this._pendingZoom) {
const [start, end, silent] = this._pendingZoom;
@@ -1452,7 +1458,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
if (this._isZoomed) {
this._handleZoomReset();
} else {
fireEvent(this, "chart-sankeyroam", { zoom: 2 });
this.chart.setOption({
series: sankeySeries.map((s: any) => ({
id: s.id,
@@ -1497,19 +1502,15 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
}
private _handleZoomReset() {
const option = this.chart?.getOption();
const sankeySeries = (option?.series as any[])?.filter(
(s: any) => s.type === "sankey"
);
// Before the dataZoom action and setOption, which lay the labels out again
if (sankeySeries?.length) {
fireEvent(this, "chart-sankeyroam", { zoom: 1 });
}
// A dataZoom action reruns the layout of every series, graphs included
if (this._isZoomed) {
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) => ({
@@ -1519,6 +1520,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
})),
});
this._isZoomed = false;
fireEvent(this, "chart-sankeyroam", { zoom: 1 });
}
this._getGraphRoams().forEach(({ series, zoom, x, y }) => {
this.chart!.dispatchAction({
@@ -1870,6 +1872,22 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
this._resizeAnimationDuration = undefined;
}
private _compareCustomLegendOptions(
oldOptions: HaECOption | undefined,
newOptions: HaECOption | undefined
): boolean {
const oldLegends = ensureArray(
oldOptions?.legend || []
) as LegendComponentOption[];
const newLegends = ensureArray(
newOptions?.legend || []
) as LegendComponentOption[];
return (
oldLegends.some((l) => l.show && l.type === "custom") !==
newLegends.some((l) => l.show && l.type === "custom")
);
}
static styles = css`
:host {
display: block;
-2
View File
@@ -32,7 +32,6 @@ export interface NetworkNode {
borderColor?: string;
borderWidth?: number;
};
label?: { position: "left" | "right" | "top" | "bottom" };
fixed?: boolean;
/**
* Distance from the center, where 0 is the center and 1 is the edge
@@ -316,7 +315,6 @@ export class HaNetworkGraph extends LitElement {
symbolSize: node.symbolSize || 30,
symbol: node.symbol || "circle",
itemStyle: node.itemStyle || {},
label: node.label,
fixed: node.fixed,
x: node.x,
y: node.y,
+3 -8
View File
@@ -237,10 +237,8 @@ export class HaSankeyChart extends LitElement {
distance: LABEL_DISTANCE,
minMargin: LABEL_MIN_MARGIN,
overflow: "break",
lineOverflow: this.vertical ? "truncate" : "none",
},
labelLayout: (params) => {
// params.rect is already zoomed, only the gap slack needs scaling
if (this.vertical) {
// reduce the label font size so the longest word fits on one line
const longestWord = params.text
@@ -259,7 +257,7 @@ export class HaSankeyChart extends LitElement {
: longest;
}, "");
const wordWidth = measureTextWidth(longestWord, FONT_SIZE) || 1;
const availableWidth = params.rect.width + 6 * this._currentZoom;
const availableWidth = (params.rect.width + 6) * this._currentZoom;
// minMargin is applied as padding on the label box, so words must
// fit in the inner wrap width or overflow:break splits them.
const wrapWidth = Math.max(availableWidth - LABEL_MIN_MARGIN, 1);
@@ -268,17 +266,14 @@ export class HaSankeyChart extends LitElement {
(wrapWidth / wordWidth) * FONT_SIZE
);
return {
fontSize,
// Safari draws a 0px canvas font at its default size, so hide
// unreadable labels by dropping all of their lines instead.
height: fontSize > 1 ? undefined : 0,
fontSize: fontSize > 1 ? fontSize : 0,
width: availableWidth,
align: "center",
dy: -2, // shift up or the lowest row labels may be cut off
};
}
const availableHeight = params.rect.height + 8 * this._currentZoom; // 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
@@ -66,15 +66,15 @@ export class DialogDataTableSettings extends LitElement {
}
private _preserveLastFixed(columnOrder) {
let strippedColumnOrder;
const lastFixedCount = this._lastFixedCount();
if (!lastFixedCount || !columnOrder) {
return columnOrder;
if (lastFixedCount && columnOrder) {
strippedColumnOrder = [...columnOrder];
strippedColumnOrder.splice(
columnOrder.length - lastFixedCount,
lastFixedCount
);
}
const strippedColumnOrder = [...columnOrder];
strippedColumnOrder.splice(
columnOrder.length - lastFixedCount,
lastFixedCount
);
return strippedColumnOrder;
}
+36 -302
View File
@@ -1,7 +1,3 @@
import type {
LitVirtualizer,
VisibilityChangedEvent,
} from "@lit-labs/virtualizer";
import type { ContextType } from "@lit/context";
import { mdiArrowDown, mdiArrowUp, mdiChevronUp } from "@mdi/js";
import deepClone from "deep-clone-simple";
@@ -79,8 +75,6 @@ export interface DataTableSortColumnData {
valueColumn?: string;
direction?: SortingDirection;
groupable?: boolean;
// Name in the `sort` and `group` URL params, grouping is only kept with one
urlName?: string;
moveable?: boolean;
hideable?: boolean;
defaultHidden?: boolean;
@@ -197,29 +191,7 @@ export class HaDataTable extends LitElement {
@query(".mdc-data-table__header-row") private _headerRow?: HTMLDivElement;
@query("lit-virtualizer")
private _scroller?: LitVirtualizer<DataTableRowData>;
@query(".sticky-group-header") private _stickyGroupHeader?: HTMLElement;
@query(".sticky-group-header .group-header")
private _stickyGroupHeaderCell?: HTMLElement;
// Group pinned to the top of the scroller while its own header is scrolled out of view
@state() private _stickyGroup?: string;
// Position and width of the sticky group header, applied directly on scroll
// to avoid re-rendering the table on every frame
private _stickyGroupPosition = { top: 0, width: 0, scrollLeft: 0 };
private _stickyGroupFrame?: number;
// Resizing doesn't scroll, but changes the width of the sticky group header
private _resizeObserver = new ResizeObserver(() =>
this._scheduleStickyGroupUpdate()
);
private _firstVisibleIndex = 0;
@query("lit-virtualizer") private _scroller?: HTMLElement;
@state() private _collapsedGroups: string[] = [];
@@ -265,7 +237,6 @@ export class HaDataTable extends LitElement {
public connectedCallback() {
super.connectedCallback();
this._resizeObserver.observe(this);
if (this._filteredData?.length) {
// Force update of location of rows
this._filteredData = [...this._filteredData];
@@ -276,21 +247,7 @@ export class HaDataTable extends LitElement {
this.updateComplete.then(() => this._calcTableHeight());
}
public disconnectedCallback() {
super.disconnectedCallback();
this._resizeObserver.disconnect();
if (this._stickyGroupFrame !== undefined) {
cancelAnimationFrame(this._stickyGroupFrame);
this._stickyGroupFrame = undefined;
}
}
protected updated(changedProps: PropertyValues<this>) {
// The sticky group header may have just been rendered, and the items may
// have changed (filter, sort, collapse) without the visible range changing
this._positionStickyGroupHeader();
this._scheduleStickyGroupUpdate();
if (!this._headerRow) {
return;
}
@@ -386,7 +343,6 @@ export class HaDataTable extends LitElement {
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
} else if (properties.has("groupColumn")) {
this._collapsedGroups = [];
this._stickyGroup = undefined;
this._lastSelectedRowId = null;
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
}
@@ -417,12 +373,6 @@ export class HaDataTable extends LitElement {
}
}
private _memRenderRow = memoizeOne(
(columns: DataTableColumnContainer, narrow: boolean) =>
(row: DataTableRowData, index: number) =>
this._renderRow(columns, narrow, row, index)
);
private _sortedColumns = memoizeOne(
(columns: DataTableColumnContainer, columnOrder?: string[]) => {
if (!columnOrder || !columnOrder.length) {
@@ -458,7 +408,8 @@ export class HaDataTable extends LitElement {
protected render() {
const columns = this._sortedColumns(this.columns, this.columnOrder);
const renderRow = this._memRenderRow(columns, this.narrow);
const renderRow = (row: DataTableRowData, index: number) =>
this._renderRow(columns, this.narrow, row, index);
const filteredDataLength = this._filteredData?.length || 0;
@@ -704,12 +655,9 @@ export class HaDataTable extends LitElement {
: html`
<lit-virtualizer
scroller
class="mdc-data-table__content scroller ha-scrollbar ${classMap(
{ grouped: Boolean(this.groupColumn) && !this.autoHeight }
)}"
class="mdc-data-table__content scroller ha-scrollbar"
tabindex=${ifDefined(!this.autoHeight ? "0" : undefined)}
@scroll=${this._saveScrollPos}
@visibilityChanged=${this._handleVisibilityChanged}
.items=${this._groupData(
this._filteredData,
this._i18n?.localize,
@@ -724,25 +672,6 @@ export class HaDataTable extends LitElement {
.keyFunction=${this._keyFunction}
.renderItem=${renderRow}
></lit-virtualizer>
${
this.groupColumn &&
!this.autoHeight &&
this._stickyGroup !== undefined
? html`<div
class="mdc-data-table__row sticky-group-header"
aria-hidden="true"
.group=${this._stickyGroup}
@click=${this._collapseStickyGroup}
>
${this._renderGroupHeader(
this._stickyGroup,
this._collapsedGroups.includes(this._stickyGroup),
this._i18n?.localize,
true
)}
</div>`
: nothing
}
`
}
</div>
@@ -763,14 +692,7 @@ export class HaDataTable extends LitElement {
return nothing;
}
if (row.append) {
return html`<div
class="mdc-data-table__row ${classMap({
"group-header-row": row.groupHeader !== undefined,
})}"
.group=${row.groupHeader}
>
${row.content}
</div>`;
return html`<div class="mdc-data-table__row">${row.content}</div>`;
}
if (row.empty) {
return html`<div class="mdc-data-table__row empty-row"></div>`;
@@ -878,13 +800,7 @@ export class HaDataTable extends LitElement {
key: string,
column: DataTableColumnData
): boolean {
// The grouped value is already shown in the sticky group header, which
// isn't available for auto-height tables
if (
column.main ||
column.showNarrow ||
(!this.autoHeight && key === this.groupColumn)
) {
if (column.main || column.showNarrow) {
return false;
}
return this._isColumnVisible(key, column);
@@ -1032,15 +948,33 @@ export class HaDataTable extends LitElement {
const groupedItems: DataTableRowData[] = [];
sortedEntries.forEach(([groupName, rows]) => {
const collapsed = collapsedGroups.includes(groupName);
groupedItems.push({
append: true,
selectable: false,
groupHeader: groupName,
content: this._renderGroupHeader(
groupName,
collapsedGroups.includes(groupName),
localize
),
content: html`<div
class="mdc-data-table__cell group-header"
role="cell"
.group=${groupName}
@click=${this._collapseGroup}
>
<ha-icon-button
.path=${mdiChevronUp}
.label=${
localize?.(
`ui.components.data-table.${collapsed ? "expand" : "collapse"}`
) || (collapsed ? "Expand" : "Collapse")
}
class=${collapsed ? "collapsed" : ""}
>
</ha-icon-button>
${
groupName === UNDEFINED_GROUP_KEY
? localize?.("ui.components.data-table.ungrouped") ||
"Ungrouped"
: groupName || ""
}
</div>`,
});
if (!collapsedGroups.includes(groupName)) {
groupedItems.push(...rows);
@@ -1061,40 +995,6 @@ export class HaDataTable extends LitElement {
}
);
private _renderGroupHeader(
groupName: string,
collapsed: boolean,
localize: LocalizeFunc | undefined,
sticky = false
) {
// The sticky copy is hidden from assistive technology, so its button is
// made inert. It handles clicks on its whole row instead, as its cell
// shifts with horizontal scrolling.
return html`<div
class="mdc-data-table__cell group-header"
role="cell"
.group=${groupName}
@click=${sticky ? nothing : this._collapseGroup}
>
<ha-icon-button
.path=${mdiChevronUp}
.label=${
localize?.(
`ui.components.data-table.${collapsed ? "expand" : "collapse"}`
) || (collapsed ? "Expand" : "Collapse")
}
class=${collapsed ? "collapsed" : ""}
?inert=${sticky}
>
</ha-icon-button>
${
groupName === UNDEFINED_GROUP_KEY
? localize?.("ui.components.data-table.ungrouped") || "Ungrouped"
: groupName || ""
}
</div>`;
}
private _memFilterData = memoizeOne(
(
data: DataTableRowData[],
@@ -1297,101 +1197,6 @@ export class HaDataTable extends LitElement {
if (this._headerRow) {
this._headerRow.scrollLeft = e.target.scrollLeft;
}
this._scheduleStickyGroupUpdate();
}
private _handleVisibilityChanged(ev: VisibilityChangedEvent) {
this._firstVisibleIndex = ev.first;
// Fired before the virtualizer positions its children, so measure later
this._scheduleStickyGroupUpdate();
}
private _scheduleStickyGroupUpdate() {
if (!this.groupColumn) {
this._stickyGroup = undefined;
return;
}
if (this._stickyGroupFrame !== undefined) {
return;
}
this._stickyGroupFrame = requestAnimationFrame(() => {
this._stickyGroupFrame = undefined;
this._updateStickyGroup();
});
}
/**
* The virtualizer positions rows absolutely, so `position: sticky` can't be
* used. Instead, an overlay copy of the current group header is shown at the
* top of the scroller, and the next group header slides under it. The
* overlay doesn't move with the scroll, as browsers scroll off the main
* thread and it would lag behind the rows.
*/
private _updateStickyGroup() {
const scroller = this._scroller;
if (
!scroller ||
!this.groupColumn ||
this.autoHeight ||
scroller.scrollTop <= 0
) {
this._stickyGroup = undefined;
return;
}
const scrollerTop = scroller.getBoundingClientRect().top;
let group: string | undefined;
let currentTop = -Infinity;
scroller
.querySelectorAll<HTMLElement & { group: string }>(".group-header-row")
.forEach((row) => {
const top = row.getBoundingClientRect().top - scrollerTop;
// Rows can sit at fractional positions, so a header less than a
// pixel below the top counts as reached
if (top < 1 && top > currentTop) {
currentTop = top;
group = row.group;
}
});
if (group === undefined) {
// The current group header is not rendered, find it from the first visible row
const items = scroller.items;
for (
let i = Math.min(this._firstVisibleIndex, items.length - 1);
i >= 0;
i--
) {
if (items[i]?.append && items[i].groupHeader !== undefined) {
group = items[i].groupHeader;
break;
}
}
}
this._stickyGroup = group;
this._stickyGroupPosition = {
top: scroller.offsetTop,
width: scroller.clientWidth,
scrollLeft: scroller.scrollLeft,
};
this._positionStickyGroupHeader();
}
private _positionStickyGroupHeader() {
const header = this._stickyGroupHeader;
if (!header) {
return;
}
const { top, width, scrollLeft } = this._stickyGroupPosition;
header.style.width = `${width}px`;
header.style.transform = `translateY(${top}px)`;
// Follow the horizontal scroll of the group header rows it covers
if (this._stickyGroupHeaderCell) {
this._stickyGroupHeaderCell.style.transform = `translateX(${-scrollLeft}px)`;
}
}
@eventOptions({ passive: true })
@@ -1403,40 +1208,6 @@ export class HaDataTable extends LitElement {
this._scroller.scrollLeft = e.target.scrollLeft;
}
private _collapseStickyGroup = async (
ev: HASSDomCurrentTargetEvent<HTMLElement & { group: string }>
) => {
const groupName = ev.currentTarget.group;
this._collapseGroup(ev);
await this.updateComplete;
const scroller = this._scroller;
if (!scroller) {
return;
}
// Keep the toggled group in view, its rows may have been above the viewport.
// Scroll a rendered header to the very top ourselves, as scrollIntoView
// would leave room for the sticky header (scroll-padding-top).
const row = Array.from(
scroller.querySelectorAll<HTMLElement & { group: string }>(
".group-header-row"
)
).find((headerRow) => headerRow.group === groupName);
if (row) {
scroller.scrollTop +=
row.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
return;
}
const index = scroller.items.findIndex(
(item) => item?.append && item.groupHeader === groupName
);
if (index !== -1) {
// The header isn't rendered, but element() still returns a proxy for
// any index in the items: its scrollIntoView() pins that index in the
// virtualizer's layout, which renders it and aligns it to the top
scroller.element(index)?.scrollIntoView({ block: "start" });
}
};
private _collapseGroup = (
ev: HASSDomCurrentTargetEvent<HTMLElement & { group: string }>
) => {
@@ -1762,6 +1533,10 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell--numeric {
text-align: var(--float-end);
}
.mdc-data-table__header-cell--numeric.sortable:hover,
.mdc-data-table__header-cell--numeric.sortable:not(.not-sorted) {
text-align: var(--float-start);
}
/* custom from here */
@@ -1787,28 +1562,6 @@ export class HaDataTable extends LitElement {
transform: rotate(180deg);
}
.sticky-group-header {
position: absolute;
top: 0;
inset-inline-start: 0;
z-index: 1;
/* Match the top border of the group header rows it covers */
border-top: 1px solid transparent;
border-bottom: 1px solid var(--divider-color);
background-color: var(--primary-background-color);
will-change: transform;
}
.sticky-group-header .group-header {
/* Don't paint over the bottom divider */
background-color: transparent;
}
/* Keep focused rows clear of the sticky group header */
lit-virtualizer.grouped {
scroll-padding-top: var(--data-table-row-height, 52px);
}
:host {
display: block;
}
@@ -1835,8 +1588,8 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell > * {
transition: var(--float-start) 0.2s ease;
}
.mdc-data-table__header-cell--numeric ha-svg-icon {
transition: var(--float-end) 0.2s ease;
.mdc-data-table__header-cell--numeric > span {
transition: none;
}
.mdc-data-table__header-cell ha-svg-icon {
top: -3px;
@@ -1859,25 +1612,6 @@ export class HaDataTable extends LitElement {
inset-inline-start: 12px;
inset-inline-end: initial;
}
.mdc-data-table__header-cell--numeric.not-sorted ha-svg-icon {
right: -20px;
inset-inline-end: -20px;
inset-inline-start: initial;
}
.mdc-data-table__header-cell--numeric.sortable:not(.not-sorted)
ha-svg-icon,
.mdc-data-table__header-cell--numeric.sortable:hover.not-sorted
ha-svg-icon {
right: 12px;
inset-inline-end: 12px;
inset-inline-start: initial;
}
.mdc-data-table__header-cell--numeric.sortable:not(.not-sorted) span,
.mdc-data-table__header-cell--numeric.sortable:hover.not-sorted span {
left: -24px;
inset-inline-start: -24px;
inset-inline-end: initial;
}
.table-header {
border-bottom: 1px solid var(--divider-color);
}
@@ -188,8 +188,8 @@ const sortData = (
: b[column.valueColumn || sortColumn];
if (column.type === "numeric") {
valA = valA == null || isNaN(valA) ? undefined : Number(valA);
valB = valB == null || isNaN(valB) ? undefined : Number(valB);
valA = isNaN(valA) ? undefined : Number(valA);
valB = isNaN(valB) ? undefined : Number(valB);
} else if (column.type === "ip") {
return sort * ipCompare(valA, valB);
} else if (typeof valA === "string" && typeof valB === "string") {
@@ -56,7 +56,6 @@ export abstract class HaDeviceAutomationPicker<
private _localizeDeviceAutomation: (
localize: LocalizeFunc,
states: HassEntities,
formatEntityName: HomeAssistant["formatEntityName"],
entityRegistry: EntityRegistryEntry[],
automation: T
) => string;
@@ -106,6 +105,7 @@ export abstract class HaDeviceAutomationPicker<
const value = this._value;
return html`<ha-generic-picker
.hass=${this.hass}
.label=${this.label}
.value=${value}
.disabled=${!this._automations || this._automations.length === 0}
@@ -139,7 +139,6 @@ export abstract class HaDeviceAutomationPicker<
const primary = this._localizeDeviceAutomation(
this.hass.localize,
this.hass.states,
this.hass.formatEntityName,
this._entityReg,
automation
);
@@ -187,7 +186,6 @@ export abstract class HaDeviceAutomationPicker<
? this._localizeDeviceAutomation(
this.hass.localize,
this.hass.states,
this.hass.formatEntityName,
this._entityReg,
described
)
@@ -429,6 +429,7 @@ export class HaDevicePicker extends LitElement {
return html`
<ha-generic-picker
.noUnknownState=${canReplace}
.hass=${this.hass}
.autofocus=${this.autofocus}
.disabled=${this.disabled}
.helper=${this.helper}
@@ -84,6 +84,7 @@ class HaEntityAttributePicker extends LitElement {
return html`
<ha-generic-picker
.hass=${this.hass}
.value=${this.value}
.label=${
this.label ??
@@ -205,6 +205,7 @@ export class HaEntityNamePicker extends LitElement {
return html`
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.getItems=${this._getFilteredItems}
@@ -77,9 +77,6 @@ export class HaEntityPicker extends LitElement {
@property() public label?: string;
@property({ attribute: "aria-label" })
public ariaLabel: string | null = null;
@property() public value?: string;
@property() public helper?: string;
@@ -452,7 +449,6 @@ export class HaEntityPicker extends LitElement {
.allowCustomValue=${this.allowCustomEntity}
.required=${this.required}
.label=${this.label}
.ariaLabel=${this.ariaLabel}
.placeholder=${placeholder}
.helper=${this.helper}
.value=${this.addButton ? undefined : this.value}
@@ -261,6 +261,7 @@ export class HaStateContentPicker extends LitElement {
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.value=${this._getPickerValue()}
@@ -138,6 +138,7 @@ export class HaEntityStatePicker extends LitElement {
return html`
<ha-generic-picker
.hass=${this.hass}
.allowCustomValue=${this.allowCustomValue}
.disabled=${this.disabled || noEntity}
.autofocus=${this.autofocus}
@@ -511,6 +511,7 @@ export class HaStatisticPicker extends LitElement {
return html`
<ha-generic-picker
.hass=${this.hass}
.autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomEntity}
.disabled=${this.disabled}
+2 -5
View File
@@ -230,7 +230,7 @@ export class StateBadge extends LitElement {
const domain = computeDomain(this.stateObj.entity_id);
if (domain === "update") {
cls.set("has-no-radius", true);
} else if (domain === "media_player") {
} else if (domain === "media_player" || domain === "camera") {
cls.set("has-media-image", true);
} else if (this.style.backgroundImage !== "") {
cls.set("has-image", true);
@@ -257,10 +257,7 @@ export class StateBadge extends LitElement {
justify-content: center;
}
:host(.has-image) {
border-radius: var(
--state-badge-with-image-border-radius,
var(--ha-picture-border-radius)
);
border-radius: var(--state-badge-with-image-border-radius, 50%);
}
:host(.has-media-image) {
border-radius: var(--state-badge-with-media-image-border-radius, 8%);
+13 -18
View File
@@ -1,15 +1,10 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { ContextType } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import { consume } from "../common/decorators/consume";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import { apiContext, configContext } from "../data/context";
import { fetchHassioAddonsInfo } from "../data/hassio/addon";
import type { ValueChangedEvent } from "../types";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./ha-alert";
import "./ha-app-icon";
import "./ha-combo-box-item";
@@ -47,13 +42,7 @@ const rowRenderer: RenderItemFunction<AddonPickerItem> = (item) => html`
@customElement("ha-addon-picker")
class HaAddonPicker extends LitElement {
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
public hass!: HomeAssistant;
@property() public label?: string;
@@ -84,7 +73,10 @@ class HaAddonPicker extends LitElement {
}
protected render() {
const label = this.label ?? this._localize("ui.components.app-picker.app");
const label =
this.label === undefined && this.hass
? this.hass.localize("ui.components.app-picker.app")
: this.label;
if (this._error) {
return html`<ha-alert alert-type="error">${this._error}</ha-alert>`;
@@ -95,6 +87,7 @@ class HaAddonPicker extends LitElement {
return html`
<ha-generic-picker
.hass=${this.hass}
.autofocus=${this.autofocus}
.label=${label}
.valueRenderer=${this._valueRenderer}
@@ -113,8 +106,8 @@ class HaAddonPicker extends LitElement {
private async _getApps() {
try {
if (isComponentLoaded(this._config.config, "hassio")) {
const addonsInfo = await fetchHassioAddonsInfo(this._api);
if (isComponentLoaded(this.hass.config, "hassio")) {
const addonsInfo = await fetchHassioAddonsInfo(this.hass);
this._addons = addonsInfo.addons
.filter((addon) => addon.version)
.map((addon) => ({
@@ -130,12 +123,14 @@ class HaAddonPicker extends LitElement {
sorting_label: [addon.name, addon.slug].filter(Boolean).join("_"),
}));
} else {
this._error = this._localize(
this._error = this.hass.localize(
"ui.components.app-picker.error.no_supervisor"
);
}
} catch (_err: any) {
this._error = this._localize("ui.components.app-picker.error.fetch_apps");
this._error = this.hass.localize(
"ui.components.app-picker.error.fetch_apps"
);
}
}
-10
View File
@@ -372,16 +372,6 @@ export class HaAnsiToHtml extends LitElement {
return !!numberOfFoundLines;
}
public trimLines(maxLines: number) {
const pre = this._pre;
if (!pre) {
return;
}
while (pre.childElementCount > maxLines && pre.firstElementChild) {
pre.firstElementChild.remove();
}
}
public clear() {
if (this._pre) {
this._pre.innerHTML = "";
@@ -281,6 +281,7 @@ export class HaAreaControlsPicker extends LitElement {
return html`
<ha-generic-picker
.hass=${this.hass}
.value=${this.value || ""}
.disabled=${this.disabled}
.required=${this.required}
+9 -1
View File
@@ -3,12 +3,16 @@ import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import type { HomeAssistant } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-area-picker";
import "./ha-sortable";
@customElement("ha-areas-picker")
export class HaAreasPicker extends LitElement {
export class HaAreasPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property({ type: Array }) public value?: string[];
@@ -63,6 +67,10 @@ export class HaAreasPicker extends LitElement {
@property({ type: Boolean }) public reorder = false;
protected render() {
if (!this.hass) {
return nothing;
}
const currentAreas = this._currentAreas;
return html`
<ha-sortable
+29 -45
View File
@@ -43,10 +43,7 @@ import {
findAvailableLanguage,
getTranslation,
} from "../util/common-translation";
import {
documentationUrl,
SECURE_CONNECTION_DOCUMENTATION_PATH,
} from "../util/documentation-url";
import { documentationUrl } from "../util/documentation-url";
import "./ha-alert";
import "./ha-markdown";
import "./input/ha-input";
@@ -54,7 +51,7 @@ import type { HaInput } from "./input/ha-input";
interface AssistMessage {
who: string;
text: string;
text: string | TemplateResult;
thinking: string;
thinking_expanded?: boolean;
tool_calls: Record<
@@ -542,18 +539,31 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
this._conversation = [...this._conversation!, message];
}
private _showNotSupportedMessage() {
const documentationLink = `[${this._localize(
"ui.components.secure_connection_required.documentation_link"
)}](${documentationUrl(this._config, SECURE_CONNECTION_DOCUMENTATION_PATH)})`;
private async _showNotSupportedMessage() {
this._addMessage({
who: "hass",
text: `${this._localize(
"ui.components.secure_connection_required.microphone"
)}\n\n${this._localize(
"ui.components.secure_connection_required.learn_more",
{ documentation_link: documentationLink }
)}`,
text:
// New lines matter for messages
// prettier-ignore
html`${this._localize(
"ui.dialogs.voice_command.not_supported_microphone_browser"
)}
${this._localize(
"ui.dialogs.voice_command.not_supported_microphone_documentation",
{
documentation_link: html`<a
target="_blank"
rel="noopener noreferrer"
href=${documentationUrl(
this._config,
"/docs/configuration/securing/#remote-access"
)}
>${this._localize(
"ui.dialogs.voice_command.not_supported_microphone_documentation_link"
)}</a>`,
}
)}`,
thinking: "",
tool_calls: {},
});
@@ -573,31 +583,13 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
}
this._stt_binary_handler_id = undefined;
this._audioBuffer = [];
const microphoneError = await this._audioRecorder.start();
if (microphoneError) {
this._audioBuffer = undefined;
this._processing = false;
this._addMessage({
who: "hass",
text: this._localize(
`ui.dialogs.voice_command.microphone_${microphoneError}`
),
thinking: "",
tool_calls: {},
error: true,
});
return;
}
const userMessage: AssistMessage = {
who: "user",
text: "…",
thinking: "",
tool_calls: {},
};
await this._audioRecorder.start();
this._addMessage(userMessage);
@@ -610,9 +602,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
if (event.type === "run-start") {
this._stt_binary_handler_id =
event.data.runner_data.stt_binary_handler_id;
this._audio = new Audio(
this._connection.hassUrl(event.data.tts_output!.url)
);
this._audio = new Audio(event.data.tts_output!.url);
this._audio.play();
this._audio.addEventListener("ended", () => {
this._unloadAudio();
@@ -626,11 +616,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
);
this._audio.addEventListener("error", () => {
this._unloadAudio();
showAlertDialog(this, {
title: this._localize(
"ui.dialogs.voice_command.error_playing_audio"
),
});
showAlertDialog(this, { title: "Error playing audio." });
});
}
@@ -680,9 +666,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
);
} catch (err: any) {
await showAlertDialog(this, {
title: this._localize(
"ui.dialogs.voice_command.error_starting_pipeline"
),
title: "Error starting pipeline",
text: err.message || err,
});
this._stopListening();
+3 -12
View File
@@ -14,25 +14,16 @@ export class HaBadge extends LitElement {
@property({ type: Boolean, attribute: "icon-only" }) iconOnly = false;
/**
* Accessible name for the badge button when it shows only its icon.
*/
@property({ attribute: "accessible-label" }) public accessibleLabel?: string;
protected render() {
const label = this.label;
const isButton = this.type === "button";
return html`
<div
class="badge ${classMap({
"icon-only": this.iconOnly,
})}"
role=${ifDefined(isButton ? "button" : undefined)}
tabindex=${ifDefined(isButton ? "0" : undefined)}
aria-label=${ifDefined(
isButton && this.iconOnly ? this.accessibleLabel : undefined
)}
role=${ifDefined(this.type === "button" ? "button" : undefined)}
tabindex=${ifDefined(this.type === "button" ? "0" : undefined)}
>
<ha-ripple .disabled=${this.type !== "button"}></ha-ripple>
<slot name="icon"></slot>
@@ -132,7 +123,7 @@ export class HaBadge extends LitElement {
::slotted(img[slot="icon"]) {
width: 30px;
height: 30px;
border-radius: var(--ha-picture-border-radius);
border-radius: var(--ha-border-radius-circle);
object-fit: cover;
overflow: hidden;
margin-left: -10px;
@@ -14,7 +14,7 @@ import {
formatClockCardDate,
} from "../panels/lovelace/cards/clock/clock-date-format";
import type { ClockCardDatePart } from "../panels/lovelace/cards/types";
import type { ValueChangedEvent } from "../types";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./chips/ha-assist-chip";
import "./chips/ha-chip-set";
import "./chips/ha-input-chip";
@@ -116,6 +116,8 @@ interface ClockDatePartValueItem {
@customElement("ha-clock-date-format-picker")
export class HaClockDateFormatPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public disabled = false;
@property({ type: Boolean }) public required = false;
@@ -151,6 +153,7 @@ export class HaClockDateFormatPicker extends LitElement {
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.value=${this._getPickerValue()}
+30 -54
View File
@@ -31,12 +31,9 @@ import type { ContextType } from "@lit/context";
import { consume } from "../common/decorators/consume";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import {
computeEntitySearchLabels,
computeEntityNameParts,
} from "../common/entity/compute_entity_name_display";
import { getEntityContext } from "../common/entity/context/get_entity_context";
import { computeDeviceName } from "../common/entity/compute_device_name";
import { computeEntityNameParts } from "../common/entity/compute_entity_name_display";
import { computeAreaName } from "../common/entity/compute_area_name";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { copyToClipboard } from "../common/util/copy-clipboard";
@@ -45,11 +42,7 @@ import type {
JinjaArgType,
HassArgHoverContext,
} from "../resources/jinja_ha_completions";
import type {
HomeAssistant,
HomeAssistantFormatters,
HomeAssistantRegistries,
} from "../types";
import type { HomeAssistant } from "../types";
import { showToast } from "../util/toast";
import { documentationUrl } from "../util/documentation-url";
import {
@@ -720,9 +713,11 @@ export class HaCodeEditor extends ReactiveElement {
this._formatters!.formatEntityState(this._states![entityId]),
formatEntityName: (entityId) => {
const stateObj = this._states?.[entityId];
return stateObj
? this._formatters!.formatEntityName(stateObj)
: (this._registries?.entities?.[entityId]?.name ?? undefined);
return (
(stateObj?.attributes.friendly_name as string | undefined) ??
this._registries?.entities?.[entityId]?.name ??
undefined
);
},
formatAttributeName: (entityId, attribute) =>
this._formatters!.formatEntityAttributeName(
@@ -1045,42 +1040,23 @@ export class HaCodeEditor extends ReactiveElement {
});
};
private _getStates = memoizeOne(
(
states: HassEntities,
formatEntityName: HomeAssistantFormatters["formatEntityName"],
registries: HomeAssistantRegistries
): Completion[] => {
const options = Object.keys(states).map((key) => {
const name = formatEntityName(states[key]);
const { friendlyName } = computeEntitySearchLabels(
states[key],
registries
);
return {
type: "variable",
// label is used for searching, so include the names and entity_id here
label: [name, friendlyName !== name && friendlyName, key]
.filter(Boolean)
.join(" "),
displayLabel: key,
detail: name || undefined,
apply: key,
};
});
return options;
private _getStates = memoizeOne((states: HassEntities): Completion[] => {
if (!states) {
return [];
}
);
private _getEntityCompletions(): Completion[] {
if (!this._states || !this._formatters || !this._registries) return [];
return this._getStates(
this._states,
this._formatters.formatEntityName,
this._registries
);
}
const options = Object.keys(states).map((key) => ({
type: "variable",
label: states[key].attributes.friendly_name
? `${states[key].attributes.friendly_name} ${key}` // label is used for searching, so include both name and entity_id here
: key,
displayLabel: key,
detail: states[key].attributes.friendly_name,
apply: key,
}));
return options;
});
// Map of HA Jinja function name → (arg index → JinjaArgType).
// Derived from the snippet definitions in jinja_ha_completions.ts.
@@ -1460,8 +1436,8 @@ export class HaCodeEditor extends ReactiveElement {
from: number;
to: number;
}): CompletionResult | null {
const states = this._getEntityCompletions();
if (!states.length) return null;
const states = this._getStates(this._states!);
if (!states?.length) return null;
// from is stringNode.from + 1 to skip the opening quote character.
const from = stringNode.from + 1;
// Always offer completions inside a known entity-string context, including
@@ -1653,9 +1629,9 @@ export class HaCodeEditor extends ReactiveElement {
// If cursor is after the entity field, show all entities
if (context.pos >= afterField) {
const states = this._getEntityCompletions();
const states = this._getStates(this._states!);
if (!states.length) {
if (!states || !states.length) {
return null;
}
@@ -1708,9 +1684,9 @@ export class HaCodeEditor extends ReactiveElement {
const afterListMarker = currentLine.from + listItemMatch[0].length;
if (context.pos >= afterListMarker) {
const states = this._getEntityCompletions();
const states = this._getStates(this._states!);
if (!states.length) {
if (!states || !states.length) {
return null;
}
@@ -1768,9 +1744,9 @@ export class HaCodeEditor extends ReactiveElement {
return null;
}
const states = this._getEntityCompletions();
const states = this._getStates(this._states!);
if (!states.length) {
if (!states || !states.length) {
return null;
}
@@ -0,0 +1,315 @@
import { DIRECTION_ALL, Manager, Pan, Tap } from "@egjs/hammerjs";
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import memoizeOne from "memoize-one";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { rgb2hex } from "../common/color/convert-color";
import {
DEFAULT_MAX_KELVIN,
DEFAULT_MIN_KELVIN,
temperature2rgb,
} from "../common/color/convert-light-color";
import { fireEvent } from "../common/dom/fire_event";
import { mainWindow } from "../common/dom/get_main_window";
import { generateColorTemperatureGradient } from "../dialogs/more-info/components/lights/light-color-temp-picker";
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
// Dark end of the vertical brightness gradient; not fully black so the
// temperature hue stays visible at the bottom of the pad
const PAD_DARK_COLOR = "rgb(26, 26, 26)";
// The marker color's luminance scales with brightness down to this floor,
// so the marker never renders fully black at minimum brightness
const MARKER_LUMINANCE_FLOOR = 0.2;
@customElement("ha-color-temp-brightness-picker")
export class HaColorTempBrightnessPicker extends LitElement {
@property({ type: Boolean, reflect: true })
public disabled = false;
/** [color temperature in kelvin, brightness percentage] */
@property({ attribute: false })
public value?: [number, number];
@property({ attribute: false })
public minKelvin?: number;
@property({ attribute: false })
public maxKelvin?: number;
@query("#pad") private _pad?: HTMLElement;
private _mc?: HammerManager;
@state()
private _pressed?: string;
// Normalized physical position on the pad, [0, 1] left-to-right/top-to-bottom
@state()
private _cursorPosition?: [number, number];
@state()
private _localValue?: [number, number];
private get _minKelvin() {
return this.minKelvin ?? DEFAULT_MIN_KELVIN;
}
private get _maxKelvin() {
return this.maxKelvin ?? DEFAULT_MAX_KELVIN;
}
protected firstUpdated(changedProps: PropertyValues<this>): void {
super.firstUpdated(changedProps);
this._setupListeners();
}
connectedCallback(): void {
super.connectedCallback();
this._setupListeners();
}
disconnectedCallback(): void {
super.disconnectedCallback();
this._destroyListeners();
}
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
const rangeChanged =
changedProps.has("minKelvin") || changedProps.has("maxKelvin");
if (
rangeChanged ||
(changedProps.has("value") &&
(this._localValue?.[0] !== this.value?.[0] ||
this._localValue?.[1] !== this.value?.[1]))
) {
this._resetPosition();
}
}
private _setupListeners() {
if (this._pad && !this._mc) {
this._mc = new Manager(this._pad);
this._mc.add(
new Pan({
direction: DIRECTION_ALL,
enable: true,
})
);
this._mc.add(new Tap({ event: "singletap" }));
let savedPosition;
this._mc.on("panstart", (e) => {
if (this.disabled) return;
this._pressed = e.pointerType;
savedPosition = this._cursorPosition;
});
this._mc.on("pancancel", () => {
if (this.disabled) return;
this._pressed = undefined;
this._cursorPosition = savedPosition;
});
this._mc.on("panmove", (e) => {
if (this.disabled) return;
this._cursorPosition = this._getPositionFromEvent(e);
this._localValue = this._getValueFromCoord(...this._cursorPosition);
fireEvent(this, "cursor-moved", { value: this._localValue });
});
this._mc.on("panend", (e) => {
if (this.disabled) return;
this._pressed = undefined;
this._cursorPosition = this._getPositionFromEvent(e);
this._localValue = this._getValueFromCoord(...this._cursorPosition);
fireEvent(this, "cursor-moved", { value: undefined });
fireEvent(this, "value-changed", { value: this._localValue });
});
this._mc.on("singletap", (e) => {
if (this.disabled) return;
this._cursorPosition = this._getPositionFromEvent(e);
this._localValue = this._getValueFromCoord(...this._cursorPosition);
fireEvent(this, "value-changed", { value: this._localValue });
});
}
}
private _destroyListeners() {
if (this._mc) {
this._mc.destroy();
this._mc = undefined;
}
}
private _resetPosition() {
if (this.value === undefined) {
this._cursorPosition = undefined;
this._localValue = undefined;
return;
}
this._cursorPosition = this._getCoordsFromValue(this.value);
this._localValue = this.value;
}
private get _rtl() {
return mainWindow.document.dir === "rtl";
}
private _getCoordsFromValue = (value: [number, number]): [number, number] => {
const xValue =
(clamp(value[0], this._minKelvin, this._maxKelvin) - this._minKelvin) /
(this._maxKelvin - this._minKelvin);
const x = this._rtl ? 1 - xValue : xValue;
const y = 1 - clamp(value[1], 1, 100) / 100;
return [x, y];
};
private _getValueFromCoord = (x: number, y: number): [number, number] => {
const xValue = this._rtl ? 1 - x : x;
const kelvin = Math.round(
this._minKelvin + xValue * (this._maxKelvin - this._minKelvin)
);
const brightness = clamp(Math.round((1 - y) * 100), 1, 100);
return [kelvin, brightness];
};
private _getPositionFromEvent = (e: HammerInput): [number, number] => {
const boundingRect = this._pad!.getBoundingClientRect();
const x = clamp(
(e.center.x - boundingRect.left) / boundingRect.width,
0,
1
);
const y = clamp(
(e.center.y - boundingRect.top) / boundingRect.height,
0,
1
);
return [x, y];
};
private _generateTemperatureGradient = memoizeOne(
(min: number, max: number) => generateColorTemperatureGradient(min, max)
);
render() {
const gradient = this._generateTemperatureGradient(
this._minKelvin,
this._maxKelvin
);
const [x, y] = this._cursorPosition ?? [0, 0];
const markerScale = this._pressed
? this._pressed === "touch"
? "2.5"
: "1.5"
: "1";
const markerOffset = this._pressed === "touch" ? "0px, -24px" : "0px, 0px";
let markerColor = "#ffffff";
if (this._localValue) {
const rgb = temperature2rgb(this._localValue[0]);
const factor =
MARKER_LUMINANCE_FLOOR +
(1 - MARKER_LUMINANCE_FLOOR) * (this._localValue[1] / 100);
markerColor = rgb2hex([
Math.round(rgb[0] * factor),
Math.round(rgb[1] * factor),
Math.round(rgb[2] * factor),
]);
}
return html`
<div class="container ${classMap({ pressed: Boolean(this._pressed) })}">
<div
id="pad"
style=${styleMap({
"background-image": `linear-gradient(to top, ${PAD_DARK_COLOR}, white), linear-gradient(to ${this._rtl ? "left" : "right"}, ${gradient})`,
})}
></div>
<div
class="cursor"
style=${styleMap({
left: `${x * 100}%`,
top: `${y * 100}%`,
visibility: this._cursorPosition ? undefined : "hidden",
})}
>
<div
class="marker"
style=${styleMap({
"background-color": markerColor,
transform: `translate(${markerOffset}) scale(${markerScale})`,
})}
></div>
</div>
</div>
`;
}
static styles = css`
:host {
display: block;
outline: none;
}
.container {
position: relative;
width: 100%;
height: 100%;
display: flex;
}
#pad {
width: 100%;
height: 100%;
clip-path: inset(0 round var(--ha-border-radius-6xl));
background-blend-mode: multiply;
background-repeat: no-repeat;
cursor: pointer;
touch-action: none;
}
:host([disabled]) #pad {
cursor: initial;
filter: grayscale(1) opacity(0.5);
}
.cursor {
position: absolute;
width: 0;
height: 0;
pointer-events: none;
}
.marker {
position: absolute;
top: -16px;
left: -16px;
width: 32px;
height: 32px;
border-radius: var(--ha-border-radius-circle);
border: 2px solid white;
box-shadow:
0 1px 4px rgba(0, 0, 0, 0.3),
0 1px 6px rgba(0, 0, 0, 0.15);
}
.container:not(.pressed) .marker {
transition:
transform var(--ha-animation-duration-instant) ease-in-out,
background-color var(--ha-animation-duration-instant) ease-in-out;
}
.container:not(.pressed) .cursor {
transition:
left var(--ha-animation-duration-fast) ease-in-out,
top var(--ha-animation-duration-fast) ease-in-out;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-color-temp-brightness-picker": HaColorTempBrightnessPicker;
}
}
+11 -15
View File
@@ -1,16 +1,11 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { ContextType } from "@lit/context";
import { html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { consume } from "../common/decorators/consume";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import type { ConfigEntry } from "../data/config_entries";
import { getConfigEntries } from "../data/config_entries";
import { apiContext } from "../data/context";
import { domainToName } from "../data/integration";
import type { ValueChangedEvent } from "../types";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./ha-combo-box-item";
import "./ha-domain-icon";
import "./ha-generic-picker";
@@ -25,10 +20,7 @@ const SEARCH_KEYS = [
@customElement("ha-config-entry-picker")
class HaConfigEntryPicker extends LitElement {
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
public hass!: HomeAssistant;
@property() public integration?: string;
@@ -64,9 +56,13 @@ class HaConfigEntryPicker extends LitElement {
}
return html`
<ha-generic-picker
.hass=${this.hass}
.label=${
this.label ??
this._localize("ui.components.config-entry-picker.config_entry")
this.label === undefined && this.hass
? this.hass.localize(
"ui.components.config-entry-picker.config_entry"
)
: this.label
}
.value=${this.value}
.required=${this.required}
@@ -94,18 +90,18 @@ class HaConfigEntryPicker extends LitElement {
`;
private async _getConfigEntries() {
getConfigEntries(this._api, {
getConfigEntries(this.hass, {
type: ["device", "hub", "service"],
domain: this.integration,
}).then((configEntries) => {
this._configEntries = configEntries.map((entry: ConfigEntry) => {
const domainName = domainToName(this._localize, entry.domain);
const domainName = domainToName(this.hass.localize, entry.domain);
return {
id: entry.entry_id,
icon: entry.domain,
primary:
entry.title ||
this._localize(
this.hass.localize(
"ui.panel.config.integrations.config_entry.unnamed_entry"
),
secondary: domainName,
+3 -6
View File
@@ -1,19 +1,16 @@
import type { ContextType } from "@lit/context";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { consume } from "../common/decorators/consume";
import { configContext } from "../data/context";
import "./ha-icon-next";
import "./ha-svg-icon";
import "./item/ha-list-item-button";
import "./list/ha-list-nav";
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../types";
@customElement("ha-config-navigation-list")
class HaConfigNavigationList extends LitElement {
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@@ -66,7 +63,7 @@ class HaConfigNavigationList extends LitElement {
}
private _handleExternalApp() {
this._config.auth.external!.fireMessage({ type: "config_screen/show" });
this.hass.auth.external!.fireMessage({ type: "config_screen/show" });
}
static styles: CSSResultGroup = css`
+4 -16
View File
@@ -80,9 +80,6 @@ export class HaControlCircularSlider extends LitElement {
@property({ type: String, attribute: "high-label" })
public highLabel?: string;
@property({ attribute: false })
public formatValue?: (value: number) => string;
@property({ type: Number }) public value?: number;
@property({ type: Number }) public low?: number;
@@ -565,22 +562,13 @@ export class HaControlCircularSlider extends LitElement {
aria-valuemin=${this.min}
aria-valuemax=${this.max}
aria-valuenow=${
value != null ? this._steppedValue(value) : undefined
}
aria-valuetext=${ifDefined(
value != null && this.formatValue
? this.formatValue(this._steppedValue(value))
this._localValue != null
? this._steppedValue(this._localValue)
: undefined
)}
}
aria-disabled=${this.disabled}
aria-readonly=${this.readonly}
aria-label=${ifDefined(
id === "low"
? (this.lowLabel ?? this.label)
: id === "high"
? (this.highLabel ?? this.label)
: this.label
)}
aria-label=${ifDefined(this.lowLabel ?? this.label)}
@keydown=${this._handleKeyDown}
@keyup=${this._handleKeyUp}
/>
+3 -14
View File
@@ -1,16 +1,13 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { ContextType } from "@lit/context";
import { mdiMinus, mdiPlus } from "@mdi/js";
import type { TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, query } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { consume } from "../common/decorators/consume";
import { fireEvent } from "../common/dom/fire_event";
import { conditionalClamp } from "../common/number/clamp";
import { formatNumber } from "../common/number/format_number";
import { blankBeforeUnit } from "../common/translations/blank_before_unit";
import { internationalizationContext } from "../data/context";
import type { FrontendLocaleData } from "../data/translation";
import "./ha-svg-icon";
@@ -48,10 +45,6 @@ export class HaControlNumberButton extends LitElement {
@query("#input") _input!: HTMLDivElement;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
private _hideUnit = new ResizeController(this, {
callback: (entries) => {
const width = entries[0]?.contentRect.width;
@@ -167,9 +160,7 @@ export class HaControlNumberButton extends LitElement {
class="button minus"
type="button"
tabindex="-1"
aria-label=${ifDefined(
this._i18n?.localize("ui.components.number-buttons.decrement")
)}
aria-label="decrement"
@click=${this._handleMinusButton}
.disabled=${
this.disabled || (this.min != null && this._value <= this.min)
@@ -181,9 +172,7 @@ export class HaControlNumberButton extends LitElement {
class="button plus"
type="button"
tabindex="-1"
aria-label=${ifDefined(
this._i18n?.localize("ui.components.number-buttons.increment")
)}
aria-label="increment"
@click=${this._handlePlusButton}
.disabled=${
this.disabled || (this.max != null && this._value >= this.max)
-2
View File
@@ -337,7 +337,6 @@ export class HaControlScrubber extends LitElement {
display: block;
--control-scrubber-color: var(--primary-color);
--control-scrubber-background: var(--disabled-color);
--control-scrubber-background-opacity: 1;
--control-scrubber-rail-color: var(--disabled-color);
--control-scrubber-rail-opacity: 0.2;
--control-scrubber-thickness: 40px;
@@ -395,7 +394,6 @@ export class HaControlScrubber extends LitElement {
left: 50%;
width: var(--track-width);
background: var(--control-scrubber-background);
opacity: var(--control-scrubber-background-opacity);
transform: translate3d(calc(var(--value, 0) * -100%), 0, 0);
transition: transform 180ms ease-in-out;
}
+5 -17
View File
@@ -71,12 +71,11 @@ export class HaControlSelectMenu extends LitElement {
>
${this._renderIcon()}
<div class="content">
<p
id="label"
class="label ${classMap({ "visually-hidden": this.hideLabel })}"
>
${this.label}
</p>
${
this.hideLabel
? nothing
: html`<p id="label" class="label">${this.label}</p>`
}
${selectedText ? html`<p class="value">${selectedText}</p>` : nothing}
</div>
${this._renderArrow()}
@@ -236,17 +235,6 @@ export class HaControlSelectMenu extends LitElement {
font-size: var(--ha-font-size-m);
}
.content .visually-hidden {
position: absolute;
overflow: hidden;
clip: rect(0 0 0 0);
height: 1px;
width: 1px;
margin: -1px;
padding: 0;
border: 0;
}
.select-anchor:focus-visible {
box-shadow: 0 0 0 2px var(--control-select-menu-focus-color);
}
+74 -28
View File
@@ -4,13 +4,14 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { debounce } from "../common/util/debounce";
import type { ConfigEntry, SubEntry } from "../data/config_entries";
import { getConfigEntry, getSubEntries } from "../data/config_entries";
import type { Agent } from "../data/conversation";
import { listAgents } from "../data/conversation";
import type { EntityConfigFlow } from "../dialogs/config-flow/entity-config-flow";
import {
fetchEntityConfigFlow,
showEntityConfigFlow,
} from "../dialogs/config-flow/entity-config-flow";
import { getExtendedEntityRegistryEntry } from "../data/entity/entity_registry";
import { fetchIntegrationManifest } from "../data/integration";
import { showOptionsFlowDialog } from "../dialogs/config-flow/show-dialog-options-flow";
import { showSubConfigFlowDialog } from "../dialogs/config-flow/show-dialog-sub-config-flow";
import type { HomeAssistant } from "../types";
import "./ha-select";
import type { HaSelectOption, HaSelectSelectEvent } from "./ha-select";
@@ -33,7 +34,9 @@ export class HaConversationAgentPicker extends LitElement {
@state() _agents?: Agent[];
@state() private _configFlow?: EntityConfigFlow;
@state() private _configEntry?: ConfigEntry;
@state() private _subConfigEntry?: SubEntry;
protected render() {
if (!this._agents) {
@@ -76,15 +79,16 @@ export class HaConversationAgentPicker extends LitElement {
.options=${options}
></ha-select
>${
this._configFlow
(this._subConfigEntry &&
this._configEntry?.supported_subentry_types[
this._subConfigEntry.subentry_type
]?.supports_reconfigure) ||
this._configEntry?.supports_options
? html`<ha-icon-button
.label=${this.hass.localize(
"ui.components.conversation-agent-picker.settings"
)}
.path=${mdiCog}
@click=${this._openConfigFlow}
@click=${this._openOptionsFlow}
></ha-icon-button>`
: nothing
: ""
}
`;
}
@@ -98,23 +102,40 @@ export class HaConversationAgentPicker extends LitElement {
}
if (changedProperties.has("value")) {
this._fetchConfigFlow();
this._maybeFetchConfigEntry();
}
}
private async _fetchConfigFlow() {
const entityId = this.value;
this._configFlow = undefined;
let configFlow: EntityConfigFlow | undefined;
try {
configFlow = entityId
? await fetchEntityConfigFlow(this.hass, entityId)
: undefined;
} catch (_err) {
configFlow = undefined;
private async _maybeFetchConfigEntry() {
if (!this.value || !(this.value in this.hass.entities)) {
this._configEntry = undefined;
return;
}
if (entityId === this.value) {
this._configFlow = configFlow;
try {
const regEntry = await getExtendedEntityRegistryEntry(
this.hass,
this.value
);
if (!regEntry.config_entry_id) {
this._configEntry = undefined;
return;
}
this._configEntry = (
await getConfigEntry(this.hass, regEntry.config_entry_id)
).config_entry;
if (!regEntry.config_subentry_id) {
this._subConfigEntry = undefined;
} else {
this._subConfigEntry = (
await getSubEntries(this.hass, regEntry.config_entry_id)
).find((entry) => entry.subentry_id === regEntry.config_subentry_id);
}
} catch (_err) {
this._configEntry = undefined;
this._subConfigEntry = undefined;
}
}
@@ -182,10 +203,35 @@ export class HaConversationAgentPicker extends LitElement {
}
}
private _openConfigFlow() {
if (this._configFlow) {
showEntityConfigFlow(this, this.hass, this._configFlow);
private async _openOptionsFlow() {
if (!this._configEntry) {
return;
}
if (
this._subConfigEntry &&
this._configEntry.supported_subentry_types[
this._subConfigEntry.subentry_type
]?.supports_reconfigure
) {
showSubConfigFlowDialog(
this,
this._configEntry,
this._subConfigEntry.subentry_type,
{
startFlowHandler: this._configEntry.entry_id,
subEntryId: this._subConfigEntry.subentry_id,
}
);
return;
}
showOptionsFlowDialog(this, this._configEntry, {
manifest: await fetchIntegrationManifest(
this.hass,
this._configEntry.domain
),
});
}
static styles = css`
Loaded 100 of 496 files, more files were not shown because too many files have changed in this diff. Show more