Merge branch 'home-assistant:dev' into collapsible-blueprint-sections

This commit is contained in:
karwosts 2024-03-29 09:06:50 -07:00 committed by GitHub
commit 03ff0d3a7f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
261 changed files with 15930 additions and 11354 deletions

View File

@ -1,5 +1,5 @@
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.148.1/containers/python-3/.devcontainer/base.Dockerfile
FROM mcr.microsoft.com/vscode/devcontainers/python:0-3.11
FROM mcr.microsoft.com/devcontainers/python:3.12
ENV \
DEBIAN_FRONTEND=noninteractive \

View File

@ -21,7 +21,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@v4.1.1
uses: actions/checkout@v4.1.2
with:
ref: dev
@ -57,7 +57,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@v4.1.1
uses: actions/checkout@v4.1.2
with:
ref: master

View File

@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@v4.1.1
uses: actions/checkout@v4.1.2
- name: Setup Node
uses: actions/setup-node@v4.0.2
with:
@ -37,7 +37,7 @@ jobs:
- name: Build resources
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages
- name: Setup lint cache
uses: actions/cache@v4.0.0
uses: actions/cache@v4.0.2
with:
path: |
node_modules/.cache/prettier
@ -58,7 +58,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@v4.1.1
uses: actions/checkout@v4.1.2
- name: Setup Node
uses: actions/setup-node@v4.0.2
with:
@ -76,7 +76,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@v4.1.1
uses: actions/checkout@v4.1.2
- name: Setup Node
uses: actions/setup-node@v4.0.2
with:
@ -100,7 +100,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@v4.1.1
uses: actions/checkout@v4.1.2
- name: Setup Node
uses: actions/setup-node@v4.0.2
with:

View File

@ -23,7 +23,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4.1.1
uses: actions/checkout@v4.1.2
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.

View File

@ -22,7 +22,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@v4.1.1
uses: actions/checkout@v4.1.2
with:
ref: dev
@ -58,7 +58,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@v4.1.1
uses: actions/checkout@v4.1.2
with:
ref: master

View File

@ -16,7 +16,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@v4.1.1
uses: actions/checkout@v4.1.2
- name: Setup Node
uses: actions/setup-node@v4.0.2

View File

@ -21,7 +21,7 @@ jobs:
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
steps:
- name: Check out files from GitHub
uses: actions/checkout@v4.1.1
uses: actions/checkout@v4.1.2
- name: Setup Node
uses: actions/setup-node@v4.0.2

View File

@ -6,7 +6,7 @@ on:
- cron: "0 1 * * *"
env:
PYTHON_VERSION: "3.11"
PYTHON_VERSION: "3.12"
NODE_OPTIONS: --max_old_space_size=6144
permissions:
@ -20,7 +20,7 @@ jobs:
contents: write
steps:
- name: Checkout the repository
uses: actions/checkout@v4.1.1
uses: actions/checkout@v4.1.2
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@v5

View File

@ -6,7 +6,7 @@ on:
- published
env:
PYTHON_VERSION: "3.11"
PYTHON_VERSION: "3.12"
NODE_OPTIONS: --max_old_space_size=6144
# Set default workflow permissions
@ -23,7 +23,7 @@ jobs:
contents: write # Required to upload release assets
steps:
- name: Checkout the repository
uses: actions/checkout@v4.1.1
uses: actions/checkout@v4.1.2
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@master
@ -55,7 +55,7 @@ jobs:
script/release
- name: Upload release assets
uses: softprops/action-gh-release@v0.1.15
uses: softprops/action-gh-release@v2.0.4
with:
files: |
dist/*.whl

View File

@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v4.1.1
uses: actions/checkout@v4.1.2
- name: Upload Translations
run: |

View File

@ -1,13 +0,0 @@
diff --git a/simple-tooltip.js b/simple-tooltip.js
index 78a87f6a223925f0e29fbedb268c85a142ec6985..3d686dd6a3d5a93342b4b01408089fc316b408ca 100644
--- a/simple-tooltip.js
+++ b/simple-tooltip.js
@@ -195,6 +195,8 @@ class SimpleTooltip extends LitElement {
.hidden {
position: absolute;
left: -10000px;
+ inset-inline-start: -10000px;
+ inset-inline-end: initial;
top: auto;
width: 1px;
height: 1px;

View File

@ -0,0 +1,18 @@
diff --git a/dist/hls.light.mjs b/dist/hls.light.mjs
index eed9d788fafdb159975e1a2eb08ac88ba9c9ac33..ace881935e6665946f1c8110ebd2f739cde4427e 100644
--- a/dist/hls.light.mjs
+++ b/dist/hls.light.mjs
@@ -20523,9 +20523,9 @@ class Hls {
}
Hls.defaultConfig = void 0;
-var KeySystemFormats = empty.KeySystemFormats;
-var KeySystems = empty.KeySystems;
-var SubtitleStreamController = empty.SubtitleStreamController;
-var TimelineController = empty.TimelineController;
+var KeySystemFormats = empty;
+var KeySystems = empty;
+var SubtitleStreamController = empty;
+var TimelineController = empty;
export { AbrController, AttrList, Cues as AudioStreamController, Cues as AudioTrackController, BasePlaylistController, BaseSegment, BaseStreamController, BufferController, Cues as CMCDController, CapLevelController, ChunkMetadata, ContentSteeringController, DateRange, Cues as EMEController, ErrorActionFlags, ErrorController, ErrorDetails, ErrorTypes, Events, FPSController, Fragment, Hls, HlsSkip, HlsUrlParameters, KeySystemFormats, KeySystems, Level, LevelDetails, LevelKey, LoadStats, MetadataSchema, NetworkErrorAction, Part, PlaylistLevelType, SubtitleStreamController, Cues as SubtitleTrackController, TimelineController, Hls as default, getMediaSource, isMSESupported, isSupported };
//# sourceMappingURL=hls.light.mjs.map

File diff suppressed because one or more lines are too long

View File

@ -6,4 +6,4 @@ enableGlobalCache: false
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.1.0.cjs
yarnPath: .yarn/releases/yarn-4.1.1.cjs

View File

@ -28,7 +28,7 @@ class HcLaunchScreen extends LitElement {
:host {
display: block;
height: 100vh;
background-color: white;
background-color: #f2f4f9;
font-size: 24px;
}
.container {
@ -43,6 +43,9 @@ class HcLaunchScreen extends LitElement {
max-width: 80%;
object-fit: cover;
}
.status {
color: #1d2126;
}
`;
}
}

View File

@ -4,6 +4,7 @@ import { energyEntities } from "../stubs/entities";
import { DemoConfig } from "./types";
export const demoConfigs: Array<() => Promise<DemoConfig>> = [
() => import("./sections").then((mod) => mod.demoSections),
() => import("./arsaboo").then((mod) => mod.demoArsaboo),
() => import("./teachingbirds").then((mod) => mod.demoTeachingbirds),
() => import("./kernehed").then((mod) => mod.demoKernehed),

View File

@ -0,0 +1,16 @@
import { html } from "lit";
import { DemoConfig } from "../types";
export const demoLovelaceDescription: DemoConfig["description"] = (
localize
) => html`
<p>
${localize("ui.panel.page-demo.config.sections.description", {
blog_post: html`<a
href="https://www.home-assistant.io/blog/2024/03/04/dashboard-chapter-1/"
target="_blank"
>${localize("ui.panel.page-demo.config.sections.description_blog_post")}
</a>`,
})}
</p>
`;

View File

@ -0,0 +1,474 @@
import { convertEntities } from "../../../../src/fake_data/entity";
import { DemoConfig } from "../types";
export const demoEntitiesSections: DemoConfig["entities"] = () =>
convertEntities({
"cover.living_room_garden_shutter": {
entity_id: "cover.living_room_garden_shutter",
state: "open",
attributes: {
current_position: 100,
device_class: "shutter",
friendly_name: "Living room garden shutter",
supported_features: 15,
},
},
"cover.living_room_graveyard_shutter": {
entity_id: "cover.living_room_graveyard_shutter",
state: "open",
attributes: {
current_position: 100,
device_class: "shutter",
friendly_name: "Living room graveyard shutter",
supported_features: 15,
},
},
"cover.living_room_left_shutter": {
entity_id: "cover.living_room_left_shutter",
state: "open",
attributes: {
current_position: 100,
device_class: "shutter",
friendly_name: "Living room left shutter",
supported_features: 15,
},
},
"cover.living_room_right_shutter": {
entity_id: "cover.living_room_right_shutter",
state: "open",
attributes: {
current_position: 100,
device_class: "shutter",
friendly_name: "Living room right shutter",
supported_features: 15,
},
},
"light.floor_lamp": {
entity_id: "light.floor_lamp",
state: "on",
attributes: {
min_color_temp_kelvin: 2000,
max_color_temp_kelvin: 6535,
min_mireds: 153,
max_mireds: 500,
supported_color_modes: ["color_temp", "xy"],
color_mode: "color_temp",
brightness: 178,
color_temp_kelvin: 2583,
color_temp: 387,
hs_color: [28.664, 69.597],
rgb_color: [255, 162, 77],
xy_color: [0.538, 0.389],
icon: "mdi:floor-lamp",
friendly_name: "Floor lamp",
supported_features: 44,
},
},
"light.living_room_spotlights": {
entity_id: "light.living_room_spotlights",
state: "on",
attributes: {
supported_color_modes: ["brightness"],
color_mode: "brightness",
brightness: 126,
icon: "mdi:ceiling-light-multiple",
friendly_name: "Living room spotlights",
supported_features: 32,
},
},
"light.bar_lamp": {
entity_id: "light.bar_lamp",
state: "on",
attributes: {
min_color_temp_kelvin: 2202,
max_color_temp_kelvin: 4504,
min_mireds: 222,
max_mireds: 454,
effect_list: ["None", "candle"],
supported_color_modes: ["color_temp"],
effect: null,
color_mode: null,
brightness: null,
color_temp_kelvin: null,
color_temp: null,
hs_color: null,
rgb_color: null,
xy_color: null,
mode: "normal",
dynamics: "none",
icon: "mdi:lightbulb-variant",
friendly_name: "Bar lamp",
supported_features: 44,
},
},
"sensor.living_room_temperature": {
entity_id: "sensor.living_room_temperature",
state: "22.8",
attributes: {
state_class: "measurement",
unit_of_measurement: "°C",
device_class: "temperature",
friendly_name: "Living room Temperature",
},
},
"media_player.living_room_nest_mini": {
entity_id: "media_player.living_room_nest_mini",
state: "off",
attributes: {
device_class: "speaker",
friendly_name: "Living room Nest Mini",
supported_features: 152461,
},
},
"cover.kitchen_shutter": {
entity_id: "cover.kitchen_shutter",
state: "open",
attributes: {
current_position: 100,
device_class: "shutter",
friendly_name: "Kitchen shutter ",
supported_features: 15,
},
},
"light.kitchen_spotlights": {
entity_id: "light.kitchen_spotlights",
state: "off",
attributes: {
supported_color_modes: ["brightness"],
color_mode: null,
brightness: null,
icon: "mdi:ceiling-light-multiple",
friendly_name: "Kitchen spotlights ",
supported_features: 32,
},
},
"light.worktop_spotlights": {
entity_id: "light.worktop_spotlights",
state: "off",
attributes: {
supported_color_modes: ["brightness"],
color_mode: null,
brightness: null,
icon: "mdi:ceiling-light-multiple",
friendly_name: "Worktop spotlights ",
supported_features: 32,
},
},
"binary_sensor.fridge_door": {
entity_id: "binary_sensor.fridge_door",
state: "off",
attributes: {
device_class: "door",
icon: "mdi:fridge",
friendly_name: "Fridge door",
},
},
"media_player.kitchen_nest_audio": {
entity_id: "media_player.kitchen_nest_audio",
state: "on",
attributes: {
device_class: "speaker",
friendly_name: "Kitchen Nest Audio",
supported_features: 152461,
},
},
"binary_sensor.tesla_wall_connector_vehicle_connected": {
entity_id: "binary_sensor.tesla_wall_connector_vehicle_connected",
state: "off",
attributes: {
device_class: "plug",
friendly_name: "Wall Connector Vehicle connected",
},
},
"sensor.tesla_wall_connector_session_energy": {
entity_id: "sensor.tesla_wall_connector_session_energy",
state: "16.3",
attributes: {
state_class: "total_increasing",
unit_of_measurement: "kWh",
device_class: "energy",
friendly_name: "Tesla Wall Connector Session energy",
},
},
"sensor.electric_meter_power": {
entity_id: "sensor.electric_meter_power",
state: "797.86",
attributes: {
state_class: "measurement",
unit_of_measurement: "W",
device_class: "power",
icon: "mdi:meter-electric",
friendly_name: "Electric meter Power",
},
},
"sensor.eletric_meter_voltage": {
entity_id: "sensor.eletric_meter_voltage",
state: "232.19",
attributes: {
state_class: "measurement",
unit_of_measurement: "V",
device_class: "voltage",
friendly_name: "Electric meter voltage",
},
},
"sensor.electricity_maps_grid_fossil_fuel_percentage": {
entity_id: "sensor.electricity_maps_grid_fossil_fuel_percentage",
state: "9.84",
attributes: {
state_class: "measurement",
country_code: "FR",
unit_of_measurement: "%",
attribution: "Data provided by Electricity Maps",
icon: "mdi:barrel",
friendly_name: "Electricity Maps Grid fossil fuel percentage",
},
},
"sensor.electricity_maps_co2_intensity": {
entity_id: "sensor.electricity_maps_co2_intensity",
state: "62.0",
attributes: {
state_class: "measurement",
country_code: "FR",
unit_of_measurement: "gCO2eq/kWh",
attribution: "Data provided by Electricity Maps",
friendly_name: "Electricity Maps CO2 intensity",
icon: "mdi:molecule-co2",
},
},
"sun.sun": {
entity_id: "sun.sun",
state: "above_horizon",
attributes: {
next_dawn: "2024-03-05T05:50:21.964405+00:00",
next_dusk: "2024-03-04T18:08:54.311334+00:00",
next_midnight: "2024-03-05T00:00:00+00:00",
next_noon: "2024-03-05T12:00:05+00:00",
next_rising: "2024-03-05T06:23:42.739159+00:00",
next_setting: "2024-03-04T17:35:26.271171+00:00",
elevation: 30.38,
azimuth: 204.42,
rising: false,
friendly_name: "Sun",
},
},
"sensor.rain": {
entity_id: "sensor.moon_phase",
state: "7.2",
attributes: {
state_class: "total_increasing",
unit_of_measurement: "mm",
device_class: "precipitation",
friendly_name: "Rain",
},
},
"climate.ground_floor": {
entity_id: "climate.ground_floor",
state: "heat",
attributes: {
hvac_modes: ["auto", "heat", "off"],
min_temp: 7,
max_temp: 35,
preset_modes: [
"comfort",
"away",
"eco",
"frost_protection",
"external",
"home",
],
current_temperature: 20.8,
temperature: 21,
preset_mode: "comfort",
icon: "mdi:home-floor-0",
friendly_name: "Ground floor Thermostat",
supported_features: 401,
},
},
"climate.first_floor": {
entity_id: "climate.first_floor",
state: "heat",
attributes: {
hvac_modes: ["auto", "heat", "off"],
min_temp: 7,
max_temp: 35,
preset_modes: [
"comfort",
"away",
"eco",
"frost_protection",
"external",
"home",
],
current_temperature: 21.7,
temperature: 21,
preset_mode: "comfort",
icon: "mdi:home-floor-1",
friendly_name: "First floor Thermostat",
supported_features: 401,
},
},
"cover.study_shutter": {
entity_id: "cover.study_shutter",
state: "open",
attributes: {
current_position: 100,
device_class: "shutter",
friendly_name: "Study shutter",
supported_features: 15,
},
},
"light.study_spotlights": {
entity_id: "light.study_spotlights",
state: "off",
attributes: {
supported_color_modes: ["brightness"],
color_mode: null,
brightness: null,
icon: "mdi:ceiling-light-multiple",
friendly_name: "Study spotlights",
supported_features: 32,
},
},
"media_player.study_nest_hub": {
entity_id: "media_player.study_nest_hub",
state: "off",
attributes: {
friendly_name: "Study Nest Hub",
supported_features: 152461,
},
},
"sensor.standing_desk_height": {
entity_id: "sensor.standing_desk_height",
state: "72",
attributes: {
unit_of_measurement: "cm",
icon: "mdi:tape-measure",
friendly_name: "Standing desk Height",
},
},
"light.outdoor_light": {
entity_id: "light.outdoor_light",
state: "on",
attributes: {
supported_color_modes: ["brightness"],
color_mode: null,
brightness: 255,
icon: "mdi:outdoor-lamp",
friendly_name: "Outdoor light",
supported_features: 32,
},
},
"light.flood_light": {
entity_id: "light.flood_light",
state: "off",
attributes: {
effect_list: ["None", "candle"],
supported_color_modes: ["brightness"],
effect: null,
color_mode: null,
brightness: null,
mode: "normal",
dynamics: "none",
icon: "mdi:light-flood-down",
friendly_name: "Flood light",
supported_features: 44,
},
},
"sensor.outdoor_motion_sensor_temperature": {
entity_id: "sensor.outdoor_motion_sensor_temperature",
state: "10.2",
attributes: {
state_class: "measurement",
unit_of_measurement: "°C",
device_class: "temperature",
friendly_name: "Outdoor motion sensor Temperature",
},
},
"binary_sensor.outdoor_motion_sensor_motion": {
entity_id: "binary_sensor.outdoor_motion_sensor_motion",
state: "off",
attributes: {
device_class: "motion",
friendly_name: "Outdoor motion sensor Motion",
},
},
"sensor.outdoor_motion_sensor_illuminance": {
entity_id: "sensor.outdoor_motion_sensor_illuminance",
state: "555",
attributes: {
state_class: "measurement",
light_level: 27444,
unit_of_measurement: "lx",
device_class: "illuminance",
friendly_name: "Outdoor motion sensor Illuminance",
},
},
"automation.home_assistant_auto_update": {
entity_id: "automation.home_assistant_auto_update",
state: "off",
attributes: {
id: "1700669321947",
last_triggered: "2024-02-29T18:02:05.343139+00:00",
mode: "queued",
current: 0,
max: 50,
icon: "mdi:auto-mode",
friendly_name: "Home Assistant Auto-update",
},
},
"update.home_assistant_operating_system_update": {
entity_id: "update.home_assistant_operating_system_update",
state: "off",
attributes: {
auto_update: false,
installed_version: "12.1",
in_progress: false,
latest_version: "12.1",
release_summary: null,
release_url:
"https://github.com/home-assistant/operating-system/commits/dev",
skipped_version: null,
title: "Home Assistant Operating System",
entity_picture:
"https://brands.home-assistant.io/homeassistant/icon.png",
friendly_name: "Home Assistant Operating System Update",
supported_features: 3,
},
},
"update.home_assistant_supervisor_update": {
entity_id: "update.home_assistant_supervisor_update",
state: "off",
attributes: {
auto_update: true,
installed_version: "2024.02.2",
in_progress: false,
latest_version: "2024.02.2",
release_summary: null,
release_url:
"https://github.com/home-assistant/supervisor/commits/main",
skipped_version: null,
title: "Home Assistant Supervisor",
entity_picture: "https://brands.home-assistant.io/hassio/icon.png",
friendly_name: "Home Assistant Supervisor Update",
supported_features: 1,
},
},
"update.home_assistant_core_update": {
entity_id: "update.home_assistant_supervisor_update",
state: "off",
attributes: {
auto_update: false,
installed_version: "2024.4.0",
in_progress: false,
latest_version: "2024.4.0",
release_summary: null,
release_url: "https://github.com/home-assistant/core/commits/dev",
skipped_version: null,
title: "Home Assistant Core",
entity_picture:
"https://brands.home-assistant.io/homeassistant/icon.png",
friendly_name: "Home Assistant Core Update",
supported_features: 11,
},
},
});

View File

@ -0,0 +1,14 @@
import { DemoConfig } from "../types";
import { demoLovelaceDescription } from "./description";
import { demoEntitiesSections } from "./entities";
import { demoLovelaceSections } from "./lovelace";
export const demoSections: DemoConfig = {
authorName: "Home Assistant",
authorUrl: "https://github.com/home-assistant/frontend/",
name: "Home Demo",
description: demoLovelaceDescription,
lovelace: demoLovelaceSections,
entities: demoEntitiesSections,
theme: () => ({}),
};

View File

@ -0,0 +1,281 @@
import { DemoConfig } from "../types";
export const demoLovelaceSections: DemoConfig["lovelace"] = () => ({
title: "Home Assistant Demo",
views: [
{
type: "sections",
title: "Demo",
path: "home",
icon: "mdi:home-assistant",
sections: [
{
title: "Welcome 👋",
cards: [{ type: "custom:ha-demo-card" }],
},
{
cards: [
{
type: "tile",
entity: "cover.living_room_garden_shutter",
name: "Garden",
},
{
type: "tile",
entity: "cover.living_room_graveyard_shutter",
name: "Rear",
},
{
type: "tile",
entity: "cover.living_room_left_shutter",
name: "Left",
},
{
type: "tile",
entity: "cover.living_room_right_shutter",
name: "Right",
},
{
type: "tile",
entity: "light.floor_lamp",
},
{
type: "tile",
entity: "light.living_room_spotlights",
name: "Spotlights",
features: [
{
type: "light-brightness",
},
],
},
{
type: "tile",
entity: "light.bar_lamp",
},
{
graph: "line",
type: "sensor",
entity: "sensor.living_room_temperature",
detail: 1,
name: "Temperature",
},
{
type: "tile",
entity: "media_player.living_room_nest_mini",
name: "Nest Mini",
},
],
title: "🛋️ Living room ",
},
{
type: "grid",
cards: [
{
type: "tile",
entity: "cover.kitchen_shutter",
name: "Shutter",
},
{
type: "tile",
entity: "light.kitchen_spotlights",
name: "Spotlights",
features: [
{
type: "light-brightness",
},
],
},
{
type: "tile",
entity: "light.worktop_spotlights",
name: "Worktop",
},
{
type: "tile",
entity: "binary_sensor.fridge_door",
name: "Fridge",
},
{
type: "tile",
entity: "media_player.kitchen_nest_audio",
name: "Nest Audio",
},
],
title: "👩‍🍳 Kitchen",
},
{
type: "grid",
cards: [
{
type: "tile",
entity: "binary_sensor.tesla_wall_connector_vehicle_connected",
name: "EV",
icon: "mdi:car",
},
{
type: "tile",
entity: "sensor.tesla_wall_connector_session_energy",
name: "Last charge",
color: "green",
},
{
type: "tile",
entity: "sensor.electric_meter_power",
color: "deep-orange",
name: "Home power",
},
{
type: "tile",
entity: "sensor.eletric_meter_voltage",
name: "Voltage",
color: "deep-orange",
},
{
type: "tile",
entity: "sensor.electricity_maps_grid_fossil_fuel_percentage",
name: "Fossil fuel",
color: "brown",
},
{
type: "tile",
entity: "sensor.electricity_maps_co2_intensity",
name: "CO2 Intensity",
color: "dark-grey",
},
],
title: "⚡️ Energy",
},
{
type: "grid",
cards: [
{
type: "tile",
entity: "sun.sun",
},
{
type: "tile",
entity: "sensor.rain",
color: "blue",
},
{
features: [
{
type: "target-temperature",
},
],
type: "tile",
name: "Downstairs",
entity: "climate.ground_floor",
state_content: ["preset_mode", "current_temperature"],
},
{
features: [
{
type: "target-temperature",
},
],
type: "tile",
name: "Upstairs",
entity: "climate.first_floor",
state_content: ["preset_mode", "current_temperature"],
},
],
title: "🌤️ Climate",
},
{
type: "grid",
cards: [
{
type: "tile",
entity: "cover.study_shutter",
name: "Shutter",
},
{
type: "tile",
entity: "light.study_spotlights",
name: "Spotlights",
},
{
type: "tile",
entity: "media_player.study_nest_hub",
name: "Nest Hub",
},
{
type: "tile",
entity: "sensor.standing_desk_height",
name: "Desk",
color: "brown",
icon: "mdi:desk",
},
],
title: "🧑‍💻 Study",
},
{
type: "grid",
cards: [
{
type: "tile",
entity: "light.outdoor_light",
name: "Door light",
},
{
type: "tile",
entity: "light.flood_light",
},
{
graph: "line",
type: "sensor",
entity: "sensor.outdoor_motion_sensor_temperature",
detail: 1,
name: "Temperature",
},
{
type: "tile",
entity: "binary_sensor.outdoor_motion_sensor_motion",
name: "Motion",
color: "blue",
},
{
type: "tile",
entity: "sensor.outdoor_motion_sensor_illuminance",
color: "amber",
name: "Illuminance",
},
],
title: "🌳 Outdoor",
},
{
type: "grid",
cards: [
{
type: "tile",
entity: "automation.home_assistant_auto_update",
name: "Auto-update",
color: "green",
},
{
type: "tile",
entity: "update.home_assistant_operating_system_update",
name: "OS",
icon: "mdi:home-assistant",
},
{
type: "tile",
entity: "update.home_assistant_supervisor_update",
icon: "mdi:home-assistant",
name: "Supervisor",
},
{
type: "tile",
entity: "update.home_assistant_core_update",
name: "Core",
icon: "mdi:home-assistant",
},
],
title: "🎉 Updates",
},
],
},
],
});

View File

@ -1,3 +1,4 @@
import { TemplateResult } from "lit";
import { LocalizeFunc } from "../../../src/common/translations/localize";
import { LovelaceConfig } from "../../../src/data/lovelace/config/types";
import { Entity } from "../../../src/fake_data/entity";
@ -7,6 +8,9 @@ export interface DemoConfig {
name: string;
authorName: string;
authorUrl: string;
description?:
| string
| ((localize: LocalizeFunc) => string | TemplateResult<1>);
lovelace: (localize: LocalizeFunc) => LovelaceConfig;
entities: (localize: LocalizeFunc) => Entity[];
theme: () => Record<string, string> | null;

View File

@ -39,32 +39,51 @@ export class HADemoCard extends LitElement implements LovelaceCard {
<div class="picker">
<div class="label">
${this._switching
? html`<ha-circular-progress
indeterminate
></ha-circular-progress>`
? html`
<ha-circular-progress indeterminate></ha-circular-progress>
`
: until(
selectedDemoConfig.then(
(conf) => html`
${conf.name}
<small>
<a target="_blank" href=${conf.authorUrl}>
${this.hass.localize(
"ui.panel.page-demo.cards.demo.demo_by",
{ name: conf.authorName }
)}
</a>
${this.hass.localize(
"ui.panel.page-demo.cards.demo.demo_by",
{
name: html`
<a target="_blank" href=${conf.authorUrl}>
${conf.authorName}
</a>
`,
}
)}
</small>
`
),
""
)}
</div>
<mwc-button @click=${this._nextConfig} .disabled=${this._switching}>
${this.hass.localize("ui.panel.page-demo.cards.demo.next_demo")}
</mwc-button>
</div>
<div class="content small-hidden">
${this.hass.localize("ui.panel.page-demo.cards.demo.introduction")}
<div class="content">
<p class="small-hidden">
${this.hass.localize("ui.panel.page-demo.cards.demo.introduction")}
</p>
${until(
selectedDemoConfig.then((conf) => {
if (typeof conf.description === "function") {
return conf.description(this.hass.localize);
}
if (conf.description) {
return html`<p>${conf.description}</p>`;
}
return nothing;
}),
nothing
)}
</div>
<div class="actions small-hidden">
<a href="https://www.home-assistant.io" target="_blank">
@ -108,6 +127,7 @@ export class HADemoCard extends LitElement implements LovelaceCard {
css`
a {
color: var(--primary-color);
display: inline-block;
}
.actions a {
@ -115,7 +135,11 @@ export class HADemoCard extends LitElement implements LovelaceCard {
}
.content {
padding: 16px;
padding: 0 16px;
}
.content p {
margin: 16px 0;
}
.picker {
@ -138,9 +162,8 @@ export class HADemoCard extends LitElement implements LovelaceCard {
}
.actions {
padding-left: 8px;
padding: 0px 8px 4px 8px;
}
@media only screen and (max-width: 500px) {
.small-hidden {
display: none;

View File

@ -72,6 +72,8 @@ export class HaDemo extends HomeAssistantAppEl {
id: "sensor.co2_intensity",
name: null,
icon: null,
labels: [],
categories: {},
platform: "co2signal",
hidden_by: null,
entity_category: null,
@ -88,6 +90,8 @@ export class HaDemo extends HomeAssistantAppEl {
id: "sensor.co2_intensity",
name: null,
icon: null,
labels: [],
categories: {},
platform: "co2signal",
hidden_by: null,
entity_category: null,

View File

@ -10,6 +10,7 @@ export const mockConfigEntries = (hass: MockHomeAssistant) => {
supports_options: false,
supports_remove_device: false,
supports_unload: true,
supports_reconfigure: true,
pref_disable_new_entities: false,
pref_disable_polling: false,
disabled_by: null,

View File

@ -21,10 +21,10 @@ const ENTITIES = [
}),
];
const conditions = [
{ condition: "and" },
{ condition: "not" },
{ condition: "or" },
const conditions: Condition[] = [
{ condition: "and", conditions: [] },
{ condition: "not", conditions: [] },
{ condition: "or", conditions: [] },
{ condition: "state", entity_id: "light.kitchen", state: "on" },
{
condition: "numeric_state",
@ -34,11 +34,11 @@ const conditions = [
above: 20,
},
{ condition: "sun", after: "sunset" },
{ condition: "sun", after: "sunrise", offset: "-01:00" },
{ condition: "sun", after: "sunrise", before_offset: 3600 },
{ condition: "zone", entity_id: "device_tracker.person", zone: "zone.home" },
{ condition: "trigger", id: "motion" },
{ condition: "time" },
{ condition: "template" },
{ condition: "template", value_template: "" },
];
const initialCondition: Condition = {

View File

@ -162,7 +162,7 @@ export class DemoHaBarButton extends LitElement {
}
.custom-group {
--control-button-group-thickness: 100px;
--control-button-group-border-radius: 18px;
--control-button-group-border-radius: 36px;
--control-button-group-spacing: 20px;
}
.custom-group ha-control-button {

View File

@ -94,7 +94,7 @@ export class DemoHarControlNumberButtons extends LitElement {
--control-number-buttons-background-color: #2196f3;
--control-number-buttons-background-opacity: 0.1;
--control-number-buttons-thickness: 100px;
--control-number-buttons-border-radius: 24px;
--control-number-buttons-border-radius: 36px;
}
`;
}

View File

@ -186,8 +186,8 @@ export class DemoHaControlSelect extends LitElement {
.custom {
--mdc-icon-size: 24px;
--control-select-color: var(--state-fan-active-color);
--control-select-thickness: 100px;
--control-select-border-radius: 24px;
--control-select-thickness: 130px;
--control-select-border-radius: 48px;
}
.vertical-selects {
height: 300px;

View File

@ -150,8 +150,8 @@ export class DemoHaBarSlider extends LitElement {
--control-slider-color: #ffcf4c;
--control-slider-background: #ffcf4c;
--control-slider-background-opacity: 0.2;
--control-slider-thickness: 100px;
--control-slider-border-radius: 24px;
--control-slider-thickness: 130px;
--control-slider-border-radius: 48px;
}
.vertical-sliders {
height: 300px;

View File

@ -117,8 +117,8 @@ export class DemoHaControlSwitch extends LitElement {
.custom {
--control-switch-on-color: var(--green-color);
--control-switch-off-color: var(--red-color);
--control-switch-thickness: 100px;
--control-switch-border-radius: 24px;
--control-switch-thickness: 130px;
--control-switch-border-radius: 48px;
--control-switch-padding: 6px;
--mdc-icon-size: 24px;
}

View File

@ -59,6 +59,7 @@ const DEVICES = [
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
},
{
area_id: "backyard",
@ -77,6 +78,7 @@ const DEVICES = [
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
},
{
area_id: null,
@ -95,30 +97,37 @@ const DEVICES = [
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
},
];
const AREAS: AreaRegistryEntry[] = [
{
area_id: "backyard",
floor_id: null,
name: "Backyard",
icon: null,
picture: null,
aliases: [],
labels: [],
},
{
area_id: "bedroom",
floor_id: null,
name: "Bedroom",
icon: "mdi:bed",
picture: null,
aliases: [],
labels: [],
},
{
area_id: "livingroom",
floor_id: null,
name: "Livingroom",
icon: "mdi:sofa",
picture: null,
aliases: [],
labels: [],
},
];

View File

@ -55,6 +55,7 @@ const DEVICES = [
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
},
{
area_id: "backyard",
@ -73,6 +74,7 @@ const DEVICES = [
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
},
{
area_id: null,
@ -91,30 +93,37 @@ const DEVICES = [
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
},
];
const AREAS: AreaRegistryEntry[] = [
{
area_id: "backyard",
floor_id: null,
name: "Backyard",
icon: null,
picture: null,
aliases: [],
labels: [],
},
{
area_id: "bedroom",
floor_id: null,
name: "Bedroom",
icon: "mdi:bed",
picture: null,
aliases: [],
labels: [],
},
{
area_id: "livingroom",
floor_id: null,
name: "Livingroom",
icon: "mdi:sofa",
picture: null,
aliases: [],
labels: [],
},
];

View File

@ -11,7 +11,7 @@ const ENTITIES = [
latitude: 32.877105,
longitude: 117.232185,
gps_accuracy: 91,
battery: 71,
battery: 25,
friendly_name: "Paulus",
}),
getEntity("device_tracker", "demo_anne_therese", "school", {
@ -19,7 +19,7 @@ const ENTITIES = [
latitude: 32.877105,
longitude: 117.232185,
gps_accuracy: 91,
battery: 71,
battery: 50,
friendly_name: "Anne Therese",
}),
getEntity("device_tracker", "demo_home_boy", "home", {
@ -27,7 +27,7 @@ const ENTITIES = [
latitude: 32.877105,
longitude: 117.232185,
gps_accuracy: 91,
battery: 71,
battery: 75,
friendly_name: "Home Boy",
}),
getEntity("light", "bed_light", "on", {
@ -39,21 +39,53 @@ const ENTITIES = [
getEntity("light", "ceiling_lights", "off", {
friendly_name: "Ceiling Lights",
}),
getEntity("sensor", "battery_1", 20, {
device_class: "battery",
friendly_name: "Battery 1",
unit_of_measurement: "%",
}),
getEntity("sensor", "battery_2", 35, {
device_class: "battery",
friendly_name: "Battery 2",
unit_of_measurement: "%",
}),
getEntity("sensor", "battery_3", 40, {
device_class: "battery",
friendly_name: "Battery 3",
unit_of_measurement: "%",
}),
getEntity("sensor", "battery_4", 80, {
device_class: "battery",
friendly_name: "Battery 4",
unit_of_measurement: "%",
}),
getEntity("input_number", "min_battery_level", 30, {
mode: "slider",
step: 10,
min: 0,
max: 100,
icon: "mdi:battery-alert-variant",
friendly_name: "Minimum Battery Level",
unit_of_measurement: "%",
}),
];
const CONFIGS = [
{
heading: "Unfiltered controller",
heading: "Unfiltered entities",
config: `
- type: entities
entities:
- light.bed_light
- light.ceiling_lights
- light.kitchen_lights
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
- light.bed_light
- light.ceiling_lights
- light.kitchen_lights
`,
},
{
heading: "Filtered entities card",
heading: "On and home entities",
config: `
- type: entity-filter
entities:
@ -63,9 +95,28 @@ const CONFIGS = [
- light.bed_light
- light.ceiling_lights
- light.kitchen_lights
state_filter:
- "on"
- home
conditions:
- condition: state
state:
- "on"
- home
`,
},
{
heading: "Same state as Bed Light",
config: `
- type: entity-filter
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
- light.bed_light
- light.ceiling_lights
- light.kitchen_lights
conditions:
- condition: state
state:
- light.bed_light
`,
},
{
@ -79,9 +130,11 @@ const CONFIGS = [
- light.bed_light
- light.ceiling_lights
- light.kitchen_lights
state_filter:
- "on"
- not_home
conditions:
- condition: state
state:
- "on"
- home
card:
type: entities
title: Custom Title
@ -99,15 +152,101 @@ const CONFIGS = [
- light.bed_light
- light.ceiling_lights
- light.kitchen_lights
state_filter:
- "on"
- not_home
conditions:
- condition: state
state:
- "on"
- home
card:
type: glance
show_state: true
title: Custom Title
`,
},
{
heading:
"Filtered entities by battery attribute (< '30') using state filter",
config: `
- type: entity-filter
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
state_filter:
- operator: <
attribute: battery
value: "30"
`,
},
{
heading: "Unfiltered number entities",
config: `
- type: entities
entities:
- input_number.min_battery_level
- sensor.battery_1
- sensor.battery_3
- sensor.battery_2
- sensor.battery_4
`,
},
{
heading: "Battery lower than 50%",
config: `
- type: entity-filter
entities:
- sensor.battery_1
- sensor.battery_3
- sensor.battery_2
- sensor.battery_4
conditions:
- condition: numeric_state
below: 50
`,
},
{
heading: "Battery lower than min battery level",
config: `
- type: entity-filter
entities:
- sensor.battery_1
- sensor.battery_3
- sensor.battery_2
- sensor.battery_4
conditions:
- condition: numeric_state
below: input_number.min_battery_level
`,
},
{
heading: "Battery between min battery level and 70%",
config: `
- type: entity-filter
entities:
- sensor.battery_1
- sensor.battery_3
- sensor.battery_2
- sensor.battery_4
conditions:
- condition: numeric_state
above: input_number.min_battery_level
below: 70
`,
},
{
heading: "Error: Entities must be specified",
config: `
- type: entity-filter
`,
},
{
heading: "Error: Incorrect filter config",
config: `
- type: entity-filter
entities:
- sensor.gas_station_lowest_price
`,
},
];
@customElement("demo-lovelace-entity-filter-card")

View File

@ -36,6 +36,45 @@ const ENTITIES = [
friendly_name: "Nest",
supported_features: 43,
}),
getEntity("climate", "overkiz_radiator", "heat", {
current_temperature: 18,
min_temp: 7,
max_temp: 35,
temperature: 20,
hvac_modes: ["heat", "auto", "off"],
friendly_name: "Overkiz radiator",
supported_features: 17,
preset_mode: "comfort",
preset_modes: [
"none",
"frost_protection",
"eco",
"comfort",
"comfort-1",
"comfort-2",
"auto",
"boost",
"external",
"prog",
],
}),
getEntity("climate", "overkiz_towel_dryer", "heat", {
current_temperature: null,
min_temp: 7,
max_temp: 35,
hvac_modes: ["heat", "off"],
friendly_name: "Overkiz towel dryer",
supported_features: 16,
preset_mode: "eco",
preset_modes: [
"none",
"frost_protection",
"eco",
"comfort",
"comfort-1",
"comfort-2",
],
}),
getEntity("climate", "sensibo", "fan_only", {
current_temperature: null,
temperature: null,
@ -46,7 +85,9 @@ const ENTITIES = [
friendly_name: "Sensibo purifier",
fan_modes: ["low", "high"],
fan_mode: "low",
supported_features: 9,
swing_modes: ["on", "off", "both", "vertical", "horizontal"],
swing_mode: "vertical",
supported_features: 41,
}),
getEntity("climate", "unavailable", "unavailable", {
supported_features: 43,
@ -59,8 +100,6 @@ const CONFIGS = [
config: `
- type: thermostat
entity: climate.ecobee
- type: thermostat
entity: climate.nest
`,
},
{
@ -70,6 +109,66 @@ const CONFIGS = [
entity: climate.nest
`,
},
{
heading: "Feature example",
config: `
- type: thermostat
entity: climate.overkiz_radiator
features:
- type: climate-hvac-modes
hvac_modes:
- heat
- 'off'
- auto
- type: climate-preset-modes
style: icons
preset_modes:
- none
- frost_protection
- eco
- comfort
- comfort-1
- comfort-2
- auto
- boost
- external
- prog
- type: climate-preset-modes
style: dropdown
preset_modes:
- none
- frost_protection
- eco
- comfort
- comfort-1
- comfort-2
- auto
- boost
- external
- prog
`,
},
{
heading: "Preset only example",
config: `
- type: thermostat
entity: climate.overkiz_towel_dryer
features:
- type: climate-hvac-modes
hvac_modes:
- heat
- 'off'
- type: climate-preset-modes
style: icons
preset_modes:
- none
- frost_protection
- eco
- comfort
- comfort-1
- comfort-2
`,
},
{
heading: "Fan only example",
config: `
@ -85,6 +184,14 @@ const CONFIGS = [
fan_modes:
- low
- high
- type: climate-swing-modes
style: icons
swing_modes:
- 'on'
- 'off'
- 'both'
- 'vertical'
- 'horizontal'
`,
},
{

View File

@ -406,6 +406,7 @@ export class DemoEntityState extends LitElement {
entity_id: "select.speed",
translation_key: "speed",
platform: "demo",
labels: [],
},
},
});

View File

@ -31,6 +31,7 @@ const createConfigEntry = (
supports_options: false,
supports_remove_device: false,
supports_unload: true,
supports_reconfigure: true,
disabled_by: null,
pref_disable_new_entities: false,
pref_disable_polling: false,
@ -198,6 +199,8 @@ const createEntityRegistryEntries = (
has_entity_name: false,
unique_id: "updater",
options: null,
labels: [],
categories: {},
},
];
@ -221,6 +224,7 @@ const createDeviceRegistryEntries = (
name_by_user: null,
disabled_by: null,
configuration_url: null,
labels: [],
},
];

View File

@ -11,7 +11,7 @@ import "../../components/demo-more-infos";
import { ClimateEntityFeature } from "../../../../src/data/climate";
const ENTITIES = [
getEntity("climate", "thermostat", "heat", {
getEntity("climate", "radiator", "heat", {
friendly_name: "Basic heater",
hvac_modes: ["heat", "off"],
hvac_mode: "heat",
@ -80,6 +80,24 @@ const ENTITIES = [
max_humidity: 100,
humidity: 50,
}),
getEntity("climate", "towel_dryer", "heat", {
friendly_name: "Preset only heater",
hvac_modes: ["heat", "off"],
hvac_mode: "heat",
preset_modes: [
"none",
"frost_protection",
"eco",
"comfort",
"comfort-1",
"comfort-2",
],
preset_mode: "eco",
current_temperature: null,
min_temp: 7,
max_temp: 35,
supported_features: ClimateEntityFeature.PRESET_MODE,
}),
getEntity("climate", "unavailable", "unavailable", {
friendly_name: "Unavailable heater",
hvac_modes: ["heat", "off"],

View File

@ -25,22 +25,22 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "7.23.9",
"@braintree/sanitize-url": "7.0.0",
"@codemirror/autocomplete": "6.12.0",
"@babel/runtime": "7.24.1",
"@braintree/sanitize-url": "7.0.1",
"@codemirror/autocomplete": "6.15.0",
"@codemirror/commands": "6.3.3",
"@codemirror/language": "6.10.1",
"@codemirror/legacy-modes": "6.3.3",
"@codemirror/search": "6.5.6",
"@codemirror/state": "6.4.1",
"@codemirror/view": "6.24.1",
"@codemirror/view": "6.26.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "6.12.2",
"@formatjs/intl-datetimeformat": "6.12.3",
"@formatjs/intl-displaynames": "6.6.6",
"@formatjs/intl-getcanonicallocales": "2.3.0",
"@formatjs/intl-listformat": "7.5.5",
"@formatjs/intl-locale": "3.4.5",
"@formatjs/intl-numberformat": "8.10.0",
"@formatjs/intl-numberformat": "8.10.1",
"@formatjs/intl-pluralrules": "5.2.12",
"@formatjs/intl-relativetimeformat": "11.2.12",
"@fullcalendar/core": "6.1.11",
@ -54,7 +54,7 @@
"@lit-labs/motion": "1.0.7",
"@lit-labs/observers": "2.0.2",
"@lit-labs/virtualizer": "2.0.12",
"@lrnwebcomponents/simple-tooltip": "patch:@lrnwebcomponents/simple-tooltip@npm%3A8.0.0#~/.yarn/patches/@lrnwebcomponents-simple-tooltip-npm-8.0.0-77591f2e0c.patch",
"@lrnwebcomponents/simple-tooltip": "8.0.2",
"@material/chips": "=14.0.0-canary.53b3cad2f.0",
"@material/data-table": "=14.0.0-canary.53b3cad2f.0",
"@material/mwc-base": "0.27.0",
@ -89,8 +89,8 @@
"@polymer/paper-tabs": "3.1.0",
"@polymer/polymer": "3.5.1",
"@thomasloven/round-slider": "0.6.0",
"@vaadin/combo-box": "24.3.7",
"@vaadin/vaadin-themable-mixin": "24.3.7",
"@vaadin/combo-box": "24.3.10",
"@vaadin/vaadin-themable-mixin": "24.3.10",
"@vibrant/color": "3.2.1-alpha.1",
"@vibrant/core": "3.2.1-alpha.1",
"@vibrant/quantizer-mmcq": "3.2.1-alpha.1",
@ -98,20 +98,20 @@
"@webcomponents/scoped-custom-element-registry": "0.0.9",
"@webcomponents/webcomponentsjs": "2.8.0",
"app-datepicker": "5.1.1",
"chart.js": "4.4.1",
"chart.js": "4.4.2",
"color-name": "2.0.0",
"comlink": "4.4.1",
"core-js": "3.36.0",
"core-js": "3.36.1",
"cropperjs": "1.6.1",
"date-fns": "2.30.0",
"date-fns-tz": "2.0.0",
"date-fns-tz": "2.0.1",
"deep-clone-simple": "1.1.1",
"deep-freeze": "0.0.1",
"element-internals-polyfill": "1.3.10",
"fuse.js": "7.0.0",
"google-timezones-json": "1.2.0",
"hls.js": "1.5.7",
"home-assistant-js-websocket": "9.1.0",
"hls.js": "patch:hls.js@npm%3A1.5.7#~/.yarn/patches/hls.js-npm-1.5.7-f5bbd3d060.patch",
"home-assistant-js-websocket": "9.2.1",
"idb-keyval": "6.2.1",
"intl-messageformat": "10.5.11",
"js-yaml": "4.1.0",
@ -119,7 +119,7 @@
"leaflet-draw": "1.0.4",
"lit": "2.8.0",
"luxon": "3.4.4",
"marked": "12.0.0",
"marked": "12.0.1",
"memoize-one": "6.0.0",
"node-vibrant": "3.2.1-alpha.1",
"proxy-polyfill": "0.3.2",
@ -130,7 +130,7 @@
"rrule": "2.8.1",
"sortablejs": "1.15.2",
"stacktrace-js": "2.0.2",
"superstruct": "1.0.3",
"superstruct": "1.0.4",
"tinykeys": "2.1.0",
"tsparticles-engine": "2.12.0",
"tsparticles-preset-links": "2.12.0",
@ -147,20 +147,20 @@
"workbox-precaching": "7.0.0",
"workbox-routing": "7.0.0",
"workbox-strategies": "7.0.0",
"xss": "1.0.14"
"xss": "1.0.15"
},
"devDependencies": {
"@babel/core": "7.23.9",
"@babel/helper-define-polyfill-provider": "0.5.0",
"@babel/plugin-proposal-decorators": "7.23.9",
"@babel/plugin-transform-runtime": "7.23.9",
"@babel/preset-env": "7.23.9",
"@babel/preset-typescript": "7.23.3",
"@bundle-stats/plugin-webpack-filter": "4.10.1",
"@babel/core": "7.24.3",
"@babel/helper-define-polyfill-provider": "0.6.1",
"@babel/plugin-proposal-decorators": "7.24.1",
"@babel/plugin-transform-runtime": "7.24.3",
"@babel/preset-env": "7.24.3",
"@babel/preset-typescript": "7.24.1",
"@bundle-stats/plugin-webpack-filter": "4.12.2",
"@koa/cors": "5.0.0",
"@lokalise/node-api": "12.1.0",
"@octokit/auth-oauth-device": "7.0.0",
"@octokit/plugin-retry": "7.0.1",
"@lokalise/node-api": "12.3.0",
"@octokit/auth-oauth-device": "7.0.1",
"@octokit/plugin-retry": "7.0.3",
"@octokit/rest": "20.0.2",
"@open-wc/dev-server-hmr": "0.1.4",
"@rollup/plugin-babel": "6.0.4",
@ -170,7 +170,7 @@
"@rollup/plugin-replace": "5.0.5",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.13",
"@types/chromecast-caf-sender": "1.0.8",
"@types/chromecast-caf-sender": "1.0.9",
"@types/color-name": "1.1.3",
"@types/glob": "8.1.0",
"@types/html-minifier-terser": "7.0.2",
@ -185,8 +185,8 @@
"@types/tar": "6.1.11",
"@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29",
"@typescript-eslint/eslint-plugin": "7.1.0",
"@typescript-eslint/parser": "7.1.0",
"@typescript-eslint/eslint-plugin": "7.4.0",
"@typescript-eslint/parser": "7.4.0",
"@web/dev-server": "0.1.38",
"@web/dev-server-rollup": "0.4.1",
"babel-loader": "9.1.3",
@ -195,7 +195,7 @@
"del": "7.1.0",
"eslint": "8.57.0",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-airbnb-typescript": "17.1.0",
"eslint-config-airbnb-typescript": "18.0.0",
"eslint-config-prettier": "9.1.0",
"eslint-import-resolver-webpack": "0.13.8",
"eslint-plugin-disable": "2.0.3",
@ -210,7 +210,7 @@
"gulp": "4.0.2",
"gulp-flatmap": "1.0.2",
"gulp-json-transform": "0.5.0",
"gulp-merge-json": "2.1.2",
"gulp-merge-json": "2.2.1",
"gulp-rename": "2.0.0",
"gulp-zopfli-green": "6.0.1",
"html-minifier-terser": "7.2.0",
@ -220,11 +220,11 @@
"lint-staged": "15.2.2",
"lit-analyzer": "2.0.3",
"lodash.template": "4.5.0",
"magic-string": "0.30.7",
"magic-string": "0.30.8",
"map-stream": "0.0.7",
"mocha": "10.3.0",
"object-hash": "3.0.0",
"open": "10.0.4",
"open": "10.1.0",
"pinst": "3.0.0",
"prettier": "3.2.5",
"rollup": "2.79.1",
@ -235,16 +235,16 @@
"sinon": "17.0.1",
"source-map-url": "0.4.1",
"systemjs": "6.14.3",
"tar": "6.2.0",
"tar": "6.2.1",
"terser-webpack-plugin": "5.3.10",
"transform-async-modules-webpack-plugin": "1.0.2",
"transform-async-modules-webpack-plugin": "1.0.4",
"ts-lit-plugin": "2.0.2",
"typescript": "5.3.3",
"typescript": "5.4.3",
"vinyl-buffer": "1.0.1",
"vinyl-source-stream": "2.0.0",
"webpack": "5.90.3",
"webpack": "5.91.0",
"webpack-cli": "5.1.4",
"webpack-dev-server": "5.0.2",
"webpack-dev-server": "5.0.4",
"webpack-manifest-plugin": "5.0.0",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "6.0.1",
@ -260,5 +260,5 @@
"sortablejs@1.15.2": "patch:sortablejs@npm%3A1.15.2#~/.yarn/patches/sortablejs-npm-1.15.2-73347ae85a.patch",
"leaflet-draw@1.0.4": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch"
},
"packageManager": "yarn@4.1.0"
"packageManager": "yarn@4.1.1"
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 52 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 69 KiB

View File

@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20240228.0"
version = "20240328.0"
license = {text = "Apache-2.0"}
description = "The Home Assistant frontend"
readme = "README.md"
authors = [
{name = "The Home Assistant Authors", email = "hello@home-assistant.io"}
]
requires-python = ">=3.10.0"
requires-python = ">=3.11.0"
[project.urls]
"Homepage" = "https://github.com/home-assistant/frontend"

View File

@ -1,3 +1,5 @@
import { theme2hex } from "./convert-color";
export const COLORS = [
"#44739e",
"#984ea3",
@ -65,10 +67,10 @@ export function getColorByIndex(index: number) {
export function getGraphColorByIndex(
index: number,
style: CSSStyleDeclaration
) {
): string {
// The CSS vars for the colors use range 1..n, so we need to adjust the index from the internal 0..n color index range.
return (
const themeColor =
style.getPropertyValue(`--graph-color-${index + 1}`) ||
getColorByIndex(index)
);
getColorByIndex(index);
return theme2hex(themeColor);
}

View File

@ -1,3 +1,4 @@
import colors from "color-name";
import { expandHex } from "./hex";
const rgb_hex = (component: number): string => {
@ -126,3 +127,18 @@ export const rgb2hs = (rgb: [number, number, number]): [number, number] =>
export const hs2rgb = (hs: [number, number]): [number, number, number] =>
hsv2rgb([hs[0], hs[1], 255]);
export function theme2hex(themeColor: string): string {
if (themeColor.startsWith("#")) {
return themeColor;
}
const rgbFromColorName = colors[themeColor];
if (!rgbFromColorName) {
// We have a named color, and there's nothing in the table,
// so nothing further we can do with it.
// Compare/border/background color will all be the same.
return themeColor;
}
return rgb2hex(rgbFromColorName);
}

View File

@ -231,6 +231,7 @@ export const SENSOR_ENTITIES = [
"calendar",
"camera",
"device_tracker",
"image",
"weather",
];

View File

@ -37,3 +37,20 @@ export const calcDateProperty = (
locale.time_zone === TimeZone.server
? (calcZonedDate(date, config.time_zone, fn, options) as number | boolean)
: fn(date, options);
export const calcDateDifferenceProperty = (
endDate: Date,
startDate: Date,
fn: (date: Date, options?: any) => boolean | number,
locale: FrontendLocaleData,
config: HassConfig
) =>
calcDateProperty(
endDate,
fn,
locale,
config,
locale.time_zone === TimeZone.server
? utcToZonedTime(startDate, config.time_zone)
: startDate
);

View File

@ -16,6 +16,7 @@ import { customElement, property, state, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { getGraphColorByIndex } from "../../common/color/colors";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event";
import {
formatNumber,
numberFormatToLocale,
@ -25,6 +26,7 @@ import {
getDisplayUnit,
getStatisticLabel,
getStatisticMetadata,
isExternalStatistic,
Statistics,
statisticsHaveType,
StatisticsMetaData,
@ -79,6 +81,8 @@ export class StatisticsChart extends LitElement {
@property({ type: Boolean }) public isLoadingData = false;
@property({ type: Boolean }) public clickForMoreInfo = true;
@property() public period?: string;
@state() private _chartData: ChartData = { datasets: [] };
@ -273,6 +277,33 @@ export class StatisticsChart extends LitElement {
},
// @ts-expect-error
locale: numberFormatToLocale(this.hass.locale),
onClick: (e: any) => {
if (
!this.clickForMoreInfo ||
!(e.native instanceof MouseEvent) ||
(e.native instanceof PointerEvent && e.native.pointerType !== "mouse")
) {
return;
}
const chart = e.chart;
const points = chart.getElementsAtEventForMode(
e,
"nearest",
{ intersect: true },
true
);
if (points.length) {
const firstPoint = points[0];
const statisticId = this._statisticIds[firstPoint.datasetIndex];
if (!isExternalStatistic(statisticId)) {
fireEvent(this, "hass-more-info", { entityId: statisticId });
chart.canvas.dispatchEvent(new Event("mouseout")); // to hide tooltip
}
}
},
};
}

View File

@ -4,22 +4,32 @@ import { css, html } from "lit";
import { customElement, property } from "lit/decorators";
@customElement("ha-assist-chip")
// @ts-ignore
export class HaAssistChip extends MdAssistChip {
@property({ type: Boolean, reflect: true }) filled = false;
@property({ type: Boolean }) active = false;
static override styles = [
...super.styles,
css`
:host {
--md-sys-color-primary: var(--primary-text-color);
--md-sys-color-on-surface: var(--primary-text-color);
--md-assist-chip-container-shape: 16px;
--md-assist-chip-container-shape: var(
--ha-assist-chip-container-shape,
16px
);
--md-assist-chip-outline-color: var(--outline-color);
--md-assist-chip-label-text-weight: 400;
--ha-assist-chip-filled-container-color: rgba(
var(--rgb-primary-text-color),
0.15
);
--ha-assist-chip-active-container-color: rgba(
var(--rgb-primary-color),
0.15
);
}
/** Material 3 doesn't have a filled chip, so we have to make our own **/
.filled {
@ -31,10 +41,21 @@ export class HaAssistChip extends MdAssistChip {
background-color: var(--ha-assist-chip-filled-container-color);
}
/** Set the size of mdc icons **/
::slotted([slot="icon"]) {
::slotted([slot="icon"]),
::slotted([slot="trailingIcon"]) {
display: flex;
--mdc-icon-size: var(--md-input-chip-icon-size, 18px);
}
.trailing.icon ::slotted(*),
.trailing.icon svg {
margin-inline-end: unset;
margin-inline-start: var(--_icon-label-space);
}
:where(.active)::before {
background: var(--ha-assist-chip-active-container-color);
opacity: var(--ha-assist-chip-active-container-opacity);
}
`,
];
@ -45,6 +66,30 @@ export class HaAssistChip extends MdAssistChip {
return super.renderOutline();
}
protected override getContainerClasses() {
return {
...super.getContainerClasses(),
active: this.active,
};
}
protected override renderPrimaryContent() {
return html`
<span class="leading icon" aria-hidden="true">
${this.renderLeadingIcon()}
</span>
<span class="label">${this.label}</span>
<span class="touch"></span>
<span class="trailing leading icon" aria-hidden="true">
${this.renderTrailingIcon()}
</span>
`;
}
protected renderTrailingIcon() {
return html`<slot name="trailing-icon"></slot>`;
}
}
declare global {

View File

@ -19,12 +19,16 @@ export class HaInputChip extends MdInputChip {
var(--rgb-primary-text-color),
0.15
);
--ha-input-chip-selected-container-opacity: 1;
}
/** Set the size of mdc icons **/
::slotted([slot="icon"]) {
display: flex;
--mdc-icon-size: var(--md-input-chip-icon-size, 18px);
}
.selected::before {
opacity: var(--ha-input-chip-selected-container-opacity);
}
`,
];
}

View File

@ -0,0 +1,128 @@
import { css, html, LitElement, nothing, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { LabelRegistryEntry } from "../../data/label_registry";
import { computeCssColor } from "../../common/color/compute-color";
import { fireEvent } from "../../common/dom/fire_event";
import "../ha-label";
@customElement("ha-data-table-labels")
class HaDataTableLabels extends LitElement {
@property({ attribute: false }) public labels!: LabelRegistryEntry[];
protected render(): TemplateResult {
return html`
<ha-chip-set>
${repeat(
this.labels.slice(0, 2),
(label) => label.label_id,
(label) => this._renderLabel(label, true)
)}
${this.labels.length > 2
? html`<ha-button-menu
absolute
role="button"
tabindex="0"
@click=${this._handleIconOverflowMenuOpened}
@closed=${this._handleIconOverflowMenuClosed}
>
<ha-label slot="trigger" class="plus" dense>
+${this.labels.length - 2}
</ha-label>
${repeat(
this.labels.slice(2),
(label) => label.label_id,
(label) => html`
<ha-list-item @click=${this._labelClicked} .item=${label}>
${this._renderLabel(label, false)}
</ha-list-item>
`
)}
</ha-button-menu>`
: nothing}
</ha-chip-set>
`;
}
private _renderLabel(label: LabelRegistryEntry, clickAction: boolean) {
const color = label?.color ? computeCssColor(label.color) : undefined;
return html`
<ha-label
dense
role="button"
tabindex="0"
.item=${label}
@click=${clickAction ? this._labelClicked : undefined}
@keydown=${clickAction ? this._labelClicked : undefined}
style=${color ? `--color: ${color}` : ""}
>
${label?.icon
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
: nothing}
${label.name}
</ha-label>
`;
}
private _labelClicked(ev) {
ev.stopPropagation();
if (ev.type === "keydown" && ev.key !== "Enter" && ev.key !== " ") {
return;
}
const label = (ev.currentTarget as any).item as LabelRegistryEntry;
fireEvent(this, "label-clicked", { label });
}
protected _handleIconOverflowMenuOpened(e) {
e.stopPropagation();
// If this component is used inside a data table, the z-index of the row
// needs to be increased. Otherwise the ha-button-menu would be displayed
// underneath the next row in the table.
const row = this.closest(".mdc-data-table__row") as HTMLDivElement | null;
if (row) {
row.style.zIndex = "1";
}
}
protected _handleIconOverflowMenuClosed() {
const row = this.closest(".mdc-data-table__row") as HTMLDivElement | null;
if (row) {
row.style.zIndex = "";
}
}
static get styles() {
return css`
:host {
display: block;
flex-grow: 1;
margin-top: 4px;
height: 22px;
}
ha-chip-set {
position: fixed;
flex-wrap: nowrap;
}
ha-label {
--ha-label-background-color: var(--color, var(--grey-color));
--ha-label-background-opacity: 0.5;
}
ha-button-menu {
border-radius: 10px;
}
.plus {
--ha-label-background-color: transparent;
border: 1px solid var(--divider-color);
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-data-table-labels": HaDataTableLabels;
}
interface HASSDomEvents {
"label-clicked": { label: LabelRegistryEntry };
}
}

View File

@ -32,6 +32,7 @@ import type { HaCheckbox } from "../ha-checkbox";
import "../ha-svg-icon";
import "../search-input";
import { filterData, sortData } from "./sort-filter";
import { groupBy } from "../../common/util/group-by";
declare global {
// for fire event
@ -67,13 +68,20 @@ export interface DataTableSortColumnData {
filterKey?: string;
valueColumn?: string;
direction?: SortingDirection;
groupable?: boolean;
}
export interface DataTableColumnData<T = any> extends DataTableSortColumnData {
main?: boolean;
title: TemplateResult | string;
label?: TemplateResult | string;
type?: "numeric" | "icon" | "icon-button" | "overflow-menu" | "flex";
type?:
| "numeric"
| "icon"
| "icon-button"
| "overflow"
| "overflow-menu"
| "flex";
template?: (row: T) => TemplateResult | string | typeof nothing;
width?: string;
maxWidth?: string;
@ -95,6 +103,8 @@ export interface SortableColumnContainer {
[key: string]: ClonedDataTableColumnData;
}
const UNDEFINED_GROUP_KEY = "zzzzz_undefined";
@customElement("ha-data-table")
export class HaDataTable extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@ -129,14 +139,16 @@ export class HaDataTable extends LitElement {
@property({ type: String }) public filter = "";
@property() public groupColumn?: string;
@property() public sortColumn?: string;
@property() public sortDirection: SortingDirection = null;
@state() private _filterable = false;
@state() private _filter = "";
@state() private _sortColumn?: string;
@state() private _sortDirection: SortingDirection = null;
@state() private _filteredData: DataTableRowData[] = [];
@state() private _headerHeight = 0;
@ -195,8 +207,14 @@ export class HaDataTable extends LitElement {
for (const columnId in this.columns) {
if (this.columns[columnId].direction) {
this._sortDirection = this.columns[columnId].direction!;
this._sortColumn = columnId;
this.sortDirection = this.columns[columnId].direction!;
this.sortColumn = columnId;
fireEvent(this, "sorting-changed", {
column: columnId,
direction: this.sortDirection,
});
break;
}
}
@ -226,11 +244,16 @@ export class HaDataTable extends LitElement {
properties.has("data") ||
properties.has("columns") ||
properties.has("_filter") ||
properties.has("_sortColumn") ||
properties.has("_sortDirection")
properties.has("sortColumn") ||
properties.has("sortDirection") ||
properties.has("groupColumn")
) {
this._sortFilterData();
}
if (properties.has("selectable")) {
this._items = [...this._items];
}
}
protected render() {
@ -263,75 +286,79 @@ export class HaDataTable extends LitElement {
})}
>
<div class="mdc-data-table__header-row" role="row" aria-rowindex="1">
${this.selectable
? html`
<div
class="mdc-data-table__header-cell mdc-data-table__header-cell--checkbox"
role="columnheader"
>
<ha-checkbox
class="mdc-data-table__row-checkbox"
@change=${this._handleHeaderRowCheckboxClick}
.indeterminate=${this._checkedRows.length &&
this._checkedRows.length !== this._checkableRowsCount}
.checked=${this._checkedRows.length &&
this._checkedRows.length === this._checkableRowsCount}
<slot name="header-row">
${this.selectable
? html`
<div
class="mdc-data-table__header-cell mdc-data-table__header-cell--checkbox"
role="columnheader"
>
</ha-checkbox>
<ha-checkbox
class="mdc-data-table__row-checkbox"
@change=${this._handleHeaderRowCheckboxClick}
.indeterminate=${this._checkedRows.length &&
this._checkedRows.length !== this._checkableRowsCount}
.checked=${this._checkedRows.length &&
this._checkedRows.length === this._checkableRowsCount}
>
</ha-checkbox>
</div>
`
: ""}
${Object.entries(this.columns).map(([key, column]) => {
if (column.hidden) {
return "";
}
const sorted = key === this.sortColumn;
const classes = {
"mdc-data-table__header-cell--numeric":
column.type === "numeric",
"mdc-data-table__header-cell--icon": column.type === "icon",
"mdc-data-table__header-cell--icon-button":
column.type === "icon-button",
"mdc-data-table__header-cell--overflow-menu":
column.type === "overflow-menu",
"mdc-data-table__header-cell--overflow":
column.type === "overflow",
sortable: Boolean(column.sortable),
"not-sorted": Boolean(column.sortable && !sorted),
grows: Boolean(column.grows),
};
return html`
<div
aria-label=${ifDefined(column.label)}
class="mdc-data-table__header-cell ${classMap(classes)}"
style=${column.width
? styleMap({
[column.grows ? "minWidth" : "width"]: column.width,
maxWidth: column.maxWidth || "",
})
: ""}
role="columnheader"
aria-sort=${ifDefined(
sorted
? this.sortDirection === "desc"
? "descending"
: "ascending"
: undefined
)}
@click=${this._handleHeaderClick}
.columnId=${key}
>
${column.sortable
? html`
<ha-svg-icon
.path=${sorted && this.sortDirection === "desc"
? mdiArrowDown
: mdiArrowUp}
></ha-svg-icon>
`
: ""}
<span>${column.title}</span>
</div>
`
: ""}
${Object.entries(this.columns).map(([key, column]) => {
if (column.hidden) {
return "";
}
const sorted = key === this._sortColumn;
const classes = {
"mdc-data-table__header-cell--numeric":
column.type === "numeric",
"mdc-data-table__header-cell--icon": column.type === "icon",
"mdc-data-table__header-cell--icon-button":
column.type === "icon-button",
"mdc-data-table__header-cell--overflow-menu":
column.type === "overflow-menu",
sortable: Boolean(column.sortable),
"not-sorted": Boolean(column.sortable && !sorted),
grows: Boolean(column.grows),
};
return html`
<div
aria-label=${ifDefined(column.label)}
class="mdc-data-table__header-cell ${classMap(classes)}"
style=${column.width
? styleMap({
[column.grows ? "minWidth" : "width"]: column.width,
maxWidth: column.maxWidth || "",
})
: ""}
role="columnheader"
aria-sort=${ifDefined(
sorted
? this._sortDirection === "desc"
? "descending"
: "ascending"
: undefined
)}
@click=${this._handleHeaderClick}
.columnId=${key}
>
${column.sortable
? html`
<ha-svg-icon
.path=${sorted && this._sortDirection === "desc"
? mdiArrowDown
: mdiArrowUp}
></ha-svg-icon>
`
: ""}
<span>${column.title}</span>
</div>
`;
})}
`;
})}
</slot>
</div>
${!this._filteredData.length
? html`
@ -408,7 +435,7 @@ export class HaDataTable extends LitElement {
: ""}
${Object.entries(this.columns).map(([key, column]) => {
if (column.hidden) {
return "";
return nothing;
}
return html`
<div
@ -421,6 +448,7 @@ export class HaDataTable extends LitElement {
column.type === "icon-button",
"mdc-data-table__cell--overflow-menu":
column.type === "overflow-menu",
"mdc-data-table__cell--overflow": column.type === "overflow",
grows: Boolean(column.grows),
forceLTR: Boolean(column.forceLTR),
})}"
@ -453,12 +481,12 @@ export class HaDataTable extends LitElement {
);
}
const prom = this._sortColumn
const prom = this.sortColumn
? sortData(
filteredData,
this._sortColumns[this._sortColumn],
this._sortDirection,
this._sortColumn,
this._sortColumns[this.sortColumn],
this.sortDirection,
this.sortColumn,
this.hass.locale.language
)
: filteredData;
@ -477,17 +505,56 @@ export class HaDataTable extends LitElement {
return;
}
if (this.appendRow || this.hasFab) {
if (this.appendRow || this.hasFab || this.groupColumn) {
const items = [...data];
if (this.appendRow) {
items.push({ append: true, content: this.appendRow });
}
if (this.hasFab) {
items.push({ empty: true });
if (this.groupColumn) {
const grouped = groupBy(items, (item) => item[this.groupColumn!]);
if (grouped.undefined) {
// make sure ungrouped items are at the bottom
grouped[UNDEFINED_GROUP_KEY] = grouped.undefined;
delete grouped.undefined;
}
const sorted: {
[key: string]: DataTableRowData[];
} = Object.keys(grouped)
.sort()
.reduce((obj, key) => {
obj[key] = grouped[key];
return obj;
}, {});
const groupedItems: DataTableRowData[] = [];
Object.entries(sorted).forEach(([groupName, rows]) => {
if (
groupName !== UNDEFINED_GROUP_KEY ||
Object.keys(sorted).length > 1
) {
groupedItems.push({
append: true,
content: html`<div
class="mdc-data-table__cell group-header"
role="cell"
>
${groupName === UNDEFINED_GROUP_KEY ? "" : groupName || ""}
</div>`,
});
}
groupedItems.push(...rows);
});
this._items = groupedItems;
} else {
this._items = items;
}
if (this.hasFab) {
this._items = [...this._items, { empty: true }];
}
this._items = items;
} else {
this._items = data;
}
@ -507,19 +574,19 @@ export class HaDataTable extends LitElement {
if (!this.columns[columnId].sortable) {
return;
}
if (!this._sortDirection || this._sortColumn !== columnId) {
this._sortDirection = "asc";
} else if (this._sortDirection === "asc") {
this._sortDirection = "desc";
if (!this.sortDirection || this.sortColumn !== columnId) {
this.sortDirection = "asc";
} else if (this.sortDirection === "asc") {
this.sortDirection = "desc";
} else {
this._sortDirection = null;
this.sortDirection = null;
}
this._sortColumn = this._sortDirection === null ? undefined : columnId;
this.sortColumn = this.sortDirection === null ? undefined : columnId;
fireEvent(this, "sorting-changed", {
column: columnId,
direction: this._sortDirection,
direction: this.sortDirection,
});
}
@ -552,8 +619,15 @@ export class HaDataTable extends LitElement {
};
private _handleRowClick = (ev: Event) => {
const target = ev.target as HTMLElement;
if (["HA-CHECKBOX", "MWC-BUTTON"].includes(target.tagName)) {
if (
ev
.composedPath()
.find((el) =>
["ha-checkbox", "mwc-button", "ha-button", "ha-assist-chip"].includes(
(el as HTMLElement).localName
)
)
) {
return;
}
const rowId = (ev.currentTarget as any).rowId;
@ -629,7 +703,7 @@ export class HaDataTable extends LitElement {
.mdc-data-table__row {
display: flex;
width: 100%;
height: 52px;
height: var(--data-table-row-height, 52px);
}
.mdc-data-table__row ~ .mdc-data-table__row {
@ -655,7 +729,6 @@ export class HaDataTable extends LitElement {
display: flex;
width: 100%;
border-bottom: 1px solid var(--divider-color);
overflow-x: auto;
}
.mdc-data-table__header-row::-webkit-scrollbar {
@ -809,7 +882,9 @@ export class HaDataTable extends LitElement {
padding-inline-start: initial;
}
.mdc-data-table__cell--overflow-menu,
.mdc-data-table__header-cell--overflow-menu {
.mdc-data-table__cell--overflow,
.mdc-data-table__header-cell--overflow-menu,
.mdc-data-table__header-cell--overflow {
overflow: initial;
}
.mdc-data-table__cell--icon-button a {
@ -839,6 +914,12 @@ export class HaDataTable extends LitElement {
/* custom from here */
.group-header {
padding-top: 12px;
width: 100%;
font-weight: 500;
}
:host {
display: block;
}

View File

@ -1,12 +1,12 @@
import { mdiSofa } from "@mdi/js";
import { mdiTextureBox } from "@mdi/js";
import { CSSResultGroup, LitElement, TemplateResult, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { showAreaFilterDialog } from "../dialogs/area-filter/show-area-filter-dialog";
import { HomeAssistant } from "../types";
import "./ha-icon-next";
import "./ha-svg-icon";
import "./ha-textfield";
import "./ha-icon-next";
export type AreaFilterValue = {
hidden?: string[];
@ -51,7 +51,7 @@ export class HaAreaPicker extends LitElement {
@keydown=${this._edit}
.disabled=${this.disabled}
>
<ha-svg-icon slot="graphic" .path=${mdiSofa}></ha-svg-icon>
<ha-svg-icon slot="graphic" .path=${mdiTextureBox}></ha-svg-icon>
<span>${this.label}</span>
<span slot="secondary">${description}</span>
<ha-icon-next

View File

@ -0,0 +1,504 @@
import { mdiTextureBox } from "@mdi/js";
import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import { LitElement, PropertyValues, TemplateResult, html } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import { stringCompare } from "../common/string/compare";
import {
ScorableTextItem,
fuzzyFilterSort,
} from "../common/string/filter/sequence-matching";
import { AreaRegistryEntry } from "../data/area_registry";
import {
DeviceEntityDisplayLookup,
DeviceRegistryEntry,
getDeviceEntityDisplayLookup,
} from "../data/device_registry";
import { EntityRegistryDisplayEntry } from "../data/entity_registry";
import {
FloorRegistryEntry,
getFloorAreaLookup,
subscribeFloorRegistry,
} from "../data/floor_registry";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { HomeAssistant, ValueChangedEvent } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box";
import "./ha-floor-icon";
import "./ha-icon-button";
import "./ha-list-item";
import "./ha-svg-icon";
type ScorableAreaFloorEntry = ScorableTextItem & FloorAreaEntry;
interface FloorAreaEntry {
id: string | null;
name: string;
icon: string | null;
strings: string[];
type: "floor" | "area";
hasFloor?: boolean;
level: number | null;
}
const rowRenderer: ComboBoxLitRenderer<FloorAreaEntry> = (item) =>
html`<ha-list-item
graphic="icon"
style=${item.type === "area" && item.hasFloor
? "--mdc-list-side-padding-left: 48px;"
: ""}
>
${item.type === "floor"
? html`<ha-floor-icon slot="graphic" .floor=${item}></ha-floor-icon>`
: item.icon
? html`<ha-icon slot="graphic" .icon=${item.icon}></ha-icon>`
: html`<ha-svg-icon
slot="graphic"
.path=${mdiTextureBox}
></ha-svg-icon>`}
${item.name}
</ha-list-item>`;
@customElement("ha-area-floor-picker")
export class HaAreaFloorPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property() public value?: string;
@property() public helper?: string;
@property() public placeholder?: string;
/**
* Show only areas with entities from specific domains.
* @type {Array}
* @attr include-domains
*/
@property({ type: Array, attribute: "include-domains" })
public includeDomains?: string[];
/**
* Show no areas with entities of these domains.
* @type {Array}
* @attr exclude-domains
*/
@property({ type: Array, attribute: "exclude-domains" })
public excludeDomains?: string[];
/**
* Show only areas with entities of these device classes.
* @type {Array}
* @attr include-device-classes
*/
@property({ type: Array, attribute: "include-device-classes" })
public includeDeviceClasses?: string[];
/**
* List of areas to be excluded.
* @type {Array}
* @attr exclude-areas
*/
@property({ type: Array, attribute: "exclude-areas" })
public excludeAreas?: string[];
/**
* List of floors to be excluded.
* @type {Array}
* @attr exclude-floors
*/
@property({ type: Array, attribute: "exclude-floors" })
public excludeFloors?: string[];
@property({ attribute: false })
public deviceFilter?: HaDevicePickerDeviceFilterFunc;
@property({ attribute: false })
public entityFilter?: (entity: HassEntity) => boolean;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@state() private _floors?: FloorRegistryEntry[];
@state() private _opened?: boolean;
@query("ha-combo-box", true) public comboBox!: HaComboBox;
private _init = false;
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [
subscribeFloorRegistry(this.hass.connection, (floors) => {
this._floors = floors;
}),
];
}
public async open() {
await this.updateComplete;
await this.comboBox?.open();
}
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
}
private _getAreas = memoizeOne(
(
floors: FloorRegistryEntry[],
areas: AreaRegistryEntry[],
devices: DeviceRegistryEntry[],
entities: EntityRegistryDisplayEntry[],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"],
excludeAreas: this["excludeAreas"],
excludeFloors: this["excludeFloors"]
): FloorAreaEntry[] => {
if (!areas.length && !floors.length) {
return [
{
id: "no_areas",
type: "area",
name: this.hass.localize("ui.components.area-picker.no_areas"),
icon: null,
strings: [],
level: null,
},
];
}
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined;
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
if (
includeDomains ||
excludeDomains ||
includeDeviceClasses ||
deviceFilter ||
entityFilter
) {
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
inputDevices = devices;
inputEntities = entities.filter((entity) => entity.area_id);
if (includeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
}
if (excludeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return true;
}
return entities.every(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
}
if (includeDeviceClasses) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
}
if (deviceFilter) {
inputDevices = inputDevices!.filter((device) =>
deviceFilter!(device)
);
}
if (entityFilter) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter(stateObj);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter!(stateObj);
});
}
}
let outputAreas = areas;
let areaIds: string[] | undefined;
if (inputDevices) {
areaIds = inputDevices
.filter((device) => device.area_id)
.map((device) => device.area_id!);
}
if (inputEntities) {
areaIds = (areaIds ?? []).concat(
inputEntities
.filter((entity) => entity.area_id)
.map((entity) => entity.area_id!)
);
}
if (areaIds) {
outputAreas = outputAreas.filter((area) =>
areaIds!.includes(area.area_id)
);
}
if (excludeAreas) {
outputAreas = outputAreas.filter(
(area) => !excludeAreas!.includes(area.area_id)
);
}
if (excludeFloors) {
outputAreas = outputAreas.filter(
(area) => !area.floor_id || !excludeFloors!.includes(area.floor_id)
);
}
if (!outputAreas.length) {
return [
{
id: "no_areas",
type: "area",
name: this.hass.localize("ui.components.area-picker.no_match"),
icon: null,
strings: [],
level: null,
},
];
}
const floorAreaLookup = getFloorAreaLookup(outputAreas);
const unassisgnedAreas = Object.values(outputAreas).filter(
(area) => !area.floor_id || !floorAreaLookup[area.floor_id]
);
// @ts-ignore
const floorAreaEntries: Array<
[FloorRegistryEntry | undefined, AreaRegistryEntry[]]
> = Object.entries(floorAreaLookup)
.map(([floorId, floorAreas]) => {
const floor = floors.find((fl) => fl.floor_id === floorId)!;
return [floor, floorAreas] as const;
})
.sort(([floorA], [floorB]) => {
if (floorA.level !== floorB.level) {
return (floorA.level ?? 0) - (floorB.level ?? 0);
}
return stringCompare(floorA.name, floorB.name);
});
const output: FloorAreaEntry[] = [];
floorAreaEntries.forEach(([floor, floorAreas]) => {
if (floor) {
output.push({
id: floor.floor_id,
type: "floor",
name: floor.name,
icon: floor.icon,
strings: [floor.floor_id, ...floor.aliases, floor.name],
level: floor.level,
});
}
output.push(
...floorAreas.map((area) => ({
id: area.area_id,
type: "area" as const,
name: area.name,
icon: area.icon,
strings: [area.area_id, ...area.aliases, area.name],
hasFloor: true,
level: null,
}))
);
});
if (!output.length && !unassisgnedAreas.length) {
output.push({
id: "no_areas",
type: "area",
name: this.hass.localize(
"ui.components.area-picker.unassigned_areas"
),
icon: null,
strings: [],
level: null,
});
}
output.push(
...unassisgnedAreas.map((area) => ({
id: area.area_id,
type: "area" as const,
name: area.name,
icon: area.icon,
strings: [area.area_id, ...area.aliases, area.name],
level: null,
}))
);
return output;
}
);
protected updated(changedProps: PropertyValues) {
if (
(!this._init && this.hass && this._floors) ||
(this._init && changedProps.has("_opened") && this._opened)
) {
this._init = true;
const areas = this._getAreas(
this._floors!,
Object.values(this.hass.areas),
Object.values(this.hass.devices),
Object.values(this.hass.entities),
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.excludeAreas,
this.excludeFloors
);
this.comboBox.items = areas;
this.comboBox.filteredItems = areas;
}
}
protected render(): TemplateResult {
return html`
<ha-combo-box
.hass=${this.hass}
.helper=${this.helper}
item-value-path="id"
item-id-path="id"
item-label-path="name"
.value=${this._value}
.disabled=${this.disabled}
.required=${this.required}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.area-picker.area")
: this.label}
.placeholder=${this.placeholder
? this.hass.areas[this.placeholder]?.name
: undefined}
.renderer=${rowRenderer}
@filter-changed=${this._filterChanged}
@opened-changed=${this._openedChanged}
@value-changed=${this._areaChanged}
>
</ha-combo-box>
`;
}
private _filterChanged(ev: CustomEvent): void {
const target = ev.target as HaComboBox;
const filterString = ev.detail.value;
if (!filterString) {
this.comboBox.filteredItems = this.comboBox.items;
return;
}
const filteredItems = fuzzyFilterSort<ScorableAreaFloorEntry>(
filterString,
target.items || []
);
this.comboBox.filteredItems = filteredItems;
}
private get _value() {
return this.value || "";
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private async _areaChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const newValue = ev.detail.value;
if (newValue === "no_areas") {
return;
}
const selected = this.comboBox.selectedItem;
fireEvent(this, "value-changed", {
value: {
id: selected.id,
type: selected.type,
},
});
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-area-floor-picker": HaAreaFloorPicker;
}
}

View File

@ -1,14 +1,15 @@
import { mdiTextureBox } from "@mdi/js";
import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing, PropertyValues, TemplateResult } from "lit";
import { LitElement, PropertyValues, TemplateResult, html } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import {
fuzzyFilterSort,
ScorableTextItem,
fuzzyFilterSort,
} from "../common/string/filter/sequence-matching";
import {
AreaRegistryEntry,
@ -41,7 +42,7 @@ const rowRenderer: ComboBoxLitRenderer<AreaRegistryEntry> = (item) =>
>
${item.icon
? html`<ha-icon slot="graphic" .icon=${item.icon}></ha-icon>`
: nothing}
: html`<ha-svg-icon slot="graphic" .path=${mdiTextureBox}></ha-svg-icon>`}
${item.name}
</ha-list-item>`;
@ -137,10 +138,12 @@ export class HaAreaPicker extends LitElement {
return [
{
area_id: "no_areas",
floor_id: null,
name: this.hass.localize("ui.components.area-picker.no_areas"),
picture: null,
icon: null,
aliases: [],
labels: [],
},
];
}
@ -282,10 +285,12 @@ export class HaAreaPicker extends LitElement {
outputAreas = [
{
area_id: "no_areas",
floor_id: null,
name: this.hass.localize("ui.components.area-picker.no_match"),
picture: null,
icon: null,
aliases: [],
labels: [],
},
];
}
@ -296,10 +301,12 @@ export class HaAreaPicker extends LitElement {
...outputAreas,
{
area_id: "add_new",
floor_id: null,
name: this.hass.localize("ui.components.area-picker.add_new"),
picture: null,
icon: "mdi:plus",
aliases: [],
labels: [],
},
];
}

View File

@ -1,221 +0,0 @@
import type { Corner } from "@material/mwc-menu";
import "@material/mwc-menu/mwc-menu-surface";
import { mdiFilterVariant } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { computeStateName } from "../common/entity/compute_state_name";
import { computeDeviceName } from "../data/device_registry";
import { findRelated, RelatedResult } from "../data/search";
import type { HomeAssistant } from "../types";
import "./device/ha-device-picker";
import "./entity/ha-entity-picker";
import "./ha-area-picker";
import "./ha-icon-button";
declare global {
// for fire event
interface HASSDomEvents {
"related-changed": {
value?: FilterValue;
items?: RelatedResult;
filter?: string;
};
}
}
interface FilterValue {
area?: string;
device?: string;
entity?: string;
}
@customElement("ha-button-related-filter-menu")
export class HaRelatedFilterButtonMenu extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public corner: Corner = "BOTTOM_START";
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ type: Boolean }) public disabled = false;
@property({ attribute: false }) public value?: FilterValue;
/**
* Show no entities of these domains.
* @type {Array}
* @attr exclude-domains
*/
@property({ type: Array, attribute: "exclude-domains" })
public excludeDomains?: string[];
@state() private _open = false;
protected render(): TemplateResult {
return html`
<ha-icon-button
@click=${this._handleClick}
.label=${this.hass.localize("ui.components.related-filter-menu.filter")}
.path=${mdiFilterVariant}
></ha-icon-button>
<mwc-menu-surface
.open=${this._open}
.anchor=${this}
.fullwidth=${this.narrow}
.corner=${this.corner}
@closed=${this._onClosed}
@input=${stopPropagation}
>
<ha-area-picker
.label=${this.hass.localize(
"ui.components.related-filter-menu.filter_by_area"
)}
.hass=${this.hass}
.value=${this.value?.area}
no-add
@value-changed=${this._areaPicked}
@click=${this._preventDefault}
></ha-area-picker>
<ha-device-picker
.label=${this.hass.localize(
"ui.components.related-filter-menu.filter_by_device"
)}
.hass=${this.hass}
.value=${this.value?.device}
@value-changed=${this._devicePicked}
@click=${this._preventDefault}
></ha-device-picker>
<ha-entity-picker
.label=${this.hass.localize(
"ui.components.related-filter-menu.filter_by_entity"
)}
.hass=${this.hass}
.value=${this.value?.entity}
.excludeDomains=${this.excludeDomains}
@value-changed=${this._entityPicked}
@click=${this._preventDefault}
></ha-entity-picker>
</mwc-menu-surface>
`;
}
private _handleClick(): void {
if (this.disabled) {
return;
}
this._open = true;
}
private _onClosed(ev): void {
ev.stopPropagation();
this._open = false;
}
private _preventDefault(ev) {
ev.preventDefault();
}
private async _entityPicked(ev: CustomEvent) {
ev.stopPropagation();
const entityId = ev.detail.value;
if (!entityId) {
fireEvent(this, "related-changed", { value: undefined });
return;
}
const filter = this.hass.localize(
"ui.components.related-filter-menu.filtered_by_entity",
{
entity_name: computeStateName(
(ev.currentTarget as any).comboBox.selectedItem
),
}
);
const items = await findRelated(this.hass, "entity", entityId);
fireEvent(this, "related-changed", {
value: { entity: entityId },
filter,
items,
});
}
private async _devicePicked(ev: CustomEvent) {
ev.stopPropagation();
const deviceId = ev.detail.value;
if (!deviceId) {
fireEvent(this, "related-changed", { value: undefined });
return;
}
const filter = this.hass.localize(
"ui.components.related-filter-menu.filtered_by_device",
{
device_name: computeDeviceName(
(ev.currentTarget as any).comboBox.selectedItem,
this.hass
),
}
);
const items = await findRelated(this.hass, "device", deviceId);
fireEvent(this, "related-changed", {
value: { device: deviceId },
filter,
items,
});
}
private async _areaPicked(ev: CustomEvent) {
ev.stopPropagation();
const areaId = ev.detail.value;
if (!areaId) {
fireEvent(this, "related-changed", { value: undefined });
return;
}
const filter = this.hass.localize(
"ui.components.related-filter-menu.filtered_by_area",
{ area_name: (ev.currentTarget as any).comboBox.selectedItem.name }
);
const items = await findRelated(this.hass, "area", areaId);
fireEvent(this, "related-changed", {
value: { area: areaId },
filter,
items,
});
}
static get styles(): CSSResultGroup {
return css`
:host {
display: inline-block;
position: relative;
--mdc-menu-min-width: 250px;
}
ha-area-picker,
ha-device-picker,
ha-entity-picker {
display: block;
width: 300px;
padding: 4px 16px;
box-sizing: border-box;
}
ha-area-picker {
padding-top: 16px;
}
ha-entity-picker {
padding-bottom: 16px;
}
:host([narrow]) ha-area-picker,
:host([narrow]) ha-device-picker,
:host([narrow]) ha-entity-picker {
width: 100%;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-button-related-filter-menu": HaRelatedFilterButtonMenu;
}
}

View File

@ -2,17 +2,15 @@ import "@material/mwc-list/mwc-list-item";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import {
computeCssColor,
THEME_COLORS,
} from "../../../common/color/compute-color";
import { fireEvent } from "../../../common/dom/fire_event";
import { stopPropagation } from "../../../common/dom/stop_propagation";
import "../../../components/ha-select";
import { HomeAssistant } from "../../../types";
import { computeCssColor, THEME_COLORS } from "../common/color/compute-color";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import "./ha-select";
import { HomeAssistant } from "../types";
import { LocalizeKeys } from "../common/translations/localize";
@customElement("hui-color-picker")
export class HuiColorPicker extends LitElement {
@customElement("ha-color-picker")
export class HaColorPicker extends LitElement {
@property() public label?: string;
@property() public helper?: string;
@ -21,6 +19,8 @@ export class HuiColorPicker extends LitElement {
@property() public value?: string;
@property({ type: Boolean }) public defaultColor = false;
@property({ type: Boolean }) public disabled = false;
_valueSelected(ev) {
@ -52,16 +52,16 @@ export class HuiColorPicker extends LitElement {
</span>
`
: nothing}
<mwc-list-item value="default">
${this.hass.localize(
`ui.panel.lovelace.editor.color-picker.default_color`
)}
</mwc-list-item>
${this.defaultColor
? html` <mwc-list-item value="default">
${this.hass.localize(`ui.components.color-picker.default_color`)}
</mwc-list-item>`
: nothing}
${Array.from(THEME_COLORS).map(
(color) => html`
<mwc-list-item .value=${color} graphic="icon">
${this.hass.localize(
`ui.panel.lovelace.editor.color-picker.colors.${color}`
`ui.components.color-picker.colors.${color}` as LocalizeKeys
) || color}
<span slot="graphic">${this.renderColorCircle(color)}</span>
</mwc-list-item>
@ -100,6 +100,6 @@ export class HuiColorPicker extends LitElement {
declare global {
interface HTMLElementTagNameMap {
"hui-color-picker": HuiColorPicker;
"ha-color-picker": HaColorPicker;
}
}

View File

@ -84,6 +84,7 @@ export class HaControlButton extends LitElement {
--control-button-background-color: var(--disabled-color);
--control-button-background-opacity: 0.2;
--control-button-border-radius: 10px;
--control-button-padding: 8px;
--mdc-icon-size: 20px;
color: var(--primary-text-color);
width: 40px;
@ -95,16 +96,20 @@ export class HaControlButton extends LitElement {
position: relative;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
width: 100%;
height: 100%;
border-radius: var(--control-button-border-radius);
border: none;
margin: 0;
padding: 0;
padding: var(--control-button-padding);
box-sizing: border-box;
line-height: 0;
line-height: inherit;
font-family: Roboto;
font-weight: 500;
outline: none;
overflow: hidden;
background: none;
@ -126,6 +131,8 @@ export class HaControlButton extends LitElement {
background-color 180ms ease-in-out,
opacity 180ms ease-in-out;
opacity: var(--control-button-background-opacity);
pointer-events: none;
white-space: normal;
}
.button {
transition: color 180ms ease-in-out;
@ -133,6 +140,7 @@ export class HaControlButton extends LitElement {
}
.button ::slotted(*) {
pointer-events: none;
opacity: 0.95;
}
.button:disabled {
cursor: not-allowed;

View File

@ -529,7 +529,7 @@ export class HaControlSlider extends LitElement {
0,
0
);
border-radius: 0 var(--border-radius) var(--border-radius) 0;
border-radius: 0 8px 8px 0;
}
.slider .slider-track-bar:after {
top: 0;
@ -546,7 +546,7 @@ export class HaControlSlider extends LitElement {
0,
0
);
border-radius: var(--border-radius) 0 0 var(--border-radius);
border-radius: 8px 0 0 8px;
}
.slider .slider-track-bar.end::after {
right: initial;
@ -561,7 +561,7 @@ export class HaControlSlider extends LitElement {
calc((1 - var(--value, 0)) * var(--slider-size)),
0
);
border-radius: var(--border-radius) var(--border-radius) 0 0;
border-radius: 8px 8px 0 0;
}
:host([vertical]) .slider .slider-track-bar:after {
top: var(--handle-margin);
@ -579,7 +579,7 @@ export class HaControlSlider extends LitElement {
calc((0 - var(--value, 0)) * var(--slider-size)),
0
);
border-radius: 0 0 var(--border-radius) var(--border-radius);
border-radius: 0 0 8px 8px;
}
:host([vertical]) .slider .slider-track-bar.end::after {
top: initial;

View File

@ -139,12 +139,12 @@ export class HaDialog extends DialogBase {
}
.header_button {
position: absolute;
right: -8px;
top: -8px;
right: -12px;
top: -12px;
text-decoration: none;
color: inherit;
inset-inline-start: initial;
inset-inline-end: -8px;
inset-inline-end: -12px;
direction: var(--direction);
}
.dialog-actions {

View File

@ -86,13 +86,11 @@ export class HaExpansionPanel extends LitElement {
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (changedProps.has("expanded") && this.expanded) {
if (changedProps.has("expanded")) {
this._showContent = this.expanded;
setTimeout(() => {
// Verify we're still expanded
if (this.expanded) {
this._container.style.overflow = "initial";
}
this._container.style.overflow = this.expanded ? "initial" : "hidden";
}, 300);
}
}

View File

@ -0,0 +1,175 @@
import { SelectedDetail } from "@material/mwc-list";
import "@material/mwc-menu/mwc-menu-surface";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { findRelated, RelatedResult } from "../data/search";
import type { HomeAssistant } from "../types";
import { haStyleScrollbar } from "../resources/styles";
import { Blueprints, fetchBlueprints } from "../data/blueprint";
@customElement("ha-filter-blueprints")
export class HaFilterBlueprints extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: string[];
@property() public type?: "automation" | "script";
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _blueprints?: Blueprints;
protected render() {
return html`
<ha-expansion-panel
leftChevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.blueprint.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>`
: nothing}
</div>
${this._blueprints && this._shouldRender
? html`
<mwc-list
@selected=${this._blueprintsSelected}
multi
class="ha-scrollbar"
>
${Object.entries(this._blueprints).map(([id, blueprint]) =>
"error" in blueprint
? nothing
: html`<ha-check-list-item
.value=${id}
.selected=${this.value?.includes(id)}
>
${blueprint.metadata.name || id}
</ha-check-list-item>`
)}
</mwc-list>
`
: nothing}
</ha-expansion-panel>
`;
}
protected async firstUpdated() {
if (!this.type) {
return;
}
this._blueprints = await fetchBlueprints(this.hass, this.type);
}
protected updated(changed) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (this.narrow || !this.expanded) return;
this.renderRoot.querySelector("mwc-list")!.style.height =
`${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
private async _blueprintsSelected(
ev: CustomEvent<SelectedDetail<Set<number>>>
) {
const blueprints = this._blueprints!;
const relatedPromises: Promise<RelatedResult>[] = [];
if (!ev.detail.index.size) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
const value: string[] = [];
for (const index of ev.detail.index) {
const blueprintId = Object.keys(blueprints)[index];
value.push(blueprintId);
if (this.type) {
relatedPromises.push(
findRelated(this.hass, `${this.type}_blueprint`, blueprintId)
);
}
}
this.value = value;
const results = await Promise.all(relatedPromises);
const items: Set<string> = new Set();
for (const result of results) {
if (result[this.type!]) {
result[this.type!]!.forEach((item) => items.add(item));
}
}
fireEvent(this, "data-table-filter-changed", {
value,
items: this.type ? items : undefined,
});
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: 0;
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
background-color: var(--primary-color);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-blueprints": HaFilterBlueprints;
}
}

View File

@ -0,0 +1,316 @@
import { ActionDetail, SelectedDetail } from "@material/mwc-list";
import {
mdiDelete,
mdiDotsVertical,
mdiPencil,
mdiPlus,
mdiTag,
} from "@mdi/js";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { CSSResultGroup, LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import {
CategoryRegistryEntry,
createCategoryRegistryEntry,
deleteCategoryRegistryEntry,
subscribeCategoryRegistry,
updateCategoryRegistryEntry,
} from "../data/category_registry";
import { showConfirmationDialog } from "../dialogs/generic/show-dialog-box";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { showCategoryRegistryDetailDialog } from "../panels/config/category/show-dialog-category-registry-detail";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-expansion-panel";
import "./ha-icon";
import "./ha-list-item";
import { stopPropagation } from "../common/dom/stop_propagation";
@customElement("ha-filter-categories")
export class HaFilterCategories extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: string[];
@property() public scope?: string;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _categories: CategoryRegistryEntry[] = [];
@state() private _shouldRender = false;
protected hassSubscribeRequiredHostProps = ["scope"];
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [
subscribeCategoryRegistry(
this.hass.connection,
this.scope!,
(categories) => {
this._categories = categories;
}
),
];
}
protected render() {
return html`
<ha-expansion-panel
leftChevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.category.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>`
: nothing}
</div>
${this._shouldRender
? html`
<mwc-list
@selected=${this._categorySelected}
class="ha-scrollbar"
activatable
>
${this._categories.length > 0
? html`<ha-list-item
.selected=${!this.value?.length}
.activated=${!this.value?.length}
>${this.hass.localize(
"ui.panel.config.category.filter.show_all"
)}</ha-list-item
>`
: nothing}
${this._categories.map(
(category) =>
html`<ha-list-item
.value=${category.category_id}
.selected=${this.value?.includes(category.category_id)}
.activated=${this.value?.includes(category.category_id)}
graphic="icon"
hasMeta
>
${category.icon
? html`<ha-icon
slot="graphic"
.icon=${category.icon}
></ha-icon>`
: html`<ha-svg-icon
.path=${mdiTag}
slot="graphic"
></ha-svg-icon>`}
${category.name}
<ha-button-menu
@click=${stopPropagation}
@action=${this._handleAction}
slot="meta"
fixed
.categoryId=${category.category_id}
>
<ha-icon-button
.path=${mdiDotsVertical}
slot="trigger"
></ha-icon-button>
<mwc-list-item graphic="icon"
><ha-svg-icon
.path=${mdiPencil}
slot="graphic"
></ha-svg-icon
>${this.hass.localize(
"ui.panel.config.category.editor.edit"
)}</mwc-list-item
>
<mwc-list-item graphic="icon" class="warning"
><ha-svg-icon
class="warning"
.path=${mdiDelete}
slot="graphic"
></ha-svg-icon
>${this.hass.localize(
"ui.panel.config.category.editor.delete"
)}</mwc-list-item
>
</ha-button-menu>
</ha-list-item>`
)}
</mwc-list>
`
: nothing}
</ha-expansion-panel>
${this.expanded
? html`<ha-list-item
graphic="icon"
@click=${this._addCategory}
class="add"
>
<ha-svg-icon slot="graphic" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.category.editor.add")}
</ha-list-item>`
: nothing}
`;
}
protected updated(changed) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("mwc-list")!.style.height =
`${this.clientHeight - (49 + 48)}px`;
}, 300);
}
}
private _handleAction(ev: CustomEvent<ActionDetail>) {
const categoryId = (ev.currentTarget as any).categoryId;
switch (ev.detail.index) {
case 0:
this._editCategory(categoryId);
break;
case 1:
this._deleteCategory(categoryId);
break;
}
}
private _editCategory(id: string) {
showCategoryRegistryDetailDialog(this, {
scope: this.scope!,
entry: this._categories.find((cat) => cat.category_id === id),
updateEntry: (updates) =>
updateCategoryRegistryEntry(this.hass, this.scope!, id, updates),
});
}
private async _deleteCategory(id: string) {
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.category.editor.confirm_delete"
),
text: this.hass.localize(
"ui.panel.config.category.editor.confirm_delete_text"
),
confirmText: this.hass.localize("ui.common.delete"),
destructive: true,
});
if (!confirm) {
return;
}
try {
await deleteCategoryRegistryEntry(this.hass, this.scope!, id);
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
} catch (err: any) {
alert(`Failed to delete: ${err.message}`);
}
}
private _addCategory() {
if (!this.scope) {
return;
}
showCategoryRegistryDetailDialog(this, {
scope: this.scope,
createEntry: (values) =>
createCategoryRegistryEntry(this.hass, this.scope!, values),
});
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
private async _categorySelected(ev: CustomEvent<SelectedDetail<number>>) {
if (!ev.detail.index) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
const index = ev.detail.index - 1;
const val = this._categories![index]?.category_id;
if (!val) {
return;
}
this.value = [val];
fireEvent(this, "data-table-filter-changed", {
value: this.value,
items: undefined,
});
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
border-bottom: 1px solid var(--divider-color);
position: relative;
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: 0;
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
background-color: var(--primary-color);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
mwc-list {
--mdc-list-item-meta-size: auto;
--mdc-list-side-padding-right: 4px;
--mdc-icon-button-size: 36px;
}
.warning {
color: var(--error-color);
}
.add {
position: absolute;
bottom: 0;
right: 0;
left: 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-categories": HaFilterCategories;
}
}

View File

@ -0,0 +1,206 @@
import {
css,
CSSResultGroup,
html,
LitElement,
nothing,
PropertyValues,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
import { computeDeviceName } from "../data/device_registry";
import { findRelated, RelatedResult } from "../data/search";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-expansion-panel";
import "./ha-check-list-item";
import { loadVirtualizer } from "../resources/virtualizer";
@customElement("ha-filter-devices")
export class HaFilterDevices extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: string[];
@property() public type?: keyof RelatedResult;
@property({ type: Boolean, reflect: true }) public expanded = false;
@property({ type: Boolean }) public narrow = false;
@state() private _shouldRender = false;
public willUpdate(properties: PropertyValues) {
super.willUpdate(properties);
if (!this.hasUpdated) {
loadVirtualizer();
}
}
protected render() {
return html`
<ha-expansion-panel
leftChevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.devices.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>`
: nothing}
</div>
${this._shouldRender
? html`<mwc-list class="ha-scrollbar">
<lit-virtualizer
.items=${this._devices(this.hass.devices)}
.renderItem=${this._renderItem}
@click=${this._handleItemClick}
>
</lit-virtualizer>
</mwc-list>`
: nothing}
</ha-expansion-panel>
`;
}
private _renderItem = (device) =>
html`<ha-check-list-item
.value=${device.id}
.selected=${this.value?.includes(device.id)}
>
${computeDeviceName(device, this.hass)}
</ha-check-list-item>`;
private _handleItemClick(ev) {
const listItem = ev.target.closest("ha-check-list-item");
const value = listItem?.value;
if (!value) {
return;
}
if (this.value?.includes(value)) {
this.value = this.value?.filter((val) => val !== value);
} else {
this.value = [...(this.value || []), value];
}
listItem.selected = this.value?.includes(value);
this._findRelated();
}
protected updated(changed) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("mwc-list")!.style.height =
`${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
private _devices = memoizeOne((devices: HomeAssistant["devices"]) => {
const values = Object.values(devices);
return values.sort((a, b) =>
stringCompare(
a.name_by_user || a.name || "",
b.name_by_user || b.name || "",
this.hass.locale.language
)
);
});
private async _findRelated() {
const relatedPromises: Promise<RelatedResult>[] = [];
if (!this.value?.length) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
const value: string[] = [];
for (const deviceId of this.value) {
value.push(deviceId);
if (this.type) {
relatedPromises.push(findRelated(this.hass, "device", deviceId));
}
}
this.value = value;
const results = await Promise.all(relatedPromises);
const items: Set<string> = new Set();
for (const result of results) {
if (result[this.type!]) {
result[this.type!]!.forEach((item) => items.add(item));
}
}
fireEvent(this, "data-table-filter-changed", {
value,
items: this.type ? items : undefined,
});
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: 0;
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
background-color: var(--primary-color);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-check-list-item {
width: 100%;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-devices": HaFilterDevices;
}
}

View File

@ -0,0 +1,221 @@
import {
css,
CSSResultGroup,
html,
LitElement,
nothing,
PropertyValues,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { computeStateName } from "../common/entity/compute_state_name";
import { stringCompare } from "../common/string/compare";
import { findRelated, RelatedResult } from "../data/search";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-state-icon";
import "./ha-check-list-item";
import { loadVirtualizer } from "../resources/virtualizer";
@customElement("ha-filter-entities")
export class HaFilterEntities extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: string[];
@property() public type?: keyof RelatedResult;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
public willUpdate(properties: PropertyValues) {
super.willUpdate(properties);
if (!this.hasUpdated) {
loadVirtualizer();
}
}
protected render() {
return html`
<ha-expansion-panel
leftChevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.entities.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>`
: nothing}
</div>
${this._shouldRender
? html`
<mwc-list class="ha-scrollbar">
<lit-virtualizer
.items=${this._entities(this.hass.states, this.type)}
.renderItem=${this._renderItem}
@click=${this._handleItemClick}
>
</lit-virtualizer>
</mwc-list>
`
: nothing}
</ha-expansion-panel>
`;
}
protected updated(changed) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("mwc-list")!.style.height =
`${this.clientHeight - 49}px`;
}, 300);
}
}
private _renderItem = (entity) =>
html`<ha-check-list-item
.value=${entity.entity_id}
.selected=${this.value?.includes(entity.entity_id)}
graphic="icon"
>
<ha-state-icon
slot="graphic"
.hass=${this.hass}
.stateObj=${entity}
></ha-state-icon>
${computeStateName(entity)}
</ha-check-list-item>`;
private _handleItemClick(ev) {
const listItem = ev.target.closest("ha-check-list-item");
const value = listItem?.value;
if (!value) {
return;
}
if (this.value?.includes(value)) {
this.value = this.value?.filter((val) => val !== value);
} else {
this.value = [...(this.value || []), value];
}
listItem.selected = this.value?.includes(value);
this._findRelated();
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
private _entities = memoizeOne(
(states: HomeAssistant["states"], type: this["type"]) => {
const values = Object.values(states);
return values
.filter(
(entityState) => !type || computeStateDomain(entityState) !== type
)
.sort((a, b) =>
stringCompare(
computeStateName(a),
computeStateName(b),
this.hass.locale.language
)
);
}
);
private async _findRelated() {
const relatedPromises: Promise<RelatedResult>[] = [];
if (!this.value?.length) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
const value: string[] = [];
for (const entityId of this.value) {
value.push(entityId);
if (this.type) {
relatedPromises.push(findRelated(this.hass, "entity", entityId));
}
}
this.value = value;
const results = await Promise.all(relatedPromises);
const items: Set<string> = new Set();
for (const result of results) {
if (result[this.type!]) {
result[this.type!]!.forEach((item) => items.add(item));
}
}
fireEvent(this, "data-table-filter-changed", {
value,
items: this.type ? items : undefined,
});
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: 0;
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
background-color: var(--primary-color);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-check-list-item {
--mdc-list-item-graphic-margin: 16px;
width: 100%;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-entities": HaFilterEntities;
}
}

View File

@ -0,0 +1,295 @@
import "@material/mwc-menu/mwc-menu-surface";
import { mdiTextureBox } from "@mdi/js";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import {
FloorRegistryEntry,
getFloorAreaLookup,
subscribeFloorRegistry,
} from "../data/floor_registry";
import { findRelated, RelatedResult } from "../data/search";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-check-list-item";
import "./ha-floor-icon";
import "./ha-icon";
import "./ha-svg-icon";
@customElement("ha-filter-floor-areas")
export class HaFilterFloorAreas extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: {
floors?: string[];
areas?: string[];
};
@property() public type?: keyof RelatedResult;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _floors?: FloorRegistryEntry[];
protected render() {
const areas = this._areas(this.hass.areas, this._floors);
return html`
<ha-expansion-panel
leftChevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.areas.caption")}
${this.value?.areas?.length || this.value?.floors?.length
? html`<div class="badge">
${(this.value?.areas?.length || 0) +
(this.value?.floors?.length || 0)}
</div>`
: nothing}
</div>
${this._shouldRender
? html`
<mwc-list class="ha-scrollbar">
${repeat(
areas?.floors || [],
(floor) => floor.floor_id,
(floor) => html`
<ha-check-list-item
.value=${floor.floor_id}
.type=${"floors"}
.selected=${this.value?.floors?.includes(
floor.floor_id
) || false}
graphic="icon"
@request-selected=${this._handleItemClick}
>
<ha-floor-icon
slot="graphic"
.floor=${floor}
></ha-floor-icon>
${floor.name}
</ha-check-list-item>
${repeat(
floor.areas,
(area) => area.area_id,
(area) => this._renderArea(area)
)}
`
)}
${repeat(
areas?.unassisgnedAreas,
(area) => area.area_id,
(area) => this._renderArea(area)
)}
</mwc-list>
`
: nothing}
</ha-expansion-panel>
`;
}
private _renderArea(area) {
return html`<ha-check-list-item
.value=${area.area_id}
.selected=${this.value?.areas?.includes(area.area_id) || false}
.type=${"areas"}
graphic="icon"
class=${area.floor_id ? "floor" : ""}
@request-selected=${this._handleItemClick}
>
${area.icon
? html`<ha-icon slot="graphic" .icon=${area.icon}></ha-icon>`
: html`<ha-svg-icon
slot="graphic"
.path=${mdiTextureBox}
></ha-svg-icon>`}
${area.name}
</ha-check-list-item>`;
}
private _handleItemClick(ev) {
ev.stopPropagation();
const listItem = ev.currentTarget;
const type = listItem?.type;
const value = listItem?.value;
if (ev.detail.selected === listItem.selected || !value) {
return;
}
if (this.value?.[type]?.includes(value)) {
this.value = {
...this.value,
[type]: this.value[type].filter((val) => val !== value),
};
} else {
if (!this.value) {
this.value = {};
}
this.value = {
...this.value,
[type]: [...(this.value[type] || []), value],
};
}
listItem.selected = this.value[type]?.includes(value);
this._findRelated();
}
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [
subscribeFloorRegistry(this.hass.connection, (floors) => {
this._floors = floors;
}),
];
}
protected updated(changed) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("mwc-list")!.style.height =
`${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
private _areas = memoizeOne(
(areaReg: HomeAssistant["areas"], floors?: FloorRegistryEntry[]) => {
const areas = Object.values(areaReg);
const floorAreaLookup = getFloorAreaLookup(areas);
const unassisgnedAreas = areas.filter(
(area) => !area.floor_id || !floorAreaLookup[area.floor_id]
);
return {
floors: floors?.map((floor) => ({
...floor,
areas: floorAreaLookup[floor.floor_id] || [],
})),
unassisgnedAreas: unassisgnedAreas,
};
}
);
private async _findRelated() {
const relatedPromises: Promise<RelatedResult>[] = [];
if (
!this.value ||
(!this.value.areas?.length && !this.value.floors?.length)
) {
fireEvent(this, "data-table-filter-changed", {
value: {},
items: undefined,
});
return;
}
if (this.value.areas) {
for (const areaId of this.value.areas) {
if (this.type) {
relatedPromises.push(findRelated(this.hass, "area", areaId));
}
}
}
if (this.value.floors) {
for (const floorId of this.value.floors) {
if (this.type) {
relatedPromises.push(findRelated(this.hass, "floor", floorId));
}
}
}
const results = await Promise.all(relatedPromises);
const items: Set<string> = new Set();
for (const result of results) {
if (result[this.type!]) {
result[this.type!]!.forEach((item) => items.add(item));
}
}
fireEvent(this, "data-table-filter-changed", {
value: this.value,
items: this.type ? items : undefined,
});
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: 0;
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
background-color: var(--primary-color);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-check-list-item {
--mdc-list-item-graphic-margin: 16px;
}
.floor {
padding-left: 32px;
padding-inline-start: 32px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-floor-areas": HaFilterFloorAreas;
}
interface HASSDomEvents {
"data-table-filter-changed": { value: any; items: Set<string> | undefined };
}
}

View File

@ -0,0 +1,183 @@
import { SelectedDetail } from "@material/mwc-list";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import {
fetchIntegrationManifests,
IntegrationManifest,
} from "../data/integration";
import "./ha-domain-icon";
@customElement("ha-filter-integrations")
export class HaFilterIntegrations extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: string[];
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _manifests?: IntegrationManifest[];
@state() private _shouldRender = false;
protected render() {
return html`
<ha-expansion-panel
leftChevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.integrations.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>`
: nothing}
</div>
${this._manifests && this._shouldRender
? html`
<mwc-list
@selected=${this._integrationsSelected}
multi
class="ha-scrollbar"
>
${this._integrations(this._manifests).map(
(integration) =>
html`<ha-check-list-item
.value=${integration.domain}
.selected=${this.value?.includes(integration.domain)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.hass=${this.hass}
.domain=${integration.domain}
brandFallback
></ha-domain-icon>
${integration.name || integration.domain}
</ha-check-list-item>`
)}
</mwc-list>
`
: nothing}
</ha-expansion-panel>
`;
}
protected updated(changed) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("mwc-list")!.style.height =
`${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
protected async firstUpdated() {
this._manifests = await fetchIntegrationManifests(this.hass);
}
private _integrations = memoizeOne((manifest: IntegrationManifest[]) =>
manifest
.filter(
(mnfst) =>
!mnfst.integration_type ||
!["entity", "system", "hardware"].includes(mnfst.integration_type)
)
.sort((a, b) =>
stringCompare(
a.name || a.domain,
b.name || b.domain,
this.hass.locale.language
)
)
);
private async _integrationsSelected(
ev: CustomEvent<SelectedDetail<Set<number>>>
) {
const integrations = this._integrations(this._manifests!);
if (!ev.detail.index.size) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
const value: string[] = [];
for (const index of ev.detail.index) {
const domain = integrations[index].domain;
value.push(domain);
}
this.value = value;
fireEvent(this, "data-table-filter-changed", {
value,
items: undefined,
});
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: 0;
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
background-color: var(--primary-color);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-integrations": HaFilterIntegrations;
}
}

View File

@ -0,0 +1,209 @@
import { SelectedDetail } from "@material/mwc-list";
import "@material/mwc-menu/mwc-menu-surface";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { CSSResultGroup, LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { mdiPlus } from "@mdi/js";
import { computeCssColor } from "../common/color/compute-color";
import { fireEvent } from "../common/dom/fire_event";
import {
LabelRegistryEntry,
createLabelRegistryEntry,
subscribeLabelRegistry,
} from "../data/label_registry";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-icon";
import "./ha-label";
import { showLabelDetailDialog } from "../panels/config/labels/show-dialog-label-detail";
@customElement("ha-filter-labels")
export class HaFilterLabels extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: string[];
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _labels: LabelRegistryEntry[] = [];
@state() private _shouldRender = false;
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [
subscribeLabelRegistry(this.hass.connection, (labels) => {
this._labels = labels;
}),
];
}
protected render() {
return html`
<ha-expansion-panel
leftChevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.labels.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>`
: nothing}
</div>
${this._shouldRender
? html`
<mwc-list
@selected=${this._labelSelected}
class="ha-scrollbar"
multi
>
${this._labels.map((label) => {
const color = label.color
? computeCssColor(label.color)
: undefined;
return html`<ha-check-list-item
.value=${label.label_id}
.selected=${this.value?.includes(label.label_id)}
hasMeta
>
<ha-label style=${color ? `--color: ${color}` : ""}>
${label.icon
? html`<ha-icon
slot="icon"
.icon=${label.icon}
></ha-icon>`
: nothing}
${label.name}
</ha-label>
</ha-check-list-item>`;
})}
</mwc-list>
`
: nothing}
</ha-expansion-panel>
${this.expanded
? html`<ha-list-item
graphic="icon"
@click=${this._addLabel}
class="add"
>
<ha-svg-icon slot="graphic" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.labels.add_label")}
</ha-list-item>`
: nothing}
`;
}
protected updated(changed) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("mwc-list")!.style.height =
`${this.clientHeight - (49 + 48)}px`;
}, 300);
}
}
private _addLabel() {
showLabelDetailDialog(this, {
createEntry: (values) => createLabelRegistryEntry(this.hass, values),
});
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
private async _labelSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
if (!ev.detail.index.size) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
const value: string[] = [];
for (const index of ev.detail.index) {
const labelId = this._labels[index].label_id;
value.push(labelId);
}
this.value = value;
fireEvent(this, "data-table-filter-changed", {
value,
items: undefined,
});
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
position: relative;
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: 0;
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
background-color: var(--primary-color);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
.warning {
color: var(--error-color);
}
ha-label {
--ha-label-background-color: var(--color, var(--grey-color));
--ha-label-background-opacity: 0.5;
}
.add {
position: absolute;
bottom: 0;
right: 0;
left: 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-labels": HaFilterLabels;
}
}

View File

@ -0,0 +1,165 @@
import { SelectedDetail } from "@material/mwc-list";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-expansion-panel";
import "./ha-check-list-item";
import "./ha-icon";
@customElement("ha-filter-states")
export class HaFilterStates extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property({ attribute: false }) public value?: string[];
@property({ attribute: false }) public states?: {
value: any;
label?: string;
icon?: string;
}[];
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
protected render() {
if (!this.states) {
return nothing;
}
const hasIcon = this.states.find((item) => item.icon);
return html`
<ha-expansion-panel
leftChevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.label}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>`
: nothing}
</div>
${this._shouldRender
? html`
<mwc-list
@selected=${this._statesSelected}
multi
class="ha-scrollbar"
>
${this.states.map(
(item) =>
html`<ha-check-list-item
.value=${item.value}
.selected=${this.value?.includes(item.value)}
.graphic=${hasIcon ? "icon" : undefined}
>
${item.icon
? html`<ha-icon
slot="graphic"
.icon=${item.icon}
></ha-icon>`
: nothing}
${item.label}
</ha-check-list-item>`
)}
</mwc-list>
`
: nothing}
</ha-expansion-panel>
`;
}
protected updated(changed) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("mwc-list")!.style.height =
`${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
private async _statesSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
if (!ev.detail.index.size) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
const value: string[] = [];
for (const index of ev.detail.index) {
const val = this.states![index].value;
value.push(val);
}
this.value = value;
fireEvent(this, "data-table-filter-changed", {
value,
items: undefined,
});
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: 0;
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
background-color: var(--primary-color);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-states": HaFilterStates;
}
}

View File

@ -0,0 +1,56 @@
import {
mdiHome,
mdiHomeFloor0,
mdiHomeFloor1,
mdiHomeFloor2,
mdiHomeFloor3,
mdiHomeFloorNegative1,
} from "@mdi/js";
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators";
import { FloorRegistryEntry } from "../data/floor_registry";
import "./ha-icon";
import "./ha-svg-icon";
export const floorDefaultIconPath = (
floor: Pick<FloorRegistryEntry, "level">
) => {
switch (floor.level) {
case 0:
return mdiHomeFloor0;
case 1:
return mdiHomeFloor1;
case 2:
return mdiHomeFloor2;
case 3:
return mdiHomeFloor3;
case -1:
return mdiHomeFloorNegative1;
}
return mdiHome;
};
@customElement("ha-floor-icon")
export class HaFloorIcon extends LitElement {
@property({ attribute: false }) public floor!: Pick<
FloorRegistryEntry,
"icon" | "level"
>;
@property() public icon?: string;
protected render() {
if (this.floor.icon) {
return html`<ha-icon .icon=${this.floor.icon}></ha-icon>`;
}
const defaultPath = floorDefaultIconPath(this.floor);
return html`<ha-svg-icon .path=${defaultPath}></ha-svg-icon>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-floor-icon": HaFloorIcon;
}
}

View File

@ -0,0 +1,497 @@
import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import { LitElement, PropertyValues, TemplateResult, html } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import {
ScorableTextItem,
fuzzyFilterSort,
} from "../common/string/filter/sequence-matching";
import { AreaRegistryEntry } from "../data/area_registry";
import {
DeviceEntityDisplayLookup,
DeviceRegistryEntry,
getDeviceEntityDisplayLookup,
} from "../data/device_registry";
import { EntityRegistryDisplayEntry } from "../data/entity_registry";
import {
FloorRegistryEntry,
createFloorRegistryEntry,
getFloorAreaLookup,
subscribeFloorRegistry,
} from "../data/floor_registry";
import {
showAlertDialog,
showPromptDialog,
} from "../dialogs/generic/show-dialog-box";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { HomeAssistant, ValueChangedEvent } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box";
import "./ha-floor-icon";
import "./ha-icon-button";
import "./ha-list-item";
type ScorableFloorRegistryEntry = ScorableTextItem & FloorRegistryEntry;
const rowRenderer: ComboBoxLitRenderer<FloorRegistryEntry> = (item) =>
html`<ha-list-item
graphic="icon"
class=${classMap({ "add-new": item.floor_id === "add_new" })}
>
<ha-floor-icon slot="graphic" .floor=${item}></ha-floor-icon>
${item.name}
</ha-list-item>`;
@customElement("ha-floor-picker")
export class HaFloorPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property() public value?: string;
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: Boolean, attribute: "no-add" })
public noAdd = false;
/**
* Show only floors with entities from specific domains.
* @type {Array}
* @attr include-domains
*/
@property({ type: Array, attribute: "include-domains" })
public includeDomains?: string[];
/**
* Show no floors with entities of these domains.
* @type {Array}
* @attr exclude-domains
*/
@property({ type: Array, attribute: "exclude-domains" })
public excludeDomains?: string[];
/**
* Show only floors with entities of these device classes.
* @type {Array}
* @attr include-device-classes
*/
@property({ type: Array, attribute: "include-device-classes" })
public includeDeviceClasses?: string[];
/**
* List of floors to be excluded.
* @type {Array}
* @attr exclude-floors
*/
@property({ type: Array, attribute: "exclude-floor" })
public excludeFloors?: string[];
@property({ attribute: false })
public deviceFilter?: HaDevicePickerDeviceFilterFunc;
@property({ attribute: false })
public entityFilter?: (entity: HassEntity) => boolean;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@state() private _opened?: boolean;
@state() private _floors?: FloorRegistryEntry[];
@query("ha-combo-box", true) public comboBox!: HaComboBox;
private _suggestion?: string;
private _init = false;
public async open() {
await this.updateComplete;
await this.comboBox?.open();
}
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
}
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [
subscribeFloorRegistry(this.hass.connection, (floors) => {
this._floors = floors;
}),
];
}
private _getFloors = memoizeOne(
(
floors: FloorRegistryEntry[],
areas: AreaRegistryEntry[],
devices: DeviceRegistryEntry[],
entities: EntityRegistryDisplayEntry[],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"],
noAdd: this["noAdd"],
excludeFloors: this["excludeFloors"]
): FloorRegistryEntry[] => {
if (!floors.length) {
return [
{
floor_id: "no_floors",
name: this.hass.localize("ui.components.floor-picker.no_floors"),
icon: null,
level: 0,
aliases: [],
},
];
}
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined;
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
if (
includeDomains ||
excludeDomains ||
includeDeviceClasses ||
deviceFilter ||
entityFilter
) {
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
inputDevices = devices;
inputEntities = entities.filter((entity) => entity.area_id);
if (includeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
}
if (excludeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return true;
}
return entities.every(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
}
if (includeDeviceClasses) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
}
if (deviceFilter) {
inputDevices = inputDevices!.filter((device) =>
deviceFilter!(device)
);
}
if (entityFilter) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter(stateObj);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter!(stateObj);
});
}
}
let outputFloors = floors;
let areaIds: string[] | undefined;
if (inputDevices) {
areaIds = inputDevices
.filter((device) => device.area_id)
.map((device) => device.area_id!);
}
if (inputEntities) {
areaIds = (areaIds ?? []).concat(
inputEntities
.filter((entity) => entity.area_id)
.map((entity) => entity.area_id!)
);
}
if (areaIds) {
const floorAreaLookup = getFloorAreaLookup(areas);
outputFloors = outputFloors.filter((floor) =>
floorAreaLookup[floor.floor_id].some((area) =>
areaIds!.includes(area.area_id)
)
);
}
if (excludeFloors) {
outputFloors = outputFloors.filter(
(floor) => !excludeFloors!.includes(floor.floor_id)
);
}
if (!outputFloors.length) {
outputFloors = [
{
floor_id: "no_floors",
name: this.hass.localize("ui.components.floor-picker.no_match"),
icon: null,
level: 0,
aliases: [],
},
];
}
return noAdd
? outputFloors
: [
...outputFloors,
{
floor_id: "add_new",
name: this.hass.localize("ui.components.floor-picker.add_new"),
icon: "mdi:plus",
level: 0,
aliases: [],
},
];
}
);
protected updated(changedProps: PropertyValues) {
if (
(!this._init && this.hass && this._floors) ||
(this._init && changedProps.has("_opened") && this._opened)
) {
this._init = true;
const floors = this._getFloors(
this._floors!,
Object.values(this.hass.areas),
Object.values(this.hass.devices),
Object.values(this.hass.entities),
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.noAdd,
this.excludeFloors
).map((floor) => ({
...floor,
strings: [floor.floor_id, floor.name], // ...floor.aliases
}));
this.comboBox.items = floors;
this.comboBox.filteredItems = floors;
}
}
protected render(): TemplateResult {
return html`
<ha-combo-box
.hass=${this.hass}
.helper=${this.helper}
item-value-path="floor_id"
item-id-path="floor_id"
item-label-path="name"
.value=${this._value}
.disabled=${this.disabled}
.required=${this.required}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.floor-picker.floor")
: this.label}
.placeholder=${this.placeholder
? this._floors?.find((floor) => floor.floor_id === this.placeholder)
?.name
: undefined}
.renderer=${rowRenderer}
@filter-changed=${this._filterChanged}
@opened-changed=${this._openedChanged}
@value-changed=${this._floorChanged}
>
</ha-combo-box>
`;
}
private _filterChanged(ev: CustomEvent): void {
const target = ev.target as HaComboBox;
const filterString = ev.detail.value;
if (!filterString) {
this.comboBox.filteredItems = this.comboBox.items;
return;
}
const filteredItems = fuzzyFilterSort<ScorableFloorRegistryEntry>(
filterString,
target.items || []
);
if (!this.noAdd && filteredItems?.length === 0) {
this._suggestion = filterString;
this.comboBox.filteredItems = [
{
floor_id: "add_new_suggestion",
name: this.hass.localize(
"ui.components.floor-picker.add_new_sugestion",
{ name: this._suggestion }
),
picture: null,
},
];
} else {
this.comboBox.filteredItems = filteredItems;
}
}
private get _value() {
return this.value || "";
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _floorChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
let newValue = ev.detail.value;
if (newValue === "no_floors") {
newValue = "";
}
if (!["add_new_suggestion", "add_new"].includes(newValue)) {
if (newValue !== this._value) {
this._setValue(newValue);
}
return;
}
(ev.target as any).value = this._value;
showPromptDialog(this, {
title: this.hass.localize("ui.components.floor-picker.add_dialog.title"),
text: this.hass.localize("ui.components.floor-picker.add_dialog.text"),
confirmText: this.hass.localize(
"ui.components.floor-picker.add_dialog.add"
),
inputLabel: this.hass.localize(
"ui.components.floor-picker.add_dialog.name"
),
defaultValue:
newValue === "add_new_suggestion" ? this._suggestion : undefined,
confirm: async (name) => {
if (!name) {
return;
}
try {
const floor = await createFloorRegistryEntry(this.hass, {
name,
});
const floors = [...this._floors!, floor];
this.comboBox.filteredItems = this._getFloors(
floors,
Object.values(this.hass.areas)!,
Object.values(this.hass.devices)!,
Object.values(this.hass.entities)!,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.noAdd,
this.excludeFloors
);
await this.updateComplete;
await this.comboBox.updateComplete;
this._setValue(floor.floor_id);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.components.floor-picker.add_dialog.failed_create_floor"
),
text: err.message,
});
}
},
cancel: () => {
this._setValue(undefined);
this._suggestion = undefined;
this.comboBox.setInputValue("");
},
});
}
private _setValue(value?: string) {
this.value = value;
setTimeout(() => {
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}, 0);
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-floor-picker": HaFloorPicker;
}
}

View File

@ -118,7 +118,7 @@ export class HaIconPicker extends LitElement {
<ha-icon .icon=${this._value || this.placeholder} slot="icon">
</ha-icon>
`
: html`<slot name="fallback"></slot>`}
: html`<slot slot="icon" name="fallback"></slot>`}
</ha-combo-box>
`;
}

View File

@ -0,0 +1,491 @@
import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import { LitElement, PropertyValues, TemplateResult, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import {
ScorableTextItem,
fuzzyFilterSort,
} from "../common/string/filter/sequence-matching";
import {
DeviceEntityDisplayLookup,
DeviceRegistryEntry,
getDeviceEntityDisplayLookup,
} from "../data/device_registry";
import { EntityRegistryDisplayEntry } from "../data/entity_registry";
import {
LabelRegistryEntry,
createLabelRegistryEntry,
subscribeLabelRegistry,
} from "../data/label_registry";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { showLabelDetailDialog } from "../panels/config/labels/show-dialog-label-detail";
import { HomeAssistant, ValueChangedEvent } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box";
import "./ha-icon-button";
import "./ha-list-item";
import "./ha-svg-icon";
type ScorableLabelItem = ScorableTextItem & LabelRegistryEntry;
const ADD_NEW_ID = "___ADD_NEW___";
const NO_LABELS_ID = "___NO_LABELS___";
const ADD_NEW_SUGGESTION_ID = "___ADD_NEW_SUGGESTION___";
const rowRenderer: ComboBoxLitRenderer<LabelRegistryEntry> = (item) =>
html`<ha-list-item
graphic="icon"
class=${classMap({ "add-new": item.label_id === ADD_NEW_ID })}
>
${item.icon
? html`<ha-icon slot="graphic" .icon=${item.icon}></ha-icon>`
: nothing}
${item.name}
</ha-list-item>`;
@customElement("ha-label-picker")
export class HaLabelPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property() public value?: string;
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: Boolean, attribute: "no-add" })
public noAdd = false;
/**
* Show only labels with entities from specific domains.
* @type {Array}
* @attr include-domains
*/
@property({ type: Array, attribute: "include-domains" })
public includeDomains?: string[];
/**
* Show no labels with entities of these domains.
* @type {Array}
* @attr exclude-domains
*/
@property({ type: Array, attribute: "exclude-domains" })
public excludeDomains?: string[];
/**
* Show only labels with entities of these device classes.
* @type {Array}
* @attr include-device-classes
*/
@property({ type: Array, attribute: "include-device-classes" })
public includeDeviceClasses?: string[];
/**
* List of labels to be excluded.
* @type {Array}
* @attr exclude-labels
*/
@property({ type: Array, attribute: "exclude-label" })
public excludeLabels?: string[];
@property({ attribute: false })
public deviceFilter?: HaDevicePickerDeviceFilterFunc;
@property({ attribute: false })
public entityFilter?: (entity: HassEntity) => boolean;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@state() private _opened?: boolean;
@state() private _labels?: LabelRegistryEntry[];
@query("ha-combo-box", true) public comboBox!: HaComboBox;
private _suggestion?: string;
private _init = false;
public async open() {
await this.updateComplete;
await this.comboBox?.open();
}
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
}
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [
subscribeLabelRegistry(this.hass.connection, (labels) => {
this._labels = labels;
}),
];
}
private _getLabels = memoizeOne(
(
labels: LabelRegistryEntry[],
areas: HomeAssistant["areas"],
devices: DeviceRegistryEntry[],
entities: EntityRegistryDisplayEntry[],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"],
noAdd: this["noAdd"],
excludeLabels: this["excludeLabels"]
): LabelRegistryEntry[] => {
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined;
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
if (
includeDomains ||
excludeDomains ||
includeDeviceClasses ||
deviceFilter ||
entityFilter
) {
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
inputDevices = devices;
inputEntities = entities.filter((entity) => entity.labels.length > 0);
if (includeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
}
if (excludeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return true;
}
return entities.every(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
}
if (includeDeviceClasses) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
}
if (deviceFilter) {
inputDevices = inputDevices!.filter((device) =>
deviceFilter!(device)
);
}
if (entityFilter) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter(stateObj);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter!(stateObj);
});
}
}
let outputLabels = labels;
const usedLabels = new Set<string>();
let areaIds: string[] | undefined;
if (inputDevices) {
areaIds = inputDevices
.filter((device) => device.area_id)
.map((device) => device.area_id!);
inputDevices.forEach((device) => {
device.labels.forEach((label) => usedLabels.add(label));
});
}
if (inputEntities) {
areaIds = (areaIds ?? []).concat(
inputEntities
.filter((entity) => entity.area_id)
.map((entity) => entity.area_id!)
);
inputEntities.forEach((entity) => {
entity.labels.forEach((label) => usedLabels.add(label));
});
}
if (areaIds) {
areaIds.forEach((areaId) => {
const area = areas[areaId];
area.labels.forEach((label) => usedLabels.add(label));
});
}
if (excludeLabels) {
outputLabels = outputLabels.filter(
(label) => !excludeLabels!.includes(label.label_id)
);
}
if (inputDevices || inputEntities) {
outputLabels = outputLabels.filter((label) =>
usedLabels.has(label.label_id)
);
}
if (!outputLabels.length) {
outputLabels = [
{
label_id: NO_LABELS_ID,
name: this.hass.localize("ui.components.label-picker.no_match"),
icon: null,
color: null,
},
];
}
return noAdd
? outputLabels
: [
...outputLabels,
{
label_id: ADD_NEW_ID,
name: this.hass.localize("ui.components.label-picker.add_new"),
icon: "mdi:plus",
color: null,
},
];
}
);
protected updated(changedProps: PropertyValues) {
if (
(!this._init && this.hass && this._labels) ||
(this._init && changedProps.has("_opened") && this._opened)
) {
this._init = true;
const items = this._getLabels(
this._labels!,
this.hass.areas,
Object.values(this.hass.devices),
Object.values(this.hass.entities),
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.noAdd,
this.excludeLabels
).map((label) => ({
...label,
strings: [label.label_id, label.name],
}));
this.comboBox.items = items;
this.comboBox.filteredItems = items;
}
}
protected render(): TemplateResult {
return html`
<ha-combo-box
.hass=${this.hass}
.helper=${this.helper}
item-value-path="label_id"
item-id-path="label_id"
item-label-path="name"
.value=${this._value}
.disabled=${this.disabled}
.required=${this.required}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.label-picker.label")
: this.label}
.placeholder=${this.placeholder
? this._labels?.find((label) => label.label_id === this.placeholder)
?.name
: undefined}
.renderer=${rowRenderer}
@filter-changed=${this._filterChanged}
@opened-changed=${this._openedChanged}
@value-changed=${this._labelChanged}
>
</ha-combo-box>
`;
}
private _filterChanged(ev: CustomEvent): void {
const target = ev.target as HaComboBox;
const filterString = ev.detail.value;
if (!filterString) {
this.comboBox.filteredItems = this.comboBox.items;
return;
}
const filteredItems = fuzzyFilterSort<ScorableLabelItem>(
filterString,
target.items?.filter(
(item) => ![NO_LABELS_ID, ADD_NEW_ID].includes(item.label_id)
) || []
);
if (filteredItems.length === 0) {
if (this.noAdd) {
this.comboBox.filteredItems = [
{
label_id: NO_LABELS_ID,
name: this.hass.localize("ui.components.label-picker.no_match"),
icon: null,
color: null,
},
] as ScorableLabelItem[];
} else {
this._suggestion = filterString;
this.comboBox.filteredItems = [
{
label_id: ADD_NEW_SUGGESTION_ID,
name: this.hass.localize(
"ui.components.label-picker.add_new_sugestion",
{ name: this._suggestion }
),
icon: "mdi:plus",
color: null,
},
] as ScorableLabelItem[];
}
} else {
this.comboBox.filteredItems = filteredItems;
}
}
private get _value() {
return this.value || "";
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _labelChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
let newValue = ev.detail.value;
if (newValue === NO_LABELS_ID) {
newValue = "";
this.comboBox.setInputValue("");
return;
}
if (![ADD_NEW_SUGGESTION_ID, ADD_NEW_ID].includes(newValue)) {
if (newValue !== this._value) {
this._setValue(newValue);
}
return;
}
(ev.target as any).value = this._value;
showLabelDetailDialog(this, {
entry: undefined,
suggestedName: newValue === ADD_NEW_SUGGESTION_ID ? this._suggestion : "",
createEntry: async (values) => {
const label = await createLabelRegistryEntry(this.hass, values);
const labels = [...this._labels!, label];
this.comboBox.filteredItems = this._getLabels(
labels,
this.hass.areas!,
Object.values(this.hass.devices)!,
Object.values(this.hass.entities)!,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.noAdd,
this.excludeLabels
);
await this.updateComplete;
await this.comboBox.updateComplete;
this._setValue(label.label_id);
return label;
},
});
this._suggestion = undefined;
this.comboBox.setInputValue("");
}
private _setValue(value?: string) {
this.value = value;
setTimeout(() => {
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}, 0);
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-label-picker": HaLabelPicker;
}
}

View File

@ -1,13 +1,17 @@
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import "@material/web/ripple/ripple";
@customElement("ha-label")
class HaLabel extends LitElement {
@property({ type: Boolean, reflect: true }) dense = false;
protected render(): TemplateResult {
return html`
<span class="label">
<span class="content">
<slot name="icon"></slot>
<slot></slot>
<md-ripple></md-ripple>
</span>
`;
}
@ -22,8 +26,10 @@ class HaLabel extends LitElement {
var(--rgb-primary-text-color),
0.15
);
}
.label {
--ha-label-background-opacity: 1;
position: relative;
box-sizing: border-box;
display: inline-flex;
flex-direction: row;
align-items: center;
@ -35,9 +41,22 @@ class HaLabel extends LitElement {
height: 32px;
padding: 0 16px;
border-radius: 18px;
background-color: var(--ha-label-background-color);
color: var(--ha-label-text-color);
--mdc-icon-size: 18px;
--mdc-icon-size: 12px;
}
.content > * {
position: relative;
display: inline-flex;
flex-direction: row;
align-items: center;
}
:host:before {
position: absolute;
content: "";
inset: 0;
border-radius: inherit;
background-color: var(--ha-label-background-color);
opacity: var(--ha-label-background-opacity);
}
::slotted([slot="icon"]) {
margin-right: 8px;
@ -45,11 +64,23 @@ class HaLabel extends LitElement {
margin-inline-start: -8px;
margin-inline-end: 8px;
display: flex;
color: var(--ha-label-icon-color);
}
span {
display: inline-flex;
}
:host([dense]) {
height: 20px;
padding: 0 12px;
border-radius: 10px;
}
:host([dense]) ::slotted([slot="icon"]) {
margin-right: 4px;
margin-left: -4px;
margin-inline-start: -4px;
margin-inline-end: 4px;
}
`,
];
}

View File

@ -0,0 +1,212 @@
import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import { LitElement, TemplateResult, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { computeCssColor } from "../common/color/compute-color";
import { fireEvent } from "../common/dom/fire_event";
import {
LabelRegistryEntry,
subscribeLabelRegistry,
updateLabelRegistryEntry,
} from "../data/label_registry";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { showLabelDetailDialog } from "../panels/config/labels/show-dialog-label-detail";
import { HomeAssistant, ValueChangedEvent } from "../types";
import "./chips/ha-chip-set";
import "./chips/ha-input-chip";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-label-picker";
import type { HaLabelPicker } from "./ha-label-picker";
@customElement("ha-labels-picker")
export class HaLabelsPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property({ attribute: false }) public value?: string[];
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: Boolean, attribute: "no-add" })
public noAdd = false;
/**
* Show only labels with entities from specific domains.
* @type {Array}
* @attr include-domains
*/
@property({ type: Array, attribute: "include-domains" })
public includeDomains?: string[];
/**
* Show no labels with entities of these domains.
* @type {Array}
* @attr exclude-domains
*/
@property({ type: Array, attribute: "exclude-domains" })
public excludeDomains?: string[];
/**
* Show only labels with entities of these device classes.
* @type {Array}
* @attr include-device-classes
*/
@property({ type: Array, attribute: "include-device-classes" })
public includeDeviceClasses?: string[];
/**
* List of labels to be excluded.
* @type {Array}
* @attr exclude-labels
*/
@property({ type: Array, attribute: "exclude-label" })
public excludeLabels?: string[];
@property({ attribute: false })
public deviceFilter?: HaDevicePickerDeviceFilterFunc;
@property({ attribute: false })
public entityFilter?: (entity: HassEntity) => boolean;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@state() private _labels?: LabelRegistryEntry[];
@query("ha-label-picker", true) public labelPicker!: HaLabelPicker;
public async open() {
await this.updateComplete;
await this.labelPicker?.open();
}
public async focus() {
await this.updateComplete;
await this.labelPicker?.focus();
}
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [
subscribeLabelRegistry(this.hass.connection, (labels) => {
this._labels = labels;
}),
];
}
protected render(): TemplateResult {
return html`
${this.value?.length
? html`<ha-chip-set>
${repeat(
this.value,
(item) => item,
(item, idx) => {
const label = this._labels?.find(
(lbl) => lbl.label_id === item
);
const color = label?.color
? computeCssColor(label.color)
: undefined;
return html`
<ha-input-chip
.idx=${idx}
.item=${label}
@remove=${this._removeItem}
@click=${this._openDetail}
.label=${label?.name}
selected
style=${color ? `--color: ${color}` : ""}
>
${label?.icon
? html`<ha-icon
slot="icon"
.icon=${label.icon}
></ha-icon>`
: nothing}
</ha-input-chip>
`;
}
)}
</ha-chip-set>`
: nothing}
<ha-label-picker
.hass=${this.hass}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.label-picker.add_label")
: this.label}
.placeholder=${this.placeholder}
.excludeLabels=${this.value}
@value-changed=${this._labelChanged}
>
</ha-label-picker>
`;
}
private get _value() {
return this.value || [];
}
private _removeItem(ev) {
this._value.splice(ev.target.idx, 1);
this._setValue([...this._value]);
}
private _openDetail(ev) {
const label = ev.target.item;
showLabelDetailDialog(this, {
entry: label,
updateEntry: async (values) => {
const updated = await updateLabelRegistryEntry(
this.hass,
label.label_id,
values
);
this._labels = this._labels!.map((lbl) =>
lbl.label_id === updated.label_id ? updated : lbl
);
return updated;
},
});
}
private _labelChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const newValue = ev.detail.value;
if (!newValue || this._value.includes(newValue)) {
return;
}
this._setValue([...this._value, newValue]);
this.labelPicker.value = "";
}
private _setValue(value?: string[]) {
this.value = value;
setTimeout(() => {
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}, 0);
}
static styles = css`
ha-chip-set {
margin-bottom: 8px;
}
ha-input-chip {
--md-input-chip-selected-container-color: var(--color, var(--grey-color));
--ha-input-chip-selected-container-opacity: 0.5;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-labels-picker": HaLabelsPicker;
}
}

View File

@ -0,0 +1,38 @@
import { MdOutlinedTextField } from "@material/web/textfield/outlined-text-field";
import "element-internals-polyfill";
import { css } from "lit";
import { customElement } from "lit/decorators";
@customElement("ha-outlined-text-field")
export class HaOutlinedTextField extends MdOutlinedTextField {
static override styles = [
...super.styles,
css`
:host {
--md-sys-color-on-surface: var(--primary-text-color);
--md-sys-color-primary: var(--primary-text-color);
--md-outlined-text-field-input-text-color: var(--primary-text-color);
--md-sys-color-on-surface-variant: var(--secondary-text-color);
--md-outlined-field-outline-color: var(--outline-color);
--md-outlined-field-focus-outline-color: var(--primary-color);
--md-outlined-field-hover-outline-color: var(--outline-hover-color);
}
:host([dense]) {
--md-outlined-field-top-space: 5.5px;
--md-outlined-field-bottom-space: 5.5px;
--md-outlined-field-container-shape-start-start: 10px;
--md-outlined-field-container-shape-start-end: 10px;
--md-outlined-field-container-shape-end-end: 10px;
--md-outlined-field-container-shape-end-start: 10px;
--md-outlined-field-focus-outline-width: 1px;
--mdc-icon-size: var(--md-input-chip-icon-size, 18px);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-outlined-text-field": HaOutlinedTextField;
}
}

View File

@ -17,7 +17,7 @@ export const pushSupported =
class HaPushNotificationsToggle extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _disabled: boolean = false;
@property({ type: Boolean }) public disabled!: boolean;
@state() private _pushChecked: boolean =
"Notification" in window && Notification.permission === "granted";
@ -27,7 +27,7 @@ class HaPushNotificationsToggle extends LitElement {
protected render(): TemplateResult {
return html`
<ha-switch
.disabled=${this._disabled || this._loading}
.disabled=${this.disabled || this._loading}
.checked=${this._pushChecked}
@change=${this._handlePushChange}
></ha-switch>

View File

@ -0,0 +1,83 @@
import { CSSResultGroup, LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import { LabelSelector } from "../../data/selector";
import { HomeAssistant } from "../../types";
import "../ha-labels-picker";
@customElement("ha-selector-label")
export class HaLabelSelector extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property() public value?: string | string[];
@property() public name?: string;
@property() public label?: string;
@property() public placeholder?: string;
@property() public helper?: string;
@property({ attribute: false }) public selector!: LabelSelector;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
protected render() {
if (this.selector.label.multiple) {
return html`
<ha-labels-picker
.hass=${this.hass}
.value=${ensureArray(this.value ?? [])}
.disabled=${this.disabled}
.label=${this.label}
@value-changed=${this._handleChange}
>
</ha-labels-picker>
`;
}
return html`
<ha-label-picker
.hass=${this.hass}
.value=${this.value}
.disabled=${this.disabled}
.label=${this.label}
@value-changed=${this._handleChange}
>
</ha-label-picker>
`;
}
private _handleChange(ev) {
let value = ev.detail.value;
if (this.value === value) {
return;
}
if (
(value === "" || (Array.isArray(value) && value.length === 0)) &&
!this.required
) {
value = undefined;
}
fireEvent(this, "value-changed", { value });
}
static get styles(): CSSResultGroup {
return css`
ha-labels-picker {
display: block;
width: 100%;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-label": HaLabelSelector;
}
}

View File

@ -2,7 +2,7 @@ import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import { UiColorSelector } from "../../data/selector";
import "../../panels/lovelace/components/hui-color-picker";
import "../ha-color-picker";
import { HomeAssistant } from "../../types";
@customElement("ha-selector-ui_color")
@ -19,13 +19,14 @@ export class HaSelectorUiColor extends LitElement {
protected render() {
return html`
<hui-color-picker
<ha-color-picker
.label=${this.label}
.hass=${this.hass}
.value=${this.value}
.helper=${this.helper}
.defaultColor=${this.selector.ui_color?.default_color}
@value-changed=${this._valueChanged}
></hui-color-picker>
></ha-color-picker>
`;
}

View File

@ -30,6 +30,7 @@ const LOAD_ELEMENTS = {
entity: () => import("./ha-selector-entity"),
statistic: () => import("./ha-selector-statistic"),
file: () => import("./ha-selector-file"),
label: () => import("./ha-selector-label"),
language: () => import("./ha-selector-language"),
navigation: () => import("./ha-selector-navigation"),
number: () => import("./ha-selector-number"),

View File

@ -30,6 +30,8 @@ import {
entityMeetsTargetSelector,
expandAreaTarget,
expandDeviceTarget,
expandFloorTarget,
expandLabelTarget,
Selector,
} from "../data/selector";
import { HomeAssistant, ValueChangedEvent } from "../types";
@ -58,20 +60,12 @@ const showOptionalToggle = (field) =>
!("boolean" in field.selector && field.default);
interface ExtHassService extends Omit<HassService, "fields"> {
fields: {
key: string;
name?: string;
description: string;
required?: boolean;
advanced?: boolean;
default?: any;
example?: any;
filter?: {
supported_features?: number[];
attribute?: Record<string, any[]>;
};
selector?: Selector;
}[];
fields: Array<
Omit<HassService["fields"][string], "selector"> & {
key: string;
selector?: Selector;
}
>;
hasSelector: string[];
}
@ -275,10 +269,42 @@ export class HaServiceControl extends LitElement {
ensureArray(
value?.target?.device_id || value?.data?.device_id
)?.slice() || [];
const targetAreas = ensureArray(
value?.target?.area_id || value?.data?.area_id
const targetAreas =
ensureArray(value?.target?.area_id || value?.data?.area_id)?.slice() ||
[];
const targetFloors = ensureArray(
value?.target?.floor_id || value?.data?.floor_id
)?.slice();
if (targetAreas) {
const targetLabels = ensureArray(
value?.target?.label_id || value?.data?.label_id
)?.slice();
if (targetLabels) {
targetLabels.forEach((labelId) => {
const expanded = expandLabelTarget(
this.hass,
labelId,
this.hass.areas,
this.hass.devices,
this.hass.entities,
targetSelector
);
targetDevices.push(...expanded.devices);
targetEntities.push(...expanded.entities);
targetAreas.push(...expanded.areas);
});
}
if (targetFloors) {
targetFloors.forEach((floorId) => {
const expanded = expandFloorTarget(
this.hass,
floorId,
this.hass.areas,
targetSelector
);
targetAreas.push(...expanded.areas);
});
}
if (targetAreas.length) {
targetAreas.forEach((areaId) => {
const expanded = expandAreaTarget(
this.hass,

View File

@ -83,7 +83,7 @@ export class HaSortable extends LitElement {
super.connectedCallback();
this._shouldBeDestroy = false;
if (this.hasUpdated) {
this.requestUpdate();
this._createSortable();
}
}

View File

@ -6,38 +6,57 @@ import "@material/mwc-menu/mwc-menu-surface";
import {
mdiClose,
mdiDevices,
mdiHome,
mdiLabel,
mdiPlus,
mdiSofa,
mdiTextureBox,
mdiUnfoldMoreVertical,
} from "@mdi/js";
import { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light";
import { HassEntity, HassServiceTarget } from "home-assistant-js-websocket";
import { css, CSSResultGroup, html, LitElement, nothing, unsafeCSS } from "lit";
import {
HassEntity,
HassServiceTarget,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
import { CSSResultGroup, LitElement, css, html, nothing, unsafeCSS } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ensureArray } from "../common/array/ensure-array";
import { computeCssColor } from "../common/color/compute-color";
import { hex2rgb } from "../common/color/convert-color";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { computeDomain } from "../common/entity/compute_domain";
import { computeStateName } from "../common/entity/compute_state_name";
import { isValidEntityId } from "../common/entity/valid_entity_id";
import { AreaRegistryEntry } from "../data/area_registry";
import {
computeDeviceName,
DeviceRegistryEntry,
computeDeviceName,
} from "../data/device_registry";
import { EntityRegistryDisplayEntry } from "../data/entity_registry";
import {
FloorRegistryEntry,
subscribeFloorRegistry,
} from "../data/floor_registry";
import {
LabelRegistryEntry,
subscribeLabelRegistry,
} from "../data/label_registry";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { HomeAssistant } from "../types";
import "./device/ha-device-picker";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./entity/ha-entity-picker";
import type { HaEntityPickerEntityFilterFunc } from "./entity/ha-entity-picker";
import "./ha-area-picker";
import "./ha-area-floor-picker";
import { floorDefaultIconPath } from "./ha-floor-icon";
import "./ha-icon-button";
import "./ha-input-helper-text";
import "./ha-svg-icon";
@customElement("ha-target-picker")
export class HaTargetPicker extends LitElement {
export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: HassServiceTarget;
@ -72,14 +91,33 @@ export class HaTargetPicker extends LitElement {
@property({ type: Boolean }) public addOnTop = false;
@state() private _addMode?: "area_id" | "entity_id" | "device_id";
@state() private _addMode?:
| "area_id"
| "entity_id"
| "device_id"
| "label_id";
@query("#input") private _inputElement?;
@query(".add-container", true) private _addContainer?: HTMLDivElement;
@state() private _floors?: FloorRegistryEntry[];
@state() private _labels?: LabelRegistryEntry[];
private _opened = false;
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [
subscribeFloorRegistry(this.hass.connection, (floors) => {
this._floors = floors;
}),
subscribeLabelRegistry(this.hass.connection, (labels) => {
this._labels = labels;
}),
];
}
protected render() {
if (this.addOnTop) {
return html` ${this._renderChips()} ${this._renderItems()} `;
@ -90,6 +128,21 @@ export class HaTargetPicker extends LitElement {
private _renderItems() {
return html`
<div class="mdc-chip-set items">
${this.value?.floor_id
? ensureArray(this.value.floor_id).map((floor_id) => {
const floor = this._floors?.find(
(flr) => flr.floor_id === floor_id
);
return this._renderChip(
"floor_id",
floor_id,
floor?.name || floor_id,
undefined,
floor?.icon,
floor ? floorDefaultIconPath(floor) : mdiHome
);
})
: ""}
${this.value?.area_id
? ensureArray(this.value.area_id).map((area_id) => {
const area = this.hass.areas![area_id];
@ -99,10 +152,10 @@ export class HaTargetPicker extends LitElement {
area?.name || area_id,
undefined,
area?.icon,
mdiSofa
mdiTextureBox
);
})
: ""}
: nothing}
${this.value?.device_id
? ensureArray(this.value.device_id).map((device_id) => {
const device = this.hass.devices![device_id];
@ -115,7 +168,7 @@ export class HaTargetPicker extends LitElement {
mdiDevices
);
})
: ""}
: nothing}
${this.value?.entity_id
? ensureArray(this.value.entity_id).map((entity_id) => {
const entity = this.hass.states[entity_id];
@ -126,7 +179,35 @@ export class HaTargetPicker extends LitElement {
entity
);
})
: ""}
: nothing}
${this.value?.label_id
? ensureArray(this.value.label_id).map((label_id) => {
const label = this._labels?.find(
(lbl) => lbl.label_id === label_id
);
let color = label?.color
? computeCssColor(label.color)
: undefined;
if (color?.startsWith("var(")) {
const computedStyles = getComputedStyle(this);
color = computedStyles.getPropertyValue(
color.substring(4, color.length - 1)
);
}
if (color?.startsWith("#")) {
color = hex2rgb(color).join(",");
}
return this._renderChip(
"label_id",
label_id,
label ? label.name : label_id,
undefined,
label?.icon,
mdiLabel,
color
);
})
: nothing}
</div>
`;
}
@ -194,6 +275,26 @@ export class HaTargetPicker extends LitElement {
</span>
</span>
</div>
<div
class="mdc-chip label_id add"
.type=${"label_id"}
@click=${this._showPicker}
>
<div class="mdc-chip__ripple"></div>
<ha-svg-icon
class="mdc-chip__icon mdc-chip__icon--leading"
.path=${mdiPlus}
></ha-svg-icon>
<span role="gridcell">
<span role="button" tabindex="0" class="mdc-chip__primary-action">
<span class="mdc-chip__text"
>${this.hass.localize(
"ui.components.target-picker.add_label_id"
)}</span
>
</span>
</span>
</div>
${this._renderPicker()}
</div>
${this.helper
@ -207,18 +308,22 @@ export class HaTargetPicker extends LitElement {
}
private _renderChip(
type: "area_id" | "device_id" | "entity_id",
type: "floor_id" | "area_id" | "device_id" | "entity_id" | "label_id",
id: string,
name: string,
entityState?: HassEntity,
icon?: string | null,
fallbackIconPath?: string
fallbackIconPath?: string,
color?: string
) {
return html`
<div
class="mdc-chip ${classMap({
[type]: true,
})}"
style=${color
? `--color: rgb(${color}); --background-color: rgba(${color}, .5)`
: ""}
>
${icon
? html`<ha-icon
@ -296,7 +401,7 @@ export class HaTargetPicker extends LitElement {
@input=${stopPropagation}
>${this._addMode === "area_id"
? html`
<ha-area-picker
<ha-area-floor-picker
.hass=${this.hass}
id="input"
.type=${"area_id"}
@ -309,9 +414,10 @@ export class HaTargetPicker extends LitElement {
.includeDeviceClasses=${this.includeDeviceClasses}
.includeDomains=${this.includeDomains}
.excludeAreas=${ensureArray(this.value?.area_id)}
.excludeFloors=${ensureArray(this.value?.floor_id)}
@value-changed=${this._targetPicked}
@click=${this._preventDefault}
></ha-area-picker>
></ha-area-floor-picker>
`
: this._addMode === "device_id"
? html`
@ -331,23 +437,42 @@ export class HaTargetPicker extends LitElement {
@click=${this._preventDefault}
></ha-device-picker>
`
: html`
<ha-entity-picker
.hass=${this.hass}
id="input"
.type=${"entity_id"}
.label=${this.hass.localize(
"ui.components.target-picker.add_entity_id"
)}
.entityFilter=${this.entityFilter}
.includeDeviceClasses=${this.includeDeviceClasses}
.includeDomains=${this.includeDomains}
.excludeEntities=${ensureArray(this.value?.entity_id)}
@value-changed=${this._targetPicked}
@click=${this._preventDefault}
allow-custom-entity
></ha-entity-picker>
`}</mwc-menu-surface
: this._addMode === "label_id"
? html`
<ha-label-picker
.hass=${this.hass}
id="input"
.type=${"label_id"}
.label=${this.hass.localize(
"ui.components.target-picker.add_label_id"
)}
no-add
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDeviceClasses=${this.includeDeviceClasses}
.includeDomains=${this.includeDomains}
.excludeLabels=${ensureArray(this.value?.label_id)}
@value-changed=${this._targetPicked}
@click=${this._preventDefault}
></ha-label-picker>
`
: html`
<ha-entity-picker
.hass=${this.hass}
id="input"
.type=${"entity_id"}
.label=${this.hass.localize(
"ui.components.target-picker.add_entity_id"
)}
.entityFilter=${this.entityFilter}
.includeDeviceClasses=${this.includeDeviceClasses}
.includeDomains=${this.includeDomains}
.excludeEntities=${ensureArray(this.value?.entity_id)}
@value-changed=${this._targetPicked}
@click=${this._preventDefault}
allow-custom-entity
></ha-entity-picker>
`}</mwc-menu-surface
>`;
}
@ -356,18 +481,24 @@ export class HaTargetPicker extends LitElement {
if (!ev.detail.value) {
return;
}
const value = ev.detail.value;
let value = ev.detail.value;
const target = ev.currentTarget;
let type = target.type;
if (target.type === "entity_id" && !isValidEntityId(value)) {
if (type === "entity_id" && !isValidEntityId(value)) {
return;
}
if (type === "area_id") {
value = ev.detail.value.id;
type = `${ev.detail.value.type}_id`;
}
target.value = "";
if (
this.value &&
this.value[target.type] &&
ensureArray(this.value[target.type]).includes(value)
this.value[type] &&
ensureArray(this.value[type]).includes(value)
) {
return;
}
@ -375,19 +506,31 @@ export class HaTargetPicker extends LitElement {
value: this.value
? {
...this.value,
[target.type]: this.value[target.type]
? [...ensureArray(this.value[target.type]), value]
[type]: this.value[type]
? [...ensureArray(this.value[type]), value]
: value,
}
: { [target.type]: value },
: { [type]: value },
});
}
private _handleExpand(ev) {
const target = ev.currentTarget as any;
const newAreas: string[] = [];
const newDevices: string[] = [];
const newEntities: string[] = [];
if (target.type === "area_id") {
if (target.type === "floor_id") {
Object.values(this.hass.areas).forEach((area) => {
if (
area.floor_id === target.id &&
!this.value!.area_id?.includes(area.area_id) &&
this._areaMeetsFilter(area)
) {
newAreas.push(area.area_id);
}
});
} else if (target.type === "area_id") {
Object.values(this.hass.devices).forEach((device) => {
if (
device.area_id === target.id &&
@ -416,6 +559,34 @@ export class HaTargetPicker extends LitElement {
newEntities.push(entity.entity_id);
}
});
} else if (target.type === "label_id") {
Object.values(this.hass.areas).forEach((area) => {
if (
area.labels.includes(target.id) &&
!this.value!.area_id?.includes(area.area_id) &&
this._areaMeetsFilter(area)
) {
newAreas.push(area.area_id);
}
});
Object.values(this.hass.devices).forEach((device) => {
if (
device.labels.includes(target.id) &&
!this.value!.device_id?.includes(device.id) &&
this._deviceMeetsFilter(device)
) {
newDevices.push(device.id);
}
});
Object.values(this.hass.entities).forEach((entity) => {
if (
entity.labels.includes(target.id) &&
!this.value!.entity_id?.includes(entity.entity_id) &&
this._entityRegMeetsFilter(entity)
) {
newEntities.push(entity.entity_id);
}
});
} else {
return;
}
@ -426,6 +597,9 @@ export class HaTargetPicker extends LitElement {
if (newDevices.length) {
value = this._addItems(value, "device_id", newDevices);
}
if (newAreas.length) {
value = this._addItems(value, "area_id", newAreas);
}
value = this._removeItem(value, target.type, target.id);
fireEvent(this, "value-changed", { value });
}
@ -495,44 +669,33 @@ export class HaTargetPicker extends LitElement {
ev.preventDefault();
}
private _areaMeetsFilter(area: AreaRegistryEntry): boolean {
const areaDevices = Object.values(this.hass.devices).filter(
(device) => device.area_id === area.area_id
);
if (areaDevices.some((device) => this._deviceMeetsFilter(device))) {
return true;
}
const areaEntities = Object.values(this.hass.entities).filter(
(entity) => entity.area_id === area.area_id
);
if (areaEntities.some((entity) => this._entityRegMeetsFilter(entity))) {
return true;
}
return false;
}
private _deviceMeetsFilter(device: DeviceRegistryEntry): boolean {
const devEntities = Object.values(this.hass.entities).filter(
(entity) => entity.device_id === device.id
);
if (this.includeDomains) {
if (!devEntities || !devEntities.length) {
return false;
}
if (
!devEntities.some((entity) =>
this.includeDomains!.includes(computeDomain(entity.entity_id))
)
) {
return false;
}
}
if (this.includeDeviceClasses) {
if (!devEntities || !devEntities.length) {
return false;
}
if (
!devEntities.some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return (
stateObj.attributes.device_class &&
this.includeDeviceClasses!.includes(
stateObj.attributes.device_class
)
);
})
) {
return false;
}
if (!devEntities.some((entity) => this._entityRegMeetsFilter(entity))) {
return false;
}
if (this.deviceFilter) {
@ -541,19 +704,6 @@ export class HaTargetPicker extends LitElement {
}
}
if (this.entityFilter) {
if (
!devEntities.some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return this.entityFilter!(stateObj);
})
) {
return false;
}
}
return true;
}
@ -641,8 +791,8 @@ export class HaTargetPicker extends LitElement {
--mdc-icon-size: 20px;
border-radius: 50%;
padding: 6px;
margin-left: -14px !important;
margin-inline-start: -14px !important;
margin-left: -13px !important;
margin-inline-start: -13px !important;
margin-inline-end: 4px !important;
direction: var(--direction);
}
@ -651,16 +801,19 @@ export class HaTargetPicker extends LitElement {
margin-inline-end: 0;
margin-inline-start: initial;
}
.mdc-chip.area_id:not(.add) {
border: 2px solid #fed6a4;
.mdc-chip.area_id:not(.add),
.mdc-chip.floor_id:not(.add) {
border: 1px solid #fed6a4;
background: var(--card-background-color);
}
.mdc-chip.area_id:not(.add) .mdc-chip__icon--leading,
.mdc-chip.area_id.add {
.mdc-chip.area_id.add,
.mdc-chip.floor_id:not(.add) .mdc-chip__icon--leading,
.mdc-chip.floor_id.add {
background: #fed6a4;
}
.mdc-chip.device_id:not(.add) {
border: 2px solid #a8e1fb;
border: 1px solid #a8e1fb;
background: var(--card-background-color);
}
.mdc-chip.device_id:not(.add) .mdc-chip__icon--leading,
@ -668,13 +821,21 @@ export class HaTargetPicker extends LitElement {
background: #a8e1fb;
}
.mdc-chip.entity_id:not(.add) {
border: 2px solid #d2e7b9;
border: 1px solid #d2e7b9;
background: var(--card-background-color);
}
.mdc-chip.entity_id:not(.add) .mdc-chip__icon--leading,
.mdc-chip.entity_id.add {
background: #d2e7b9;
}
.mdc-chip.label_id:not(.add) {
border: 1px solid var(--color, #e0e0e0);
background: var(--card-background-color);
}
.mdc-chip.label_id:not(.add) .mdc-chip__icon--leading,
.mdc-chip.label_id.add {
background: var(--background-color, #e0e0e0);
}
.mdc-chip:hover {
z-index: 5;
}
@ -690,7 +851,7 @@ export class HaTargetPicker extends LitElement {
}
ha-entity-picker,
ha-device-picker,
ha-area-picker {
ha-area-floor-picker {
display: block;
width: 100%;
}

View File

@ -0,0 +1,96 @@
import { mdiMagnify } from "@mdi/js";
import { CSSResultGroup, LitElement, TemplateResult, css, html } from "lit";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { HomeAssistant } from "../types";
import "./ha-icon-button";
import "./ha-outlined-text-field";
import type { HaOutlinedTextField } from "./ha-outlined-text-field";
import "./ha-svg-icon";
@customElement("search-input-outlined")
class SearchInputOutlined extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public filter?: string;
@property({ type: Boolean })
public suffix = false;
@property({ type: Boolean })
public autofocus = false;
@property({ type: String })
public label?: string;
@property({ type: String })
public placeholder?: string;
public focus() {
this._input?.focus();
}
@query("ha-outlined-text-field", true) private _input!: HaOutlinedTextField;
protected render(): TemplateResult {
const placeholder =
this.placeholder || this.hass.localize("ui.common.search");
return html`
<ha-outlined-text-field
.autofocus=${this.autofocus}
.aria-label=${this.label || this.hass.localize("ui.common.search")}
.placeholder=${placeholder}
.value=${this.filter || ""}
icon
.iconTrailing=${this.filter || this.suffix}
@input=${this._filterInputChanged}
dense
>
<slot name="prefix" slot="leading-icon">
<ha-svg-icon
tabindex="-1"
class="prefix"
.path=${mdiMagnify}
></ha-svg-icon>
</slot>
</ha-outlined-text-field>
`;
}
private async _filterChanged(value: string) {
fireEvent(this, "value-changed", { value: String(value) });
}
private async _filterInputChanged(e) {
this._filterChanged(e.target.value);
}
static get styles(): CSSResultGroup {
return css`
:host {
display: inline-flex;
/* For iOS */
z-index: 0;
}
ha-outlined-text-field {
display: block;
width: 100%;
}
ha-svg-icon,
ha-icon-button {
display: flex;
color: var(--primary-text-color);
}
ha-svg-icon {
outline: none;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"search-input-outlined": SearchInputOutlined;
}
}

View File

@ -7,9 +7,11 @@ export { subscribeAreaRegistry } from "./ws-area_registry";
export interface AreaRegistryEntry {
area_id: string;
floor_id: string | null;
name: string;
picture: string | null;
icon: string | null;
labels: string[];
aliases: string[];
}
@ -23,9 +25,11 @@ export interface AreaDeviceLookup {
export interface AreaRegistryEntryMutableParams {
name: string;
floor_id?: string | null;
picture?: string | null;
icon?: string | null;
aliases?: string[];
labels?: string[];
}
export const createAreaRegistryEntry = (

View File

@ -219,8 +219,8 @@ export interface NumericStateCondition extends BaseCondition {
condition: "numeric_state";
entity_id: string;
attribute?: string;
above?: number;
below?: number;
above?: string | number;
below?: string | number;
value_template?: string;
}

View File

@ -0,0 +1,86 @@
import { Connection, createCollection } from "home-assistant-js-websocket";
import { Store } from "home-assistant-js-websocket/dist/store";
import { stringCompare } from "../common/string/compare";
import { HomeAssistant } from "../types";
import { debounce } from "../common/util/debounce";
export interface CategoryRegistryEntry {
category_id: string;
name: string;
icon: string | null;
}
export interface CategoryRegistryEntryMutableParams {
name: string;
icon?: string | null;
}
export const fetchCategoryRegistry = (conn: Connection, scope: string) =>
conn
.sendMessagePromise<CategoryRegistryEntry[]>({
type: "config/category_registry/list",
scope,
})
.then((categories) =>
categories.sort((ent1, ent2) => stringCompare(ent1.name, ent2.name))
);
export const subscribeCategoryRegistry = (
conn: Connection,
scope: string,
onChange: (floors: CategoryRegistryEntry[]) => void
) =>
createCollection<CategoryRegistryEntry[]>(
`_categoryRegistry_${scope}`,
(conn2: Connection) => fetchCategoryRegistry(conn2, scope),
(conn2: Connection, store: Store<CategoryRegistryEntry[]>) =>
conn2.subscribeEvents(
debounce(
() =>
fetchCategoryRegistry(conn2, scope).then(
(categories: CategoryRegistryEntry[]) =>
store.setState(categories, true)
),
500,
true
),
"category_registry_updated"
),
conn,
onChange
);
export const createCategoryRegistryEntry = (
hass: HomeAssistant,
scope: string,
values: CategoryRegistryEntryMutableParams
) =>
hass.callWS<CategoryRegistryEntry>({
type: "config/category_registry/create",
scope,
...values,
});
export const updateCategoryRegistryEntry = (
hass: HomeAssistant,
scope: string,
category_id: string,
updates: Partial<CategoryRegistryEntryMutableParams>
) =>
hass.callWS<CategoryRegistryEntry>({
type: "config/category_registry/update",
scope,
category_id,
...updates,
});
export const deleteCategoryRegistryEntry = (
hass: HomeAssistant,
scope: string,
category_id: string
) =>
hass.callWS({
type: "config/category_registry/delete",
scope,
category_id,
});

View File

@ -148,6 +148,11 @@ export const updateCloudPref = (
...prefs,
});
export const removeCloudData = (hass: HomeAssistant) =>
hass.callWS({
type: "cloud/remove_data",
});
export const updateCloudGoogleEntityConfig = (
hass: HomeAssistant,
entity_id: string,

View File

@ -1,5 +1,4 @@
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { LocalizeFunc } from "../../common/translations/localize";
import { HomeAssistant } from "../../types";
export interface CloudTTSInfo {
@ -27,27 +26,21 @@ export const getCloudTtsLanguages = (info?: CloudTTSInfo) => {
return languages;
};
export const getCloudTtsSupportedGenders = (
export const getCloudTtsSupportedVoices = (
language: string,
info: CloudTTSInfo | undefined,
localize: LocalizeFunc
info: CloudTTSInfo | undefined
) => {
const genders: Array<[string, string]> = [];
const voices: Array<string> = [];
if (!info) {
return genders;
return voices;
}
for (const [curLang, gender] of info.languages) {
for (const [curLang, voice] of info.languages) {
if (curLang === language) {
genders.push([
gender,
gender === "male" || gender === "female"
? localize(`ui.components.media-browser.tts.gender_${gender}`)
: gender,
]);
voices.push(voice);
}
}
return genders.sort((a, b) => caseInsensitiveStringCompare(a[1], b[1]));
return voices.sort((a, b) => caseInsensitiveStringCompare(a, b));
};

View File

@ -18,6 +18,7 @@ export interface ConfigEntry {
supports_options: boolean;
supports_remove_device: boolean;
supports_unload: boolean;
supports_reconfigure: boolean;
pref_disable_new_entities: boolean;
pref_disable_polling: boolean;
disabled_by: "user" | null;

View File

@ -26,13 +26,18 @@ const HEADERS = {
"HA-Frontend-Base": `${location.protocol}//${location.host}`,
};
export const createConfigFlow = (hass: HomeAssistant, handler: string) =>
export const createConfigFlow = (
hass: HomeAssistant,
handler: string,
entry_id?: string
) =>
hass.callApi<DataEntryFlowStep>(
"POST",
"config/config_entries/flow",
{
handler,
show_advanced_options: Boolean(hass.userData?.showAdvanced),
entry_id,
},
HEADERS
);

View File

@ -20,6 +20,7 @@ export interface DeviceRegistryEntry {
manufacturer: string | null;
model: string | null;
name: string | null;
labels: string[];
sw_version: string | null;
hw_version: string | null;
serial_number: string | null;
@ -43,6 +44,7 @@ export interface DeviceRegistryEntryMutableParams {
area_id?: string | null;
name_by_user?: string | null;
disabled_by?: string | null;
labels?: string[];
}
export const fallbackDeviceName = (
@ -140,7 +142,7 @@ export const getDeviceEntityDisplayLookup = (
export const getDeviceIntegrationLookup = (
entitySources: EntitySources,
entities: EntityRegistryDisplayEntry[]
entities: EntityRegistryDisplayEntry[] | EntityRegistryEntry[]
): Record<string, string[]> => {
const deviceIntegrations: Record<string, string[]> = {};

View File

@ -11,7 +11,11 @@ import {
isLastDayOfMonth,
} from "date-fns/esm";
import { Collection, getCollection } from "home-assistant-js-websocket";
import { calcDate, calcDateProperty } from "../common/datetime/calc_date";
import {
calcDate,
calcDateProperty,
calcDateDifferenceProperty,
} from "../common/datetime/calc_date";
import { formatTime24h } from "../common/datetime/format_time";
import { groupBy } from "../common/util/group-by";
import { HomeAssistant } from "../types";
@ -443,12 +447,12 @@ const getEnergyData = async (
addMonths,
hass.locale,
hass.config,
-(calcDateProperty(
-(calcDateDifferenceProperty(
end || new Date(),
start,
differenceInMonths,
hass.locale,
hass.config,
start
hass.config
) as number) - 1
);
} else {

View File

@ -70,6 +70,7 @@ export const DOMAIN_ATTRIBUTES_UNITS = {
brightness: "%",
},
sun: {
azimuth: "°",
elevation: "°",
},
vacuum: {

View File

@ -10,7 +10,7 @@ import { computeDomain } from "../common/entity/compute_domain";
export { subscribeEntityRegistryDisplay } from "./ws-entity_registry_display";
type entityCategory = "config" | "diagnostic";
type EntityCategory = "config" | "diagnostic";
export interface EntityRegistryDisplayEntry {
entity_id: string;
@ -18,8 +18,9 @@ export interface EntityRegistryDisplayEntry {
icon?: string;
device_id?: string;
area_id?: string;
labels: string[];
hidden?: boolean;
entity_category?: entityCategory;
entity_category?: EntityCategory;
translation_key?: string;
platform?: string;
display_precision?: number;
@ -30,6 +31,7 @@ export interface EntityRegistryDisplayEntryResponse {
ei: string;
di?: string;
ai?: string;
lb: string[];
ec?: number;
en?: string;
ic?: string;
@ -38,7 +40,7 @@ export interface EntityRegistryDisplayEntryResponse {
hb?: boolean;
dp?: number;
}[];
entity_categories: Record<number, entityCategory>;
entity_categories: Record<number, EntityCategory>;
}
export interface EntityRegistryEntry {
@ -50,14 +52,16 @@ export interface EntityRegistryEntry {
config_entry_id: string | null;
device_id: string | null;
area_id: string | null;
labels: string[];
disabled_by: "user" | "device" | "integration" | "config_entry" | null;
hidden_by: Exclude<EntityRegistryEntry["disabled_by"], "config_entry">;
entity_category: entityCategory | null;
entity_category: EntityCategory | null;
has_entity_name: boolean;
original_name?: string;
unique_id: string;
translation_key?: string;
options: EntityRegistryOptions | null;
categories: { [scope: string]: string };
}
export interface ExtEntityRegistryEntry extends EntityRegistryEntry {
@ -133,6 +137,8 @@ export interface EntityRegistryEntryUpdateParams {
| WeatherEntityOptions
| LightEntityOptions;
aliases?: string[];
labels?: string[];
categories?: { [scope: string]: string | null };
}
const batteryPriorities = ["sensor", "binary_sensor"];

133
src/data/floor_registry.ts Normal file
View File

@ -0,0 +1,133 @@
import { Connection, createCollection } from "home-assistant-js-websocket";
import { Store } from "home-assistant-js-websocket/dist/store";
import { stringCompare } from "../common/string/compare";
import { debounce } from "../common/util/debounce";
import { HomeAssistant } from "../types";
import { AreaRegistryEntry } from "./area_registry";
export { subscribeAreaRegistry } from "./ws-area_registry";
export interface FloorRegistryEntry {
floor_id: string;
name: string;
level: number | null;
icon: string | null;
aliases: string[];
}
export interface FloorAreaLookup {
[floorId: string]: AreaRegistryEntry[];
}
export interface FloorRegistryEntryMutableParams {
name: string;
level?: number | null;
icon?: string | null;
aliases?: string[];
}
const fetchFloorRegistry = (conn: Connection) =>
conn
.sendMessagePromise({
type: "config/floor_registry/list",
})
.then((floors) =>
(floors as FloorRegistryEntry[]).sort((ent1, ent2) => {
if (ent1.level !== ent2.level) {
return (ent1.level ?? 9999) - (ent2.level ?? 9999);
}
return stringCompare(ent1.name, ent2.name);
})
);
const subscribeFloorRegistryUpdates = (
conn: Connection,
store: Store<FloorRegistryEntry[]>
) =>
conn.subscribeEvents(
debounce(
() =>
fetchFloorRegistry(conn).then((areas: FloorRegistryEntry[]) =>
store.setState(areas, true)
),
500,
true
),
"floor_registry_updated"
);
export const subscribeFloorRegistry = (
conn: Connection,
onChange: (floors: FloorRegistryEntry[]) => void
) =>
createCollection<FloorRegistryEntry[]>(
"_floorRegistry",
fetchFloorRegistry,
subscribeFloorRegistryUpdates,
conn,
onChange
);
export const createFloorRegistryEntry = (
hass: HomeAssistant,
values: FloorRegistryEntryMutableParams
) =>
hass.callWS<FloorRegistryEntry>({
type: "config/floor_registry/create",
...values,
});
export const updateFloorRegistryEntry = (
hass: HomeAssistant,
floorId: string,
updates: Partial<FloorRegistryEntryMutableParams>
) =>
hass.callWS<AreaRegistryEntry>({
type: "config/floor_registry/update",
floor_id: floorId,
...updates,
});
export const deleteFloorRegistryEntry = (
hass: HomeAssistant,
floorId: string
) =>
hass.callWS({
type: "config/floor_registry/delete",
floor_id: floorId,
});
export const getFloorAreaLookup = (
areas: AreaRegistryEntry[]
): FloorAreaLookup => {
const floorAreaLookup: FloorAreaLookup = {};
for (const area of areas) {
if (!area.floor_id) {
continue;
}
if (!(area.floor_id in floorAreaLookup)) {
floorAreaLookup[area.floor_id] = [];
}
floorAreaLookup[area.floor_id].push(area);
}
return floorAreaLookup;
};
export const floorCompare =
(entries?: FloorRegistryEntry[], order?: string[]) =>
(a: string, b: string) => {
const indexA = order ? order.indexOf(a) : -1;
const indexB = order ? order.indexOf(b) : -1;
if (indexA === -1 && indexB === -1) {
const nameA = entries?.[a]?.name ?? a;
const nameB = entries?.[b]?.name ?? b;
return stringCompare(nameA, nameB);
}
if (indexA === -1) {
return 1;
}
if (indexB === -1) {
return -1;
}
return indexA - indexB;
};

View File

@ -43,6 +43,7 @@ export interface IntegrationManifest {
| "cloud_push"
| "local_polling"
| "local_push";
single_config_entry?: boolean;
}
export interface IntegrationSetup {
domain: string;

View File

@ -11,6 +11,7 @@ export interface Integration {
iot_class?: string;
supported_by?: string;
is_built_in?: boolean;
single_config_entry?: boolean;
}
export interface Integrations {

Some files were not shown because too many files have changed in this diff Show More