Compare commits

..

16 Commits

Author SHA1 Message Date
Paul Bottein
a3cbbde25e Add suggestions label 2025-09-24 10:22:41 +02:00
Paul Bottein
d59be1b279 Update field design 2025-09-24 09:54:29 +02:00
Paul Bottein
240b525aaa Add translation 2025-09-24 08:51:23 +02:00
Paul Bottein
6a991b0a81 Fix duplicated 2025-09-23 18:27:31 +02:00
Paul Bottein
30ff6752e4 Improve entity name editor 2025-09-23 18:07:37 +02:00
Paul Bottein
e05ac1df34 Improve labels 2025-09-22 17:45:48 +02:00
Paul Bottein
2789a66be5 fix search 2025-09-22 14:10:35 +02:00
Paul Bottein
52bc358e18 Handle entity name 2025-09-22 10:57:49 +02:00
Paul Bottein
2333b85a72 Use name content picker 2025-09-22 10:57:49 +02:00
Paul Bottein
6b3e48be6f Rename type 2025-09-22 10:57:49 +02:00
Paul Bottein
1fa6c57eb3 Hide icon 2025-09-22 10:57:49 +02:00
Paul Bottein
f251555b0c Fix flickering 2025-09-22 10:57:49 +02:00
Paul Bottein
d32960a24a Save filter for live update 2025-09-22 10:57:49 +02:00
Paul Bottein
b35ffcffd6 Use combo box 2025-09-22 10:57:49 +02:00
Paul Bottein
4cd0e89ff4 Use select 2025-09-22 10:57:49 +02:00
Paul Bottein
118dd1e690 Add name presets 2025-09-22 10:57:49 +02:00
223 changed files with 3299 additions and 4714 deletions

View File

@@ -42,7 +42,7 @@ jobs:
- name: Deploy to Netlify
id: deploy
run: |
npx -y netlify-cli@23.7.3 deploy --dir=cast/dist --alias dev
npx -y netlify-cli deploy --dir=cast/dist --alias dev
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_CAST_SITE_ID }}
@@ -77,7 +77,7 @@ jobs:
- name: Deploy to Netlify
id: deploy
run: |
npx -y netlify-cli@23.7.3 deploy --dir=cast/dist --prod
npx -y netlify-cli deploy --dir=cast/dist --prod
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_CAST_SITE_ID }}

View File

@@ -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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: |
node_modules/.cache/prettier

View File

@@ -36,14 +36,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
uses: github/codeql-action/init@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3
with:
languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
uses: github/codeql-action/autobuild@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -57,4 +57,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
uses: github/codeql-action/analyze@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3

View File

@@ -43,7 +43,7 @@ jobs:
- name: Deploy to Netlify
id: deploy
run: |
npx -y netlify-cli@23.7.3 deploy --dir=demo/dist --prod
npx -y netlify-cli deploy --dir=demo/dist --prod
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_DEV_SITE_ID }}
@@ -78,7 +78,7 @@ jobs:
- name: Deploy to Netlify
id: deploy
run: |
npx -y netlify-cli@23.7.3 deploy --dir=demo/dist --prod
npx -y netlify-cli deploy --dir=demo/dist --prod
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_SITE_ID }}

View File

@@ -35,7 +35,7 @@ jobs:
- name: Deploy to Netlify
id: deploy
run: |
npx -y netlify-cli@23.7.3 deploy --dir=gallery/dist --prod
npx -y netlify-cli deploy --dir=gallery/dist --prod
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_GALLERY_SITE_ID }}

View File

@@ -40,7 +40,7 @@ jobs:
- name: Deploy preview to Netlify
id: deploy
run: |
npx -y netlify-cli@23.7.3 deploy --dir=gallery/dist --alias "deploy-preview-${{ github.event.number }}" \
npx -y netlify-cli deploy --dir=gallery/dist --alias "deploy-preview-${{ github.event.number }}" \
--json > deploy_output.json
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}

View File

@@ -75,7 +75,7 @@ jobs:
# home-assistant/wheels doesn't support SHA pinning
- name: Build wheels
uses: home-assistant/wheels@2025.09.1
uses: home-assistant/wheels@2025.07.0
with:
abi: cp313
tag: musllinux_1_2

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.10.3.cjs
yarnPath: .yarn/releases/yarn-4.10.0.cjs

View File

@@ -5,17 +5,17 @@ const castContext = framework.CastReceiverContext.getInstance();
const playerManager = castContext.getPlayerManager();
playerManager.setMessageInterceptor(
framework.messages.MessageType.LOAD,
"LOAD" as framework.messages.MessageType.LOAD,
(loadRequestData) => {
const media = loadRequestData.media;
// Special handling if it came from Google Assistant
if (media.entity) {
media.contentId = media.entity;
media.streamType = framework.messages.StreamType.LIVE;
media.streamType = "LIVE" as framework.messages.StreamType.LIVE;
media.contentType = "application/vnd.apple.mpegurl";
// @ts-ignore
media.hlsVideoSegmentFormat =
framework.messages.HlsVideoSegmentFormat.FMP4;
"fmp4" as framework.messages.HlsVideoSegmentFormat.FMP4;
}
return loadRequestData;
}

View File

@@ -75,7 +75,7 @@ export const castDemoEntities: () => Entity[] = () =>
longitude: 4.8903147,
radius: 100,
friendly_name: "Home",
icon: "mdi:home",
icon: "hass:home",
},
},
"input_number.harmonyvolume": {
@@ -88,7 +88,7 @@ export const castDemoEntities: () => Entity[] = () =>
step: 1,
mode: "slider",
friendly_name: "Volume",
icon: "mdi:volume-high",
icon: "hass:volume-high",
},
},
"climate.upstairs": {

View File

@@ -56,7 +56,7 @@ export const castDemoLovelace: () => LovelaceConfig = () => {
type: "weblink",
url: "/lovelace/climate",
name: "Climate controls",
icon: "mdi:arrow-right",
icon: "hass:arrow-right",
},
],
},
@@ -76,7 +76,7 @@ export const castDemoLovelace: () => LovelaceConfig = () => {
type: "weblink",
url: "/lovelace/overview",
name: "Back",
icon: "mdi:arrow-left",
icon: "hass:arrow-left",
},
],
},

View File

@@ -40,7 +40,8 @@ const playDummyMedia = (viewTitle?: string) => {
loadRequestData.media.contentId =
"https://cast.home-assistant.io/images/google-nest-hub.png";
loadRequestData.media.contentType = "image/jpeg";
loadRequestData.media.streamType = framework.messages.StreamType.NONE;
loadRequestData.media.streamType =
"NONE" as framework.messages.StreamType.NONE;
const metadata = new framework.messages.GenericMediaMetadata();
metadata.title = viewTitle;
loadRequestData.media.metadata = metadata;
@@ -89,7 +90,7 @@ const showMediaPlayer = () => {
const options = new framework.CastReceiverOptions();
options.disableIdleTimeout = true;
options.customNamespaces = {
[CAST_NS]: framework.system.MessageType.JSON,
[CAST_NS]: "json" as framework.system.MessageType.JSON,
};
castContext.addCustomMessageListener(
@@ -97,9 +98,7 @@ castContext.addCustomMessageListener(
// @ts-ignore
(ev: ReceivedMessage<HassMessage>) => {
// We received a show Lovelace command, stop media from playing, hide media player and show Lovelace controller
if (
playerManager.getPlayerState() !== framework.messages.PlayerState.IDLE
) {
if (playerManager.getPlayerState() !== "IDLE") {
playerManager.stop();
} else {
showLovelaceController();
@@ -113,7 +112,7 @@ castContext.addCustomMessageListener(
const playerManager = castContext.getPlayerManager();
playerManager.setMessageInterceptor(
framework.messages.MessageType.LOAD,
"LOAD" as framework.messages.MessageType.LOAD,
(loadRequestData) => {
if (
loadRequestData.media.contentId ===
@@ -127,24 +126,23 @@ playerManager.setMessageInterceptor(
// Special handling if it came from Google Assistant
if (media.entity) {
media.contentId = media.entity;
media.streamType = framework.messages.StreamType.LIVE;
media.streamType = "LIVE" as framework.messages.StreamType.LIVE;
media.contentType = "application/vnd.apple.mpegurl";
// @ts-ignore
media.hlsVideoSegmentFormat =
framework.messages.HlsVideoSegmentFormat.FMP4;
"fmp4" as framework.messages.HlsVideoSegmentFormat.FMP4;
}
return loadRequestData;
}
);
playerManager.addEventListener(
framework.events.EventType.MEDIA_STATUS,
"MEDIA_STATUS" as framework.events.EventType.MEDIA_STATUS,
(event) => {
if (
event.mediaStatus?.playerState === framework.messages.PlayerState.IDLE &&
event.mediaStatus?.playerState === "IDLE" &&
event.mediaStatus?.idleReason &&
event.mediaStatus?.idleReason !==
framework.messages.IdleReason.INTERRUPTED
event.mediaStatus?.idleReason !== "INTERRUPTED"
) {
// media finished or stopped, return to default Lovelace
showLovelaceController();

View File

@@ -143,7 +143,7 @@ export const demoEntitiesArsaboo: DemoConfig["entities"] = (localize) =>
state: "on",
attributes: {
friendly_name: "Home Automation",
icon: "mdi:home-automation",
icon: "hass:home-automation",
},
},
"input_boolean.tvtime": {

View File

@@ -4,7 +4,7 @@ export const demoLovelaceArsaboo: DemoConfig["lovelace"] = (localize) => ({
title: "Home Assistant",
views: [
{
icon: "mdi:home-assistant",
icon: "hass:home-assistant",
id: "home",
title: "Home",
cards: [

View File

@@ -1236,7 +1236,7 @@ export const demoLovelaceJimpower: DemoConfig["lovelace"] = () => ({
},
],
path: "security",
icon: "mdi:shield-home",
icon: "hass:shield-home",
name: "Security",
background:
'center / cover no-repeat url("/assets/jimpower/background-15.jpg") fixed',

View File

@@ -17,10 +17,6 @@ export const createMediaPlayerEntities = () => [
new Date().getTime() - 23000
).toISOString(),
volume_level: 0.5,
source_list: ["AirPlay", "Blu-Ray", "TV", "USB", "iPod (USB)"],
source: "AirPlay",
sound_mode_list: ["Movie", "Music", "Game", "Pure Audio"],
sound_mode: "Music",
}),
getEntity("media_player", "music_playing", "playing", {
friendly_name: "Playing The Music",
@@ -28,8 +24,8 @@ export const createMediaPlayerEntities = () => [
media_title: "I Wanna Be A Hippy (Flamman & Abraxas Radio Mix)",
media_artist: "Technohead",
// Pause + Seek + Volume Set + Volume Mute + Previous Track + Next Track + Play Media +
// Select Source + Stop + Clear + Play + Shuffle Set + Browse Media + Grouping
supported_features: 784959,
// Select Source + Stop + Clear + Play + Shuffle Set + Browse Media
supported_features: 195135,
entity_picture: "/images/album_cover.jpg",
media_duration: 300,
media_position: 0,
@@ -38,9 +34,6 @@ export const createMediaPlayerEntities = () => [
new Date().getTime() - 23000
).toISOString(),
volume_level: 0.5,
sound_mode_list: ["Movie", "Music", "Game", "Pure Audio"],
sound_mode: "Music",
group_members: ["media_player.playing", "media_player.stream_playing"],
}),
getEntity("media_player", "stream_playing", "playing", {
friendly_name: "Playing the Stream",
@@ -156,18 +149,15 @@ export const createMediaPlayerEntities = () => [
}),
getEntity("media_player", "receiver_on", "on", {
source_list: ["AirPlay", "Blu-Ray", "TV", "USB", "iPod (USB)"],
sound_mode_list: ["Movie", "Music", "Game", "Pure Audio"],
volume_level: 0.63,
is_volume_muted: false,
source: "TV",
sound_mode: "Movie",
friendly_name: "Receiver (selectable sources)",
// Volume Set + Volume Mute + On + Off + Select Source + Play + Sound Mode
supported_features: 84364,
}),
getEntity("media_player", "receiver_off", "off", {
source_list: ["AirPlay", "Blu-Ray", "TV", "USB", "iPod (USB)"],
sound_mode_list: ["Movie", "Music", "Game", "Pure Audio"],
friendly_name: "Receiver (selectable sources)",
// Volume Set + Volume Mute + On + Off + Select Source + Play + Sound Mode
supported_features: 84364,

View File

@@ -117,7 +117,7 @@ export class DemoHaBadge extends LitElement {
}
.card-content {
display: flex;
gap: var(--ha-space-6);
gap: 24px;
}
`;
}

View File

@@ -155,11 +155,11 @@ export class DemoHaButton extends LitElement {
.card-content {
display: flex;
flex-direction: column;
gap: var(--ha-space-6);
gap: 24px;
}
.card-content div {
display: flex;
gap: var(--ha-space-2);
gap: 8px;
}
`;
}

View File

@@ -1,37 +0,0 @@
---
title: Marquee Text
---
# Marquee Text `<ha-marquee-text>`
Marquee text component scrolls text horizontally if it overflows its container. It supports pausing on hover and customizable speed and pause duration.
## Implementation
### Example Usage
<ha-marquee-text style="width: 200px;">
This is a long text that will scroll horizontally if it overflows the container.
</ha-marquee-text>
```html
<ha-marquee-text style="width: 200px;">
This is a long text that will scroll horizontally if it overflows the
container.
</ha-marquee-text>
```
### API
**Slots**
- default slot: The text content to be displayed and scrolled.
- no default
**Properties/Attributes**
| Name | Type | Default | Description |
| -------------- | ------- | ------- | ---------------------------------------------------------------------------- |
| speed | number | `15` | The speed of the scrolling animation. Higher values result in faster scroll. |
| pause-on-hover | boolean | `true` | Whether to pause the scrolling animation when |
| pause-duration | number | `1000` | The delay in milliseconds before the scrolling animation starts/restarts. |

View File

@@ -1,25 +0,0 @@
import { css, LitElement } from "lit";
import { customElement } from "lit/decorators";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-marquee-text";
@customElement("demo-components-ha-marquee-text")
export class DemoHaMarqueeText extends LitElement {
static styles = css`
ha-card {
max-width: 600px;
margin: 24px auto;
}
.card-content {
display: flex;
flex-direction: column;
align-items: flex-start;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-marquee-text": DemoHaMarqueeText;
}
}

View File

@@ -123,11 +123,11 @@ export class DemoHaProgressButton extends LitElement {
.card-content {
display: flex;
flex-direction: column;
gap: var(--ha-space-6);
gap: 24px;
}
.card-content div {
display: flex;
gap: var(--ha-space-2);
gap: 8px;
}
`;
}

View File

@@ -88,7 +88,7 @@ export class DemoHaSlider extends LitElement {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--ha-space-6);
gap: 24px;
}
`;
}

View File

@@ -70,7 +70,7 @@ export class DemoHaSpinner extends LitElement {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--ha-space-6);
gap: 24px;
}
`;
}

View File

@@ -11,10 +11,7 @@ import "../../../../src/components/ha-alert";
import "../../../../src/components/ha-button-menu";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-form/ha-form";
import type {
HaFormSchema,
HaFormDataContainer,
} from "../../../../src/components/ha-form/types";
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
import "../../../../src/components/ha-formfield";
import "../../../../src/components/ha-icon-button";
import "../../../../src/components/ha-list-item";
@@ -36,7 +33,6 @@ import { haStyle } from "../../../../src/resources/styles";
import type { HomeAssistant } from "../../../../src/types";
import { suggestAddonRestart } from "../../dialogs/suggestAddonRestart";
import { hassioStyle } from "../../resources/hassio-style";
import type { ObjectSelector, Selector } from "../../../../src/data/selector";
const SUPPORTED_UI_TYPES = [
"string",
@@ -82,124 +78,77 @@ class HassioAddonConfig extends LitElement {
@query("ha-yaml-editor") private _editor?: HaYamlEditor;
private _getTranslationEntry(
language: string,
entry: HaFormSchema,
options?: { path?: string[] }
) {
let parent = this.addon.translations[language]?.configuration;
if (!parent) return undefined;
if (options?.path) {
for (const key of options.path) {
parent = parent[key]?.fields;
if (!parent) return undefined;
}
}
return parent[entry.name];
}
public computeLabel = (
entry: HaFormSchema,
_data: HaFormDataContainer,
options?: { path?: string[] }
): string =>
this._getTranslationEntry(this.hass.language, entry, options)?.name ||
this._getTranslationEntry("en", entry, options)?.name ||
public computeLabel = (entry: HaFormSchema): string =>
this.addon.translations[this.hass.language]?.configuration?.[entry.name]
?.name ||
this.addon.translations.en?.configuration?.[entry.name]?.name ||
entry.name;
public computeHelper = (
entry: HaFormSchema,
options?: { path?: string[] }
): string =>
this._getTranslationEntry(this.hass.language, entry, options)
public computeHelper = (entry: HaFormSchema): string =>
this.addon.translations[this.hass.language]?.configuration?.[entry.name]
?.description ||
this._getTranslationEntry("en", entry, options)?.description ||
this.addon.translations.en?.configuration?.[entry.name]?.description ||
"";
private _convertSchema = memoizeOne(
// Convert supervisor schema to selectors
(schema: readonly HaFormSchema[]): HaFormSchema[] =>
this._convertSchemaElements(schema)
(schema: Record<string, any>): HaFormSchema[] =>
schema.map((entry) =>
entry.type === "select"
? {
name: entry.name,
required: entry.required,
selector: { select: { options: entry.options } },
}
: entry.type === "string"
? entry.multiple
? {
name: entry.name,
required: entry.required,
selector: {
select: { options: [], multiple: true, custom_value: true },
},
}
: {
name: entry.name,
required: entry.required,
selector: {
text: {
type: entry.format
? entry.format
: MASKED_FIELDS.includes(entry.name)
? "password"
: "text",
},
},
}
: entry.type === "boolean"
? {
name: entry.name,
required: entry.required,
selector: { boolean: {} },
}
: entry.type === "schema"
? {
name: entry.name,
required: entry.required,
selector: { object: {} },
}
: entry.type === "float" || entry.type === "integer"
? {
name: entry.name,
required: entry.required,
selector: {
number: {
mode: "box",
step: entry.type === "float" ? "any" : undefined,
},
},
}
: entry
)
);
private _convertSchemaElements(
schema: readonly HaFormSchema[]
): HaFormSchema[] {
return schema.map((entry) => this._convertSchemaElement(entry));
}
private _convertSchemaElement(entry: any): HaFormSchema {
if (entry.type === "schema" && !entry.multiple) {
return {
name: entry.name,
type: "expandable",
required: entry.required,
schema: this._convertSchemaElements(entry.schema),
};
}
const selector = this._convertSchemaElementToSelector(entry, false);
if (selector) {
return {
name: entry.name,
required: entry.required,
selector,
};
}
return entry;
}
private _convertSchemaElementToSelector(
entry: any,
force: boolean
): Selector | null {
if (entry.type === "select") {
return { select: { options: entry.options } };
}
if (entry.type === "string") {
return entry.multiple
? { select: { options: [], multiple: true, custom_value: true } }
: {
text: {
type: entry.format
? entry.format
: MASKED_FIELDS.includes(entry.name)
? "password"
: "text",
},
};
}
if (entry.type === "boolean") {
return { boolean: {} };
}
if (entry.type === "schema") {
const fields: NonNullable<ObjectSelector["object"]>["fields"] = {};
for (const child_entry of entry.schema) {
fields[child_entry.name] = {
required: child_entry.required,
selector: this._convertSchemaElementToSelector(child_entry, true)!,
};
}
return {
object: {
multiple: entry.multiple,
fields,
},
};
}
if (entry.type === "float" || entry.type === "integer") {
return {
number: {
mode: "box",
step: entry.type === "float" ? "any" : undefined,
},
};
}
if (force) {
return { object: {} };
}
return null;
}
private _filteredSchema = memoizeOne(
(options: Record<string, unknown>, schema: HaFormSchema[]) =>
schema.filter((entry) => entry.name in options || entry.required)

View File

@@ -159,7 +159,7 @@ class HassioSystemManagedDialog extends LitElement {
display: flex;
justify-content: center;
align-items: center;
gap: var(--ha-space-4);
gap: 16px;
--mdc-icon-size: 48px;
margin-bottom: 32px;
}

View File

@@ -31,7 +31,7 @@ export const hassioStyle = css`
.card-group {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
grid-gap: var(--ha-space-2);
grid-gap: 8px;
}
@media screen and (min-width: 640px) {
.card-group {

View File

@@ -213,7 +213,7 @@ class HaLandingPage extends LandingPageBaseElement {
ha-card .card-content {
display: flex;
flex-direction: column;
gap: var(--ha-space-4);
gap: 16px;
}
ha-alert p {
text-align: unset;

View File

@@ -28,13 +28,13 @@
"dependencies": {
"@babel/runtime": "7.28.4",
"@braintree/sanitize-url": "7.1.1",
"@codemirror/autocomplete": "6.19.0",
"@codemirror/autocomplete": "6.18.7",
"@codemirror/commands": "6.8.1",
"@codemirror/language": "6.11.3",
"@codemirror/legacy-modes": "6.5.1",
"@codemirror/search": "6.5.11",
"@codemirror/state": "6.5.2",
"@codemirror/view": "6.38.3",
"@codemirror/view": "6.38.2",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "6.18.0",
@@ -89,8 +89,8 @@
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "3.9.1",
"@tsparticles/preset-links": "3.2.0",
"@vaadin/combo-box": "24.9.1",
"@vaadin/vaadin-themable-mixin": "24.9.1",
"@vaadin/combo-box": "24.9.0",
"@vaadin/vaadin-themable-mixin": "24.9.0",
"@vibrant/color": "4.0.0",
"@vue/web-component-wrapper": "1.3.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
@@ -111,7 +111,7 @@
"fuse.js": "7.1.0",
"google-timezones-json": "1.2.0",
"gulp-zopfli-green": "6.0.2",
"hls.js": "1.6.13",
"hls.js": "1.6.12",
"home-assistant-js-websocket": "9.5.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "10.7.16",
@@ -157,11 +157,11 @@
"@octokit/auth-oauth-device": "8.0.1",
"@octokit/plugin-retry": "8.0.1",
"@octokit/rest": "22.0.0",
"@rsdoctor/rspack-plugin": "1.3.0",
"@rspack/core": "1.5.7",
"@rsdoctor/rspack-plugin": "1.2.3",
"@rspack/core": "1.5.5",
"@rspack/dev-server": "1.1.4",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.22",
"@types/chromecast-caf-receiver": "6.0.24",
"@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0",
"@types/culori": "4.0.1",
@@ -183,7 +183,7 @@
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
"del": "8.0.1",
"eslint": "9.36.0",
"eslint": "9.35.0",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.10",
@@ -203,7 +203,7 @@
"husky": "9.1.7",
"jsdom": "27.0.0",
"jszip": "3.10.1",
"lint-staged": "16.2.1",
"lint-staged": "16.1.6",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.5.0",
@@ -213,11 +213,11 @@
"rspack-manifest-plugin": "5.1.0",
"serve": "14.2.5",
"sinon": "21.0.0",
"tar": "7.5.1",
"tar": "7.4.3",
"terser-webpack-plugin": "5.3.14",
"ts-lit-plugin": "2.0.2",
"typescript": "5.9.2",
"typescript-eslint": "8.44.1",
"typescript-eslint": "8.44.0",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.4",
"webpack-stats-plugin": "1.1.3",
@@ -235,5 +235,5 @@
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
},
"packageManager": "yarn@4.10.3"
"packageManager": "yarn@4.10.0"
}

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20250924.0"
version = "20250903.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"

View File

@@ -1,40 +1,23 @@
import { formatHex, parse } from "culori";
/**
* Expands a 3-digit hex color to a 6-digit hex color.
* @param hex - The hex color to expand.
* @returns The expanded hex color.
* @throws If the hex color is invalid.
*/
export const expandHex = (hex: string): string => {
const color = parse(hex);
if (!color) {
throw new Error(`Invalid hex color: ${hex}`);
hex = hex.replace("#", "");
if (hex.length === 6) return hex;
let result = "";
for (const val of hex) {
result += val + val;
}
const formattedColor = formatHex(color);
if (!formattedColor) {
throw new Error(`Could not format hex color: ${hex}`);
}
return formattedColor.replace("#", "");
return result;
};
/**
* Blends two hex colors. c1 is placed over c2, blend is c1's opacity.
* @param c1 - The first hex color.
* @param c2 - The second hex color.
* @param blend - The blend percentage (0-100).
* @returns The blended hex color.
*/
// Blend 2 hex colors: c1 is placed over c2, blend is c1's opacity.
export const hexBlend = (c1: string, c2: string, blend = 50): string => {
let color = "";
c1 = expandHex(c1);
c2 = expandHex(c2);
let color = "";
for (let i = 0; i <= 5; i += 2) {
const h1 = parseInt(c1.substring(i, i + 2), 16);
const h2 = parseInt(c2.substring(i, i + 2), 16);
const hex = Math.floor(h2 + (h1 - h2) * (blend / 100))
.toString(16)
.padStart(2, "0");
let hex = Math.floor(h2 + (h1 - h2) * (blend / 100)).toString(16);
while (hex.length < 2) hex = "0" + hex;
color += hex;
}
return `#${color}`;

View File

@@ -1,49 +1,28 @@
import { wcagLuminance, wcagContrast } from "culori";
export const luminosity = (rgb: [number, number, number]): number => {
// http://www.w3.org/TR/WCAG20/#relativeluminancedef
const lum: [number, number, number] = [0, 0, 0];
for (let i = 0; i < rgb.length; i++) {
const chan = rgb[i] / 255;
lum[i] = chan <= 0.03928 ? chan / 12.92 : ((chan + 0.055) / 1.055) ** 2.4;
}
/**
* Calculates the luminosity of an RGB color.
* @param rgb - The RGB color to calculate the luminosity of.
* @returns The luminosity of the color.
*/
export const luminosity = (rgb: [number, number, number]): number =>
wcagLuminance({
mode: "rgb",
r: rgb[0] / 255,
g: rgb[1] / 255,
b: rgb[2] / 255,
});
return 0.2126 * lum[0] + 0.7152 * lum[1] + 0.0722 * lum[2];
};
/**
* Calculates the contrast ratio between two RGB colors.
* @param color1 - The first color to calculate the contrast ratio of.
* @param color2 - The second color to calculate the contrast ratio of.
* @returns The contrast ratio between the two colors.
*/
export const rgbContrast = (
color1: [number, number, number],
color2: [number, number, number]
) =>
wcagContrast(
{
mode: "rgb",
r: color1[0] / 255,
g: color1[1] / 255,
b: color1[2] / 255,
},
{
mode: "rgb",
r: color2[0] / 255,
g: color2[1] / 255,
b: color2[2] / 255,
}
);
) => {
const lum1 = luminosity(color1);
const lum2 = luminosity(color2);
if (lum1 > lum2) {
return (lum1 + 0.05) / (lum2 + 0.05);
}
return (lum2 + 0.05) / (lum1 + 0.05);
};
/**
* Calculates the contrast ratio between two RGB colors.
* @param rgb1 - The first color to calculate the contrast ratio of.
* @param rgb2 - The second color to calculate the contrast ratio of.
* @returns The contrast ratio between the two colors.
*/
export const getRGBContrastRatio = (
rgb1: [number, number, number],
rgb2: [number, number, number]

View File

@@ -1,13 +1,13 @@
import type { HassConfig, HassEntity } from "home-assistant-js-websocket";
import type { FrontendLocaleData } from "../../data/translation";
import type { HomeAssistant } from "../../types";
import type { LocalizeFunc } from "./localize";
import { computeEntityName } from "../entity/compute_entity_name";
import { computeDeviceName } from "../entity/compute_device_name";
import { getEntityContext } from "../entity/context/get_entity_context";
import { computeAreaName } from "../entity/compute_area_name";
import { computeFloorName } from "../entity/compute_floor_name";
import { ensureArray } from "../array/ensure-array";
import { computeAreaName } from "../entity/compute_area_name";
import { computeDeviceName } from "../entity/compute_device_name";
import { computeEntityName } from "../entity/compute_entity_name";
import { computeFloorName } from "../entity/compute_floor_name";
import { getEntityContext } from "../entity/context/get_entity_context";
import type { LocalizeFunc } from "./localize";
export type FormatEntityStateFunc = (
stateObj: HassEntity,
@@ -77,7 +77,6 @@ export const computeFormatFunctions = async (
computeAttributeNameDisplay(localize, stateObj, entities, attribute),
formatEntityName: (stateObj, type, separator = " ") => {
const types = ensureArray(type);
const namesList: (string | undefined)[] = [];
const { device, area, floor } = getEntityContext(
stateObj,
@@ -87,33 +86,24 @@ export const computeFormatFunctions = async (
floors
);
for (const t of types) {
switch (t) {
case "entity": {
namesList.push(computeEntityName(stateObj, entities, devices));
break;
const names = types
.map((t) => {
switch (t) {
case "entity":
return computeEntityName(stateObj, entities, devices);
case "device":
return device ? computeDeviceName(device) : undefined;
case "area":
return area ? computeAreaName(area) : undefined;
case "floor":
return floor ? computeFloorName(floor) : undefined;
default:
return t;
}
case "device": {
if (device) {
namesList.push(computeDeviceName(device));
}
break;
}
case "area": {
if (area) {
namesList.push(computeAreaName(area));
}
break;
}
case "floor": {
if (floor) {
namesList.push(computeFloorName(floor));
}
break;
}
}
}
return namesList.filter((name) => name !== undefined).join(separator);
})
.filter((name) => name !== undefined);
return names.join(separator);
},
};
};

View File

@@ -974,7 +974,7 @@ export class HaChartBase extends LitElement {
right: 4px;
display: flex;
flex-direction: column;
gap: var(--ha-space-1);
gap: 4px;
}
.chart-controls.small {
top: 0;
@@ -1011,7 +1011,7 @@ export class HaChartBase extends LitElement {
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: var(--ha-space-2);
gap: 8px;
}
.chart-legend li {
height: 24px;

View File

@@ -84,9 +84,6 @@ export class StateHistoryCharts extends LitElement {
@property({ attribute: "expand-legend", type: Boolean })
public expandLegend?: boolean;
@property({ attribute: "sync-charts", type: Boolean })
public syncCharts = false;
private _computedStartTime!: Date;
private _computedEndTime!: Date;
@@ -149,7 +146,7 @@ export class StateHistoryCharts extends LitElement {
: html`${combinedItems.map((item, index) =>
this._renderHistoryItem(item, index)
)}`}
${this.syncCharts && this._hasZoomedCharts
${this._hasZoomedCharts
? html`<ha-fab
slot="fab"
class="reset-button"
@@ -193,7 +190,7 @@ export class StateHistoryCharts extends LitElement {
@chart-zoom-with-index=${this._handleTimelineSync}
.height=${this.virtualize ? undefined : this.height}
.expandLegend=${this.expandLegend}
?hide-reset-button=${this.syncCharts}
hide-reset-button
></state-history-chart-line>
</div> `;
}
@@ -212,7 +209,7 @@ export class StateHistoryCharts extends LitElement {
.clickForMoreInfo=${this.clickForMoreInfo}
@y-width-changed=${this._yWidthChanged}
@chart-zoom-with-index=${this._handleTimelineSync}
?hide-reset-button=${this.syncCharts}
hide-reset-button
></state-history-chart-timeline>
</div> `;
};
@@ -305,7 +302,7 @@ export class StateHistoryCharts extends LitElement {
private _handleTimelineSync(
e: CustomEvent<HASSDomEvents["chart-zoom-with-index"]>
) {
if (!this.syncCharts || this._isSyncing) {
if (this._isSyncing) {
return;
}
@@ -447,7 +444,6 @@ export class StateHistoryCharts extends LitElement {
position: fixed;
bottom: calc(24px + var(--safe-area-inset-bottom));
right: calc(24px + var(--safe-area-inset-bottom));
z-index: 1;
}
`;
}

View File

@@ -0,0 +1,447 @@
import "@material/mwc-menu/mwc-menu-surface";
import { mdiDrag, mdiPlus } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { IFuseOptions } from "fuse.js";
import Fuse from "fuse.js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import {
computeEntityDisplayName,
ENTITY_DISPLAY_NAME_TYPES,
type EntityDisplayNameType,
} from "../../panels/lovelace/common/entity/compute-display-name";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../chips/ha-assist-chip";
import "../chips/ha-chip-set";
import "../chips/ha-filter-chip";
import "../chips/ha-input-chip";
import "../ha-combo-box";
import type { HaComboBox } from "../ha-combo-box";
import "../ha-list-item";
import "../ha-select";
import "../ha-sortable";
import "../ha-textfield";
interface EntityNameOption {
primary: string;
secondary?: string;
value: string;
}
const SUGGESTION_ID = "___SUGGESTION___";
const suggestionStyle = styleMap({
padding: "6px 16px",
"background-color": "var(--mdc-text-field-fill-color, whitesmoke)",
"font-weight": "normal",
"font-size": "var(--ha-font-size-s)",
color: "var(--primary-text-color)",
});
const rowRenderer: ComboBoxLitRenderer<EntityNameOption> = (item) => {
if (item.value === SUGGESTION_ID) {
return html` <div style=${suggestionStyle}>${item.primary}</div>`;
}
return html`
<ha-combo-box-item type="button">
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
</ha-combo-box-item>
`;
};
@customElement("ha-entity-name-picker")
export class HaEntityNamePicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId?: string;
@property() public value?: string[] | string;
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public required = false;
@property({ type: Boolean, reflect: true }) public disabled = false;
@query(".container", true) private _container?: HTMLDivElement;
@query("ha-combo-box", true) private _comboBox!: HaComboBox;
@state() private _opened = false;
private _editIndex?: number;
private _validOptions = memoizeOne((entityId: string) => {
const stateObj = this.hass.states[entityId];
if (!stateObj) {
return [];
}
const context = getEntityContext(
stateObj,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const options: EntityDisplayNameType[] = ["entity_name"];
if (context.device) options.push("device_name");
if (context.area) options.push("area_name");
if (context.floor) options.push("floor_name");
return options;
});
private _getOptions = memoizeOne((entityId?: string) => {
if (!entityId) {
return [];
}
const items = this._validOptions(entityId).map<EntityNameOption>(
(name) => ({
primary: this.hass.localize(
`ui.components.entity.entity-name-picker.types.${name}`
),
secondary: computeEntityDisplayName(
this.hass,
this.hass.states[entityId],
name
),
value: name,
})
);
return items;
});
protected render() {
if (!this.hass) {
return nothing;
}
const value = this._value;
const options = this._getOptions(this.entityId);
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<div class="container">
<ha-sortable
no-style
@item-moved=${this._moveItem}
.disabled=${this.disabled}
handle-selector="button.primary.action"
filter=".add"
>
<ha-chip-set>
${repeat(
this._value,
(item) => item,
(item, idx) => {
const label = ENTITY_DISPLAY_NAME_TYPES.includes(item)
? this.hass.localize(
`ui.components.entity.entity-name-picker.types.${item as EntityDisplayNameType}`
)
: `"${item}"`;
return html`
<ha-input-chip
data-idx=${idx}
@remove=${this._removeItem}
@click=${this._editItem}
.label=${label}
.selected=${!this.disabled}
.disabled=${this.disabled}
>
<ha-svg-icon slot="icon" .path=${mdiDrag}></ha-svg-icon>
<span>${label}</span>
</ha-input-chip>
`;
}
)}
${this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this.hass.localize(
"ui.components.entity.entity-name-picker.add"
)}
class="add"
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-assist-chip>
`}
</ha-chip-set>
</ha-sortable>
<mwc-menu-surface
.open=${this._opened}
@closed=${this._onClosed}
@opened=${this._onOpened}
@input=${stopPropagation}
.anchor=${this._container}
>
<ha-combo-box
.hass=${this.hass}
.value=${""}
.autofocus=${this.autofocus}
.disabled=${this.disabled || !this.entityId}
.required=${this.required && !value.length}
.helper=${this.helper}
.items=${options}
allow-custom-value
item-id-path="value"
item-value-path="value"
item-label-path="primary"
.renderer=${rowRenderer}
@opened-changed=${this._openedChanged}
@value-changed=${this._comboBoxValueChanged}
@filter-changed=${this._filterChanged}
>
</ha-combo-box>
</mwc-menu-surface>
</div>
`;
}
private _onClosed(ev) {
ev.stopPropagation();
this._opened = false;
this._editIndex = undefined;
}
private async _onOpened(ev) {
if (!this._opened) {
return;
}
ev.stopPropagation();
this._opened = true;
await this._comboBox?.focus();
await this._comboBox?.open();
}
private async _addItem(ev) {
ev.stopPropagation();
this._opened = true;
}
private async _editItem(ev) {
ev.stopPropagation();
const idx = parseInt(ev.currentTarget.dataset.idx, 10);
this._editIndex = idx;
this._opened = true;
}
private get _value() {
return this.value ? ensureArray(this.value) : [];
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
const open = ev.detail.value;
if (open) {
const options = this._comboBox.items || [];
const initialValue =
this._editIndex != null ? this._value[this._editIndex] : "";
this._comboBox.filteredItems = this._filterSelectedOptions(
options,
initialValue
);
this._comboBox.setInputValue(initialValue);
} else {
this._opened = false;
}
}
private _filterSelectedOptions = (
options: EntityNameOption[],
current?: string
) => {
const value = this._value;
const filteredOptions = options.filter(
(option) => !value.includes(option.value) || option.value === current
);
if (filteredOptions.length) {
filteredOptions.unshift({
primary: this.hass.localize(
"ui.components.entity.entity-name-picker.suggestions"
),
value: SUGGESTION_ID,
});
}
return filteredOptions;
};
private _filterChanged(ev: ValueChangedEvent<string>) {
const input = ev.detail.value;
const filter = input?.toLowerCase() || "";
const options = this._comboBox.items || [];
const currentValue =
this._editIndex != null ? this._value[this._editIndex] : "";
this._comboBox.filteredItems = this._filterSelectedOptions(
options,
currentValue
);
if (!filter) {
return;
}
const fuseOptions: IFuseOptions<EntityNameOption> = {
keys: ["primary", "secondary", "value"],
isCaseSensitive: false,
minMatchCharLength: Math.min(filter.length, 2),
threshold: 0.2,
ignoreDiacritics: true,
};
const fuse = new Fuse(this._comboBox.filteredItems, fuseOptions);
const filteredItems = fuse.search(filter).map((result) => result.item);
this._comboBox.filteredItems = filteredItems;
}
private async _moveItem(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const value = this._value;
const newValue = value.concat();
const element = newValue.splice(oldIndex, 1)[0];
newValue.splice(newIndex, 0, element);
this._setValue(newValue);
await this.updateComplete;
this._filterChanged({ detail: { value: "" } } as ValueChangedEvent<string>);
}
private async _removeItem(ev) {
ev.stopPropagation();
const value: string[] = [...this._value];
const idx = parseInt(ev.target.dataset.idx, 10);
value.splice(idx, 1);
this._setValue(value);
await this.updateComplete;
this._filterChanged({ detail: { value: "" } } as ValueChangedEvent<string>);
}
private _comboBoxValueChanged(ev: ValueChangedEvent<string>): void {
ev.stopPropagation();
const value = ev.detail.value;
if (this.disabled || value === "" || value === SUGGESTION_ID) {
return;
}
const currentValue = [...this._value];
const newValue = [...currentValue];
if (this._editIndex != null) {
newValue[this._editIndex] = value;
} else {
newValue.push(value);
}
this._setValue(newValue);
}
private _setValue(value: string[]) {
const newValue =
value.length === 0 ? [] : value.length === 1 ? value[0] : value;
this.value = newValue;
fireEvent(this, "value-changed", {
value: newValue,
});
}
static styles = css`
:host {
position: relative;
width: 100%;
}
.container {
position: relative;
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-radius: 4px;
border-end-end-radius: 0;
border-end-start-radius: 0;
}
.container:after {
display: block;
content: "";
position: absolute;
pointer-events: none;
bottom: 0;
left: 0;
right: 0;
height: 1px;
width: 100%;
background-color: var(
--mdc-text-field-idle-line-color,
rgba(0, 0, 0, 0.42)
);
transform:
height 180ms ease-in-out,
background-color 180ms ease-in-out;
}
:host([disabled]) .container:after {
background-color: var(
--mdc-text-field-disabled-line-color,
rgba(0, 0, 0, 0.42)
);
}
.container:focus-within:after {
height: 2px;
background-color: var(--mdc-theme-primary);
}
label {
display: block;
margin: 0 0 8px;
}
.add {
order: 1;
}
mwc-menu-surface {
--mdc-menu-min-width: 100%;
}
ha-chip-set {
padding: 8px 8px;
}
.sortable-fallback {
display: none;
opacity: 0;
}
.sortable-ghost {
opacity: 0.4;
}
.sortable-drag {
cursor: grabbing;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-entity-name-picker": HaEntityNamePicker;
}
}

View File

@@ -86,10 +86,6 @@ class HaAlert extends LitElement {
padding: 8px;
display: flex;
}
.icon {
height: var(--ha-alert-icon-size, 24px);
width: var(--ha-alert-icon-size, 24px);
}
.issue-type::after {
position: absolute;
top: 0;

View File

@@ -1,5 +1,5 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
@@ -73,18 +73,14 @@ export class HaAnalytics extends LitElement {
.checked=${this.analytics?.preferences[preference]}
.preference=${preference}
name=${preference}
?disabled=${baseEnabled}
>
</ha-switch>
${baseEnabled
? nothing
: html`<ha-tooltip
.for="switch-${preference}"
placement="right"
>
${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.need_base_enabled`
)}
</ha-tooltip>`}
<ha-tooltip .for="switch-${preference}" placement="right">
${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.need_base_enabled`
)}
</ha-tooltip>
</span>
</ha-settings-row>
`

View File

@@ -54,7 +54,7 @@ export class HaBadge extends LitElement {
flex-direction: row;
align-items: center;
justify-content: center;
gap: var(--ha-space-2);
gap: 8px;
height: var(--ha-badge-size, 36px);
min-width: var(--ha-badge-size, 36px);
padding: 0px 12px;

View File

@@ -54,9 +54,9 @@ export class HaBottomSheet extends LitElement {
border-top-left-radius: var(--ha-border-radius-lg);
border-top-right-radius: var(--ha-border-radius-lg);
max-height: 90vh;
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
margin-bottom: var(--safe-area-inset-bottom);
margin-left: var(--safe-area-inset-left);
margin-right: var(--safe-area-inset-right);
}
`;
}

View File

@@ -5,16 +5,9 @@ import type {
CompletionResult,
CompletionSource,
} from "@codemirror/autocomplete";
import { undo, undoDepth, redo, redoDepth } from "@codemirror/commands";
import type { Extension, TransactionSpec } from "@codemirror/state";
import type { EditorView, KeyBinding, ViewUpdate } from "@codemirror/view";
import {
mdiArrowExpand,
mdiArrowCollapse,
mdiContentCopy,
mdiUndo,
mdiRedo,
} from "@mdi/js";
import { mdiArrowExpand, mdiArrowCollapse } from "@mdi/js";
import type { HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, ReactiveElement, html, render } from "lit";
@@ -23,14 +16,11 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { getEntityContext } from "../common/entity/context/get_entity_context";
import { copyToClipboard } from "../common/util/copy-clipboard";
import type { HomeAssistant } from "../types";
import { showToast } from "../util/toast";
import "./ha-code-editor-completion-items";
import type { CompletionItem } from "./ha-code-editor-completion-items";
import "./ha-icon";
import "./ha-icon-button-toolbar";
import type { HaIconButtonToolbar } from "./ha-icon-button-toolbar";
import "./ha-icon-button";
import "./ha-code-editor-completion-items";
declare global {
interface HASSDomEvents {
@@ -78,24 +68,13 @@ export class HaCodeEditor extends ReactiveElement {
@property({ type: Boolean, attribute: "disable-fullscreen" })
public disableFullscreen = false;
@property({ type: Boolean, attribute: "has-toolbar" })
public hasToolbar = true;
@state() private _value = "";
@state() private _isFullscreen = false;
@state() private _canUndo = false;
@state() private _canRedo = false;
@state() private _canCopy = false;
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
private _loadedCodeMirror?: typeof import("../resources/codemirror");
private _editorToolbar?: HaIconButtonToolbar;
private _iconList?: Completion[];
public set value(value: string) {
@@ -140,7 +119,9 @@ export class HaCodeEditor extends ReactiveElement {
super.disconnectedCallback();
this.removeEventListener("keydown", stopPropagation);
this.removeEventListener("keydown", this._handleKeyDown);
this._updateFullscreenState(false);
if (this._isFullscreen) {
this._toggleFullscreen();
}
this.updateComplete.then(() => {
this.codemirror!.destroy();
delete this.codemirror;
@@ -176,7 +157,6 @@ export class HaCodeEditor extends ReactiveElement {
this._loadedCodeMirror!.EditorView!.editable.of(!this.readOnly)
),
});
this._updateToolbarButtons();
}
if (changedProps.has("linewrap")) {
transactions.push({
@@ -197,25 +177,14 @@ export class HaCodeEditor extends ReactiveElement {
if (transactions.length > 0) {
this.codemirror.dispatch(...transactions);
}
if (changedProps.has("hasToolbar")) {
this._updateToolbar();
}
if (changedProps.has("error")) {
this.classList.toggle("error-state", this.error);
}
if (changedProps.has("_isFullscreen")) {
this.classList.toggle("fullscreen", this._isFullscreen);
this._updateToolbarButtons();
}
if (
changedProps.has("_canCopy") ||
changedProps.has("_canUndo") ||
changedProps.has("_canRedo")
) {
this._updateToolbarButtons();
}
if (changedProps.has("disableFullscreen")) {
this._updateFullscreenState();
this._updateFullscreenButton();
}
}
@@ -284,7 +253,6 @@ export class HaCodeEditor extends ReactiveElement {
}
}
// Create the code editor
this.codemirror = new this._loadedCodeMirror.EditorView({
state: this._loadedCodeMirror.EditorState.create({
doc: this._value,
@@ -292,160 +260,71 @@ export class HaCodeEditor extends ReactiveElement {
}),
parent: this.renderRoot,
});
this._canCopy = this._value?.length > 0;
// Update the toolbar. Creating it if required
this._updateToolbar();
this._updateFullscreenButton();
}
private _fullscreenLabel(): string {
if (this._isFullscreen)
return (
this.hass?.localize("ui.components.yaml-editor.exit_fullscreen") ||
"Exit fullscreen"
private _updateFullscreenButton() {
const existingButton = this.renderRoot.querySelector(".fullscreen-button");
if (this.disableFullscreen) {
// Remove button if it exists and fullscreen is disabled
if (existingButton) {
existingButton.remove();
}
// Exit fullscreen if currently in fullscreen mode
if (this._isFullscreen) {
this._isFullscreen = false;
}
return;
}
// Create button if it doesn't exist
if (!existingButton) {
const button = document.createElement("ha-icon-button");
(button as any).path = this._isFullscreen
? mdiArrowCollapse
: mdiArrowExpand;
button.setAttribute(
"label",
this._isFullscreen ? "Exit fullscreen" : "Enter fullscreen"
);
button.classList.add("fullscreen-button");
// Use bound method to ensure proper this context
button.addEventListener("click", this._handleFullscreenClick);
this.renderRoot.appendChild(button);
} else {
// Update existing button
(existingButton as any).path = this._isFullscreen
? mdiArrowCollapse
: mdiArrowExpand;
existingButton.setAttribute(
"label",
this._isFullscreen ? "Exit fullscreen" : "Enter fullscreen"
);
return (
this.hass?.localize("ui.components.yaml-editor.enter_fullscreen") ||
"Enter fullscreen"
);
}
private _fullscreenIcon(): string {
return this._isFullscreen ? mdiArrowCollapse : mdiArrowExpand;
}
private _createEditorToolbar(): HaIconButtonToolbar {
// Create the editor toolbar element
const editorToolbar = document.createElement("ha-icon-button-toolbar");
editorToolbar.classList.add("code-editor-toolbar");
editorToolbar.items = [];
return editorToolbar;
}
private _updateToolbar() {
// Show/Hide the toolbar if we have one.
this.classList.toggle("hasToolbar", this.hasToolbar);
// Update fullscreen state. Handles toolbar and fullscreen mode being disabled.
this._updateFullscreenState();
// If we don't have a toolbar, nothing to update
if (!this.hasToolbar) {
return;
}
// If we don't yet have the toolbar, create it.
if (!this._editorToolbar) {
this._editorToolbar = this._createEditorToolbar();
}
// Ensure all toolbar buttons are correctly configured.
this._updateToolbarButtons();
// Render the toolbar. This must be placed as a child of the code
// mirror element to ensure it doesn't affect the positioning and
// size of codemirror.
this.codemirror?.dom.appendChild(this._editorToolbar);
}
private _updateToolbarButtons() {
// Re-render all toolbar items.
if (!this._editorToolbar) {
return;
}
this._editorToolbar.items = [
{
id: "undo",
disabled: !this._canUndo,
label: this.hass?.localize("ui.common.undo") || "Undo",
path: mdiUndo,
action: (e: Event) => this._handleUndoClick(e),
},
{
id: "redo",
disabled: !this._canRedo,
label: this.hass?.localize("ui.common.redo") || "Redo",
path: mdiRedo,
action: (e: Event) => this._handleRedoClick(e),
},
{
id: "copy",
disabled: !this._canCopy,
label:
this.hass?.localize("ui.components.yaml-editor.copy_to_clipboard") ||
"Copy to Clipboard",
path: mdiContentCopy,
action: (e: Event) => this._handleClipboardClick(e),
},
{
id: "fullscreen",
disabled: this.disableFullscreen,
label: this._fullscreenLabel(),
path: this._fullscreenIcon(),
action: (e: Event) => this._handleFullscreenClick(e),
},
];
}
private _updateFullscreenState(
fullscreen: boolean = this._isFullscreen
): boolean {
// Update the current fullscreen state based on selected value. If fullscreen
// is disabled, or we have no toolbar, ensure we are not in fullscreen mode.
this._isFullscreen =
fullscreen && !this.disableFullscreen && this.hasToolbar;
// Return whether successfully in requested state
return this._isFullscreen === fullscreen;
}
private _handleClipboardClick = async (e: Event) => {
e.preventDefault();
e.stopPropagation();
if (this.value) {
await copyToClipboard(this.value);
showToast(this, {
message:
this.hass?.localize("ui.common.copied_clipboard") ||
"Copied to clipboard",
});
}
};
private _handleUndoClick = (e: Event) => {
e.preventDefault();
e.stopPropagation();
if (!this.codemirror) {
return;
}
undo(this.codemirror);
};
private _handleRedoClick = (e: Event) => {
e.preventDefault();
e.stopPropagation();
if (!this.codemirror) {
return;
}
redo(this.codemirror);
};
private _handleFullscreenClick = (e: Event) => {
e.preventDefault();
e.stopPropagation();
this._updateFullscreenState(!this._isFullscreen);
this._toggleFullscreen();
};
private _toggleFullscreen() {
this._isFullscreen = !this._isFullscreen;
this._updateFullscreenButton();
}
private _handleKeyDown = (e: KeyboardEvent) => {
if (
(e.key === "Escape" &&
this._isFullscreen &&
this._updateFullscreenState(false)) ||
(e.key === "F11" && this._updateFullscreenState(true))
) {
// If we successfully performed the action, stop it propagating further.
if (this._isFullscreen && e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
this._toggleFullscreen();
} else if (e.key === "F11" && !this.disableFullscreen) {
e.preventDefault();
e.stopPropagation();
this._toggleFullscreen();
}
};
@@ -713,13 +592,10 @@ export class HaCodeEditor extends ReactiveElement {
}
private _onUpdate = (update: ViewUpdate): void => {
this._canUndo = !this.readOnly && undoDepth(update.state) > 0;
this._canRedo = !this.readOnly && redoDepth(update.state) > 0;
if (!update.docChanged) {
return;
}
this._value = update.state.doc.toString();
this._canCopy = this._value?.length > 0;
fireEvent(this, "value-changed", { value: this._value });
};
@@ -738,33 +614,39 @@ export class HaCodeEditor extends ReactiveElement {
:host {
position: relative;
display: block;
--code-editor-toolbar-height: 28px;
}
:host(.error-state) .cm-gutters {
border-color: var(--error-state-color, var(--error-color)) !important;
border-color: var(--error-state-color, red);
}
:host(.hasToolbar) .cm-gutters {
padding-top: 0;
.fullscreen-button {
position: absolute;
top: 8px;
right: 8px;
z-index: 1;
color: var(--secondary-text-color);
background-color: var(--secondary-background-color);
border-radius: 50%;
opacity: 0.9;
transition: opacity 0.2s;
--mdc-icon-button-size: 32px;
--mdc-icon-size: 18px;
/* Ensure button is clickable on iOS */
cursor: pointer;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
:host(.hasToolbar) .cm-focused .cm-gutters {
padding-top: 1px;
.fullscreen-button:hover,
.fullscreen-button:active {
opacity: 1;
}
:host(.error-state) .cm-content {
border-color: var(--error-state-color, var(--error-color)) !important;
}
:host(.hasToolbar) .cm-content {
border: none;
border-top: 1px solid var(--secondary-text-color);
}
:host(.hasToolbar) .cm-focused .cm-content {
border-top: 2px solid var(--primary-color);
padding-top: 15px;
@media (hover: none) {
.fullscreen-button {
opacity: 0.8;
}
}
:host(.fullscreen) {
@@ -773,7 +655,7 @@ export class HaCodeEditor extends ReactiveElement {
left: 8px !important;
right: 8px !important;
bottom: 8px !important;
z-index: 6;
z-index: 9999 !important;
border-radius: 12px !important;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3) !important;
overflow: hidden !important;
@@ -790,28 +672,15 @@ export class HaCodeEditor extends ReactiveElement {
display: block !important;
}
:host(.hasToolbar) .cm-editor {
padding-top: var(--code-editor-toolbar-height);
}
:host(.fullscreen) .cm-editor {
height: 100% !important;
max-height: 100% !important;
border-radius: 0 !important;
}
:host(:not(.hasToolbar)) .code-editor-toolbar {
display: none !important;
}
.code-editor-toolbar {
--icon-button-toolbar-height: var(--code-editor-toolbar-height);
--icon-button-toolbar-color: var(
--code-editor-gutter-color,
var(--secondary-background-color, whitesmoke)
);
border-top-left-radius: var(--ha-border-radius-sm);
border-top-right-radius: var(--ha-border-radius-sm);
:host(.fullscreen) .fullscreen-button {
top: calc(var(--safe-area-inset-top, 0px) + 8px);
right: calc(var(--safe-area-inset-right, 0px) + 8px);
}
.completion-info {

View File

@@ -76,7 +76,7 @@ export class HaCopyTextfield extends LitElement {
.container {
display: flex;
align-items: center;
gap: var(--ha-space-2);
gap: 8px;
margin-top: 8px;
}

View File

@@ -343,7 +343,7 @@ export class HaDateRangePicker extends LitElement {
.date-range-inputs {
display: flex;
align-items: center;
gap: var(--ha-space-2);
gap: 8px;
}
.date-range-ranges {

View File

@@ -100,6 +100,8 @@ export class HaDialog extends DialogBase {
}
.mdc-dialog__container {
align-items: var(--vertical-align-dialog, center);
padding-top: var(--safe-area-inset-top);
padding-bottom: var(--safe-area-inset-bottom);
}
.mdc-dialog__title {
padding: 16px 16px 0 16px;
@@ -121,7 +123,11 @@ export class HaDialog extends DialogBase {
position: var(--dialog-surface-position, relative);
top: var(--dialog-surface-top);
margin-top: var(--dialog-surface-margin-top);
min-width: var(--mdc-dialog-min-width, 100vw);
min-width: calc(
var(--mdc-dialog-min-width, 100vw) - var(
--safe-area-inset-left
) - var(--safe-area-inset-right)
);
min-height: var(--mdc-dialog-min-height, auto);
border-radius: var(--ha-dialog-border-radius, 24px);
-webkit-backdrop-filter: var(--ha-dialog-surface-backdrop-filter, none);
@@ -138,12 +144,18 @@ export class HaDialog extends DialogBase {
@media all and (max-width: 450px), all and (max-height: 500px) {
.mdc-dialog .mdc-dialog__surface {
min-height: 100vh;
max-height: 100vh;
padding-top: var(--safe-area-inset-top);
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
min-height: calc(
100vh - var(--safe-area-inset-top, 0px) - var(
--safe-area-inset-bottom,
0px
)
);
max-height: calc(
100vh - var(--safe-area-inset-top, 0px) - var(
--safe-area-inset-bottom,
0px
)
);
}
}

View File

@@ -77,8 +77,8 @@ export class HaFormGrid extends LitElement implements HaFormElement {
var(--form-grid-column-count, auto-fit),
minmax(var(--form-grid-min-width, 200px), 1fr)
);
grid-column-gap: var(--ha-space-2);
grid-row-gap: var(--ha-space-6);
grid-column-gap: 8px;
grid-row-gap: 24px;
}
:host > ha-form {
display: block;

View File

@@ -156,7 +156,7 @@ export class HaFormOptionalActions extends LitElement implements HaFormElement {
:host {
display: flex !important;
flex-direction: column;
gap: var(--ha-space-6);
gap: 24px;
}
:host ha-form {
display: block;

View File

@@ -144,6 +144,7 @@ export class HaForm extends LitElement implements HaFormElement {
.label=${this._computeLabel(item, this.data)}
.disabled=${item.disabled || this.disabled || false}
.placeholder=${item.required ? "" : item.default}
.default=${item.default}
.helper=${this._computeHelper(item)}
.localizeValue=${this.localizeValue}
.required=${item.required || false}

View File

@@ -57,7 +57,7 @@ export class HaFormfield extends FormfieldBase {
}
.mdc-form-field {
align-items: var(--ha-formfield-align-items, center);
gap: var(--ha-space-1);
gap: 4px;
}
.mdc-form-field > label {
direction: var(--direction);

View File

@@ -227,7 +227,7 @@ export class HaGridSizeEditor extends LitElement {
"row-slider preview";
grid-template-rows: auto auto;
grid-template-columns: auto 1fr;
gap: var(--ha-space-2);
gap: 8px;
}
#columns {
grid-area: column-slider;

View File

@@ -1,126 +0,0 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, queryAll } from "lit/decorators";
import "./ha-icon";
import "./ha-icon-button";
import type { HaIconButton } from "./ha-icon-button";
import "./ha-icon-button-group";
import "./ha-tooltip";
export interface HaIconButtonToolbarItem {
[key: string]: any;
path: string;
label: string;
id?: string;
disabled?: boolean;
tooltip?: string;
action?: (e: Event) => any;
}
@customElement("ha-icon-button-toolbar")
export class HaIconButtonToolbar extends LitElement {
@property({ type: Array, attribute: false })
public items: (HaIconButtonToolbarItem | string)[] = [];
@queryAll("ha-icon-button") private _buttons?: HaIconButton[];
// Returns all toolbar buttons, or undefined if there are none.
// Optionally returns only those with matching selector.
public findToolbarButtons(selector = ""): HaIconButton[] | undefined {
// Search for all toolbar buttons
const toolbarButtons = this._buttons?.filter((button) =>
button.classList.contains("icon-toolbar-button")
);
if (!toolbarButtons || !toolbarButtons.length) return undefined;
if (!selector.length) return toolbarButtons;
// Filter by user class if provided
const classButtons = toolbarButtons.filter((button) =>
button.querySelector(selector)
);
return classButtons.length ? classButtons : undefined;
}
// Returns a toolbar button based on the provided id.
// Will return undefined if not found.
public findToolbarButtonById(id): HaIconButton | undefined {
// Find the specified id
const element = this.shadowRoot?.getElementById(id);
if (!element || element.localName !== "ha-icon-button") return undefined;
return element as HaIconButton;
}
protected render(): TemplateResult {
return html`
<ha-icon-button-group class="icon-toolbar-buttongroup">
${this.items.map((item) =>
typeof item === "string"
? html`<div class="icon-toolbar-divider" role="separator"></div>`
: html`<ha-tooltip
.disabled=${!item.tooltip}
.for=${item.id ?? "icon-button-" + item.label}
>${item.tooltip ?? ""}</ha-tooltip
>
<ha-icon-button
class="icon-toolbar-button"
.id=${item.id ?? "icon-button-" + item.label}
@click=${item.action}
.label=${item.label}
.path=${item.path}
.disabled=${item.disabled ?? false}
></ha-icon-button>`
)}
</ha-icon-button-group>
`;
}
static styles = css`
:host {
position: absolute;
top: 0px;
width: 100%;
display: flex;
flex-direction: row-reverse;
background-color: var(
--icon-button-toolbar-color,
var(--secondary-background-color, whitesmoke)
);
--icon-button-toolbar-height: 32px;
--icon-button-toolbar-button: calc(
var(--icon-button-toolbar-height) - 4px
);
--icon-button-toolbar-icon: calc(
var(--icon-button-toolbar-height) - 10px
);
}
.icon-toolbar-divider {
height: var(--icon-button-toolbar-icon);
margin: 0px 4px;
border: 0.5px solid
var(--divider-color, var(--secondary-text-color, transparent));
}
.icon-toolbar-buttongroup {
background-color: transparent;
padding-right: 4px;
height: var(--icon-button-toolbar-height);
gap: var(--ha-space-2);
}
.icon-toolbar-button {
color: var(--secondary-text-color);
--mdc-icon-button-size: var(--icon-button-toolbar-button);
--mdc-icon-size: var(--icon-button-toolbar-icon);
/* Ensure button is clickable on iOS */
cursor: pointer;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-icon-button-toolbar": HaIconButtonToolbar;
}
}

View File

@@ -1,178 +0,0 @@
import {
type TemplateResult,
LitElement,
html,
css,
type PropertyValues,
} from "lit";
import { customElement, eventOptions, property, query } from "lit/decorators";
@customElement("ha-marquee-text")
export class HaMarqueeText extends LitElement {
@property({ type: Number }) speed = 15; // pixels per second
@property({ type: Number, attribute: "pause-duration" }) pauseDuration = 1000; // ms delay at ends
@property({ type: Boolean, attribute: "pause-on-hover" })
pauseOnHover = false;
private _direction: "left" | "right" = "left";
private _animationFrame?: number;
@query(".marquee-container")
private _container?: HTMLDivElement;
@query(".marquee-text")
private _textSpan?: HTMLSpanElement;
private _position = 0;
private _maxOffset = 0;
private _pauseTimeout?: number;
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this._setupAnimation();
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("text")) {
this._setupAnimation();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
if (this._animationFrame) {
cancelAnimationFrame(this._animationFrame);
}
if (this._pauseTimeout) {
clearTimeout(this._pauseTimeout);
this._pauseTimeout = undefined;
}
}
protected render(): TemplateResult {
return html`
<div
class="marquee-container"
@mouseenter=${this._handleMouseEnter}
@mouseleave=${this._handleMouseLeave}
@touchstart=${this._handleMouseEnter}
@touchend=${this._handleMouseLeave}
>
<span class="marquee-text"><slot></slot></span>
</div>
`;
}
private _setupAnimation() {
if (!this._container || !this._textSpan) {
return;
}
this._position = 0;
this._direction = "left";
this._maxOffset = Math.max(
0,
this._textSpan.offsetWidth - this._container.offsetWidth
);
this._textSpan.style.transform = `translateX(0px)`;
if (this._animationFrame) {
cancelAnimationFrame(this._animationFrame);
}
if (this._pauseTimeout) {
clearTimeout(this._pauseTimeout);
this._pauseTimeout = undefined;
}
this._animate();
}
private _animate = () => {
if (!this._container || !this._textSpan) {
return;
}
const dt = 1 / 60; // ~16ms per frame
const pxPerFrame = this.speed * dt;
let reachedEnd = false;
if (this._direction === "left") {
this._position -= pxPerFrame;
if (this._position <= -this._maxOffset) {
this._position = -this._maxOffset;
this._direction = "right";
reachedEnd = true;
}
} else {
this._position += pxPerFrame;
if (this._position >= 0) {
this._position = 0;
this._direction = "left";
reachedEnd = true;
}
}
this._textSpan.style.transform = `translateX(${this._position}px)`;
if (reachedEnd) {
this._pauseTimeout = window.setTimeout(() => {
this._pauseTimeout = undefined;
this._animationFrame = requestAnimationFrame(this._animate);
}, this.pauseDuration);
} else {
this._animationFrame = requestAnimationFrame(this._animate);
}
};
@eventOptions({ passive: true })
private _handleMouseEnter() {
if (this.pauseOnHover && this._animationFrame) {
cancelAnimationFrame(this._animationFrame);
this._animationFrame = undefined;
}
if (this.pauseOnHover && this._pauseTimeout) {
clearTimeout(this._pauseTimeout);
this._pauseTimeout = undefined;
}
}
private _handleMouseLeave() {
if (this.pauseOnHover && !this._animationFrame && !this._pauseTimeout) {
this._animate();
}
}
static styles = css`
:host {
display: block;
overflow: hidden;
width: 100%;
}
.marquee-container {
width: 100%;
white-space: nowrap;
overflow: hidden;
user-select: none;
cursor: default;
}
.marquee-text {
display: inline-block;
vertical-align: middle;
will-change: transform;
font-size: 1em;
pointer-events: none;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-marquee-text": HaMarqueeText;
}
}

View File

@@ -168,18 +168,16 @@ export class HaMdDialog extends Dialog {
@media all and (max-width: 450px), all and (max-height: 500px) {
:host(:not([type="alert"])) {
min-width: var(--mdc-dialog-min-width, 100vw);
min-width: calc(
100vw - var(--safe-area-inset-right) - var(--safe-area-inset-left)
);
max-width: calc(
100vw - var(--safe-area-inset-right) - var(--safe-area-inset-left)
);
min-height: 100%;
max-height: 100%;
--md-dialog-container-shape: 0;
}
.container {
padding-top: var(--safe-area-inset-top);
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
}
}
::slotted(ha-dialog-header[slot="headline"]) {

View File

@@ -0,0 +1,184 @@
import { SelectBase } from "@material/mwc-select/mwc-select-base";
import { styles } from "@material/mwc-select/mwc-select.css";
import { mdiClose } from "@mdi/js";
import { css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { debounce } from "../common/util/debounce";
import { nextRender } from "../common/util/render-status";
import "./ha-icon-button";
import "./ha-menu";
@customElement("ha-multi-select-field")
export class HaMultiSelectField extends SelectBase {
// @ts-ignore
@property({ type: Boolean }) public icon = false;
@property({ type: Boolean, reflect: true }) public clearable = false;
@property({ attribute: "inline-arrow", type: Boolean })
public inlineArrow = false;
@property() public options;
protected override render() {
return html`
${super.render()}
${this.clearable && !this.required && !this.disabled && this.value
? html`<ha-icon-button
label="clear"
@click=${this._clearValue}
.path=${mdiClose}
></ha-icon-button>`
: nothing}
`;
}
protected override renderMenu() {
const classes = this.getMenuClasses();
return html`<ha-menu
innerRole="listbox"
wrapFocus
class=${classMap(classes)}
activatable
.fullwidth=${this.fixedMenuPosition ? false : !this.naturalMenuWidth}
.open=${this.menuOpen}
.anchor=${this.anchorElement}
.fixed=${this.fixedMenuPosition}
@selected=${this.onSelected}
@opened=${this.onOpened}
@closed=${this.onClosed}
@items-updated=${this.onItemsUpdated}
@keydown=${this.handleTypeahead}
>
${this.renderMenuContent()}
</ha-menu>`;
}
protected override renderLeadingIcon() {
if (!this.icon) {
return nothing;
}
return html`<span class="mdc-select__icon"
><slot name="icon"></slot
></span>`;
}
connectedCallback() {
super.connectedCallback();
window.addEventListener("translations-updated", this._translationsUpdated);
}
protected async firstUpdated() {
super.firstUpdated();
if (this.inlineArrow) {
this.shadowRoot
?.querySelector(".mdc-select__selected-text-container")
?.classList.add("inline-arrow");
}
}
protected updated(changedProperties) {
super.updated(changedProperties);
if (changedProperties.has("inlineArrow")) {
const textContainerElement = this.shadowRoot?.querySelector(
".mdc-select__selected-text-container"
);
if (this.inlineArrow) {
textContainerElement?.classList.add("inline-arrow");
} else {
textContainerElement?.classList.remove("inline-arrow");
}
}
if (changedProperties.get("options")) {
this.layoutOptions();
this.selectByValue(this.value);
}
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener(
"translations-updated",
this._translationsUpdated
);
}
private _clearValue(): void {
if (this.disabled || !this.value) {
return;
}
this.valueSetDirectly = true;
this.select(-1);
this.mdcFoundation.handleChange();
}
private _translationsUpdated = debounce(async () => {
await nextRender();
this.layoutOptions();
}, 500);
static override styles = [
styles,
css`
:host([clearable]) {
position: relative;
}
.mdc-select:not(.mdc-select--disabled) .mdc-select__icon {
color: var(--secondary-text-color);
}
.mdc-select__anchor {
width: var(--ha-select-min-width, 200px);
}
.mdc-select--filled .mdc-select__anchor {
height: var(--ha-select-height, 56px);
}
.mdc-select--filled .mdc-floating-label {
inset-inline-start: 12px;
inset-inline-end: initial;
direction: var(--direction);
}
.mdc-select--filled.mdc-select--with-leading-icon .mdc-floating-label {
inset-inline-start: 48px;
inset-inline-end: initial;
direction: var(--direction);
}
.mdc-select .mdc-select__anchor {
padding-inline-start: 12px;
padding-inline-end: 0px;
direction: var(--direction);
}
.mdc-select__anchor .mdc-floating-label--float-above {
transform-origin: var(--float-start);
}
.mdc-select__selected-text-container {
padding-inline-end: var(--select-selected-text-padding-end, 0px);
}
:host([clearable]) .mdc-select__selected-text-container {
padding-inline-end: var(--select-selected-text-padding-end, 12px);
}
ha-icon-button {
position: absolute;
top: 10px;
right: 28px;
--mdc-icon-button-size: 36px;
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
inset-inline-start: initial;
inset-inline-end: 28px;
direction: var(--direction);
}
.inline-arrow {
flex-grow: 0;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-multi-select-field": HaMultiSelectField;
}
}

View File

@@ -105,7 +105,7 @@ export class HaPickerField extends LitElement {
--md-list-item-bottom-space: 0px;
--md-list-item-leading-space: 8px;
--md-list-item-trailing-space: 8px;
--ha-md-list-item-gap: var(--ha-space-2);
--ha-md-list-item-gap: 8px;
/* Remove the default focus ring */
--md-focus-ring-width: 0px;
--md-focus-ring-duration: 0s;

View File

@@ -16,14 +16,6 @@ export class HaRipple extends Ripple {
this.attachableTouchController.attach(control);
}
disconnectedCallback(): void {
super.disconnectedCallback();
// @ts-ignore
this.hovered = false;
// @ts-ignore
this.pressed = false;
}
detach() {
super.detach();
this.attachableTouchController.detach();

View File

@@ -39,24 +39,22 @@ class HaSegmentedBar extends LitElement {
<slot name="extra"></slot>
</div>
<div class="bar">
${this.segments.map(
(segment, index) => html`
${this.hideTooltip || !segment.label
? nothing
: html`
<ha-tooltip for="segment-${index}" placement="top">
${segment.label}
</ha-tooltip>
`}
<div
id="segment-${index}"
style=${styleMap({
width: `${(segment.value / totalValue) * 100}%`,
backgroundColor: segment.color,
})}
></div>
`
)}
${this.segments.map((segment) => {
const bar = html`<div
style=${styleMap({
width: `${(segment.value / totalValue) * 100}%`,
backgroundColor: segment.color,
})}
></div>`;
return this.hideTooltip && !segment.label
? bar
: html`
<ha-tooltip>
<span slot="content">${segment.label}</span>
${bar}
</ha-tooltip>
`;
})}
</div>
${this.hideLegend
? nothing
@@ -90,7 +88,7 @@ class HaSegmentedBar extends LitElement {
.heading {
display: flex;
flex-direction: row;
gap: var(--ha-space-2);
gap: 8px;
}
.heading .title {
flex: 1;
@@ -125,7 +123,7 @@ class HaSegmentedBar extends LitElement {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--ha-space-3);
gap: 12px;
margin: 12px 0;
padding: 0;
list-style: none;
@@ -133,7 +131,7 @@ class HaSegmentedBar extends LitElement {
.legend li {
display: flex;
align-items: center;
gap: var(--ha-space-1);
gap: 4px;
font-size: var(--ha-font-size-s);
}
.legend li .bullet {

View File

@@ -116,7 +116,7 @@ export class HaSelectBox extends LitElement {
.list {
display: grid;
grid-template-columns: repeat(var(--columns, 1), minmax(0, 1fr));
gap: var(--ha-space-3);
gap: 12px;
}
.option {
position: relative;
@@ -128,7 +128,7 @@ export class HaSelectBox extends LitElement {
align-items: center;
justify-content: space-between;
padding: 12px;
gap: var(--ha-space-2);
gap: 8px;
overflow: hidden;
cursor: pointer;
}
@@ -137,7 +137,7 @@ export class HaSelectBox extends LitElement {
position: relative;
display: flex;
flex-direction: row;
gap: var(--ha-space-2);
gap: 8px;
min-width: 0;
width: 100%;
}
@@ -148,7 +148,7 @@ export class HaSelectBox extends LitElement {
.option .content .text {
display: flex;
flex-direction: column;
gap: var(--ha-space-1);
gap: 4px;
min-width: 0;
flex: 1;
}

View File

@@ -89,7 +89,7 @@ export class HaButtonToggleSelector extends LitElement {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--ha-space-2);
gap: 8px;
align-items: center;
}
@media all and (max-width: 600px) {

View File

@@ -62,7 +62,7 @@ export class HaColorTempSelector extends LitElement {
"--ha-slider-background": `linear-gradient( to var(--float-end), ${gradient})`,
})}
labeled
icon="mdi:thermometer"
icon="hass:thermometer"
.caption=${this.label || ""}
.min=${min}
.max=${max}

View File

@@ -0,0 +1,53 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { EntityNameSelector } from "../../data/selector";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../types";
import "../entity/ha-entity-name-picker";
@customElement("ha-selector-entity_name")
export class HaSelectorEntityName extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: EntityNameSelector;
@property() public value?: string | string[];
@property() public default?: string | string[];
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
@property({ attribute: false }) public context?: {
entity?: string;
};
protected render() {
const value = this.value ?? this.default;
return html`
<ha-entity-name-picker
.hass=${this.hass}
.entityId=${this.selector.entity_name?.entity_id ||
this.context?.entity}
.value=${value}
.default=${this.default}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
></ha-entity-name-picker>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-entity_name": HaSelectorEntityName;
}
}

View File

@@ -321,7 +321,7 @@ export class HaMediaSelector extends LitElement {
display: flex;
align-items: center;
padding: 8px;
gap: var(--ha-space-3);
gap: 12px;
}
ha-card .thumbnail {
width: 40px;

View File

@@ -82,12 +82,12 @@ export class HaNumberSelector extends LitElement {
labeled
.min=${this.selector.number!.min}
.max=${this.selector.number!.max}
.value=${this.value}
.value=${this.value ?? ""}
.step=${sliderStep}
.disabled=${this.disabled}
.required=${this.required}
@change=${this._handleSliderChange}
.withMarkers=${this.selector.number?.slider_ticks || false}
.ticks=${this.selector.number?.slider_ticks}
>
</ha-slider>
`

View File

@@ -297,7 +297,7 @@ export class HaObjectSelector extends LitElement {
return [
css`
ha-md-list {
gap: var(--ha-space-2);
gap: 8px;
}
ha-md-list-item {
border: 1px solid var(--divider-color);

View File

@@ -29,6 +29,7 @@ const LOAD_ELEMENTS = {
device: () => import("./ha-selector-device"),
duration: () => import("./ha-selector-duration"),
entity: () => import("./ha-selector-entity"),
entity_name: () => import("./ha-selector-entity-name"),
statistic: () => import("./ha-selector-statistic"),
file: () => import("./ha-selector-file"),
floor: () => import("./ha-selector-floor"),
@@ -77,6 +78,8 @@ export class HaSelector extends LitElement {
@property() public value?: any;
@property() public default?: any;
@property() public label?: string;
@property() public helper?: string;
@@ -133,6 +136,7 @@ export class HaSelector extends LitElement {
name: this.name,
selector: this._handleLegacySelector(this.selector),
value: this.value,
default: this.default,
label: this.label,
placeholder: this.placeholder,
disabled: this.disabled,

View File

@@ -19,7 +19,7 @@ export class HaSlider extends Slider {
Slider.styles,
css`
:host {
--wa-form-control-activated-color: var(--ha-control-color);
--wa-form-control-activated-color: var(--primary-color);
--track-size: var(--ha-slider-track-size, 4px);
--marker-height: calc(var(--ha-slider-track-size, 4px) / 2);
--marker-width: calc(var(--ha-slider-track-size, 4px) / 2);

View File

@@ -18,8 +18,6 @@ export class HaTabGroupTab extends Tab {
opacity: 0.8;
color: inherit;
--wa-space-l: 16px;
}
:host([active]:not([disabled])) {

View File

@@ -522,7 +522,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
private _handleExpand(ev) {
const target = ev.currentTarget as any;
const id = target.id.replace(/^expand-/, "");
const newAreas: string[] = [];
const newDevices: string[] = [];
const newEntities: string[] = [];
@@ -530,7 +529,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
if (target.type === "floor_id") {
Object.values(this.hass.areas).forEach((area) => {
if (
area.floor_id === id &&
area.floor_id === target.id &&
!this.value!.area_id?.includes(area.area_id) &&
this._areaMeetsFilter(area)
) {
@@ -540,7 +539,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
} else if (target.type === "area_id") {
Object.values(this.hass.devices).forEach((device) => {
if (
device.area_id === id &&
device.area_id === target.id &&
!this.value!.device_id?.includes(device.id) &&
this._deviceMeetsFilter(device)
) {
@@ -549,7 +548,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
});
Object.values(this.hass.entities).forEach((entity) => {
if (
entity.area_id === id &&
entity.area_id === target.id &&
!this.value!.entity_id?.includes(entity.entity_id) &&
this._entityRegMeetsFilter(entity)
) {
@@ -559,7 +558,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
} else if (target.type === "device_id") {
Object.values(this.hass.entities).forEach((entity) => {
if (
entity.device_id === id &&
entity.device_id === target.id &&
!this.value!.entity_id?.includes(entity.entity_id) &&
this._entityRegMeetsFilter(entity)
) {
@@ -569,7 +568,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
} else if (target.type === "label_id") {
Object.values(this.hass.areas).forEach((area) => {
if (
area.labels.includes(id) &&
area.labels.includes(target.id) &&
!this.value!.area_id?.includes(area.area_id) &&
this._areaMeetsFilter(area)
) {
@@ -578,7 +577,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
});
Object.values(this.hass.devices).forEach((device) => {
if (
device.labels.includes(id) &&
device.labels.includes(target.id) &&
!this.value!.device_id?.includes(device.id) &&
this._deviceMeetsFilter(device)
) {
@@ -587,7 +586,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
});
Object.values(this.hass.entities).forEach((entity) => {
if (
entity.labels.includes(id) &&
entity.labels.includes(target.id) &&
!this.value!.entity_id?.includes(entity.entity_id) &&
this._entityRegMeetsFilter(entity, true)
) {
@@ -607,15 +606,14 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
if (newAreas.length) {
value = this._addItems(value, "area_id", newAreas);
}
value = this._removeItem(value, target.type, id);
value = this._removeItem(value, target.type, target.id);
fireEvent(this, "value-changed", { value });
}
private _handleRemove(ev) {
const target = ev.currentTarget as any;
const id = target.id.replace(/^remove-/, "");
fireEvent(this, "value-changed", {
value: this._removeItem(this.value, target.type, id),
value: this._removeItem(this.value, target.type, target.id),
});
}

View File

@@ -217,7 +217,7 @@ class DialogJoinMediaPlayers extends LitElement {
.content {
display: flex;
flex-direction: column;
row-gap: var(--ha-space-4);
row-gap: 16px;
}
ha-dialog-header ha-button {

View File

@@ -1138,7 +1138,7 @@ export class HaMediaPlayerBrowse extends LitElement {
auto-fit,
minmax(var(--media-browse-item-size, 175px), 0.1fr)
);
grid-gap: var(--ha-space-4);
grid-gap: 16px;
padding: 16px;
}

View File

@@ -54,7 +54,7 @@ class HaMediaPlayerToggle extends LitElement {
.list-item {
display: grid;
grid-template-columns: auto 1fr auto;
column-gap: var(--ha-space-4);
column-gap: 16px;
align-items: center;
width: 100%;
}

View File

@@ -1,28 +1,6 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
/**
* Home Assistant tile info component
*
* @element ha-tile-info
*
* @summary
* A tile info component, used in tile card in Home Assistant to display primary and secondary text.
*
* @slot primary - The primary text container.
* @slot secondary - The secondary text container.
*
* @cssprop --ha-tile-info-primary-font-size - The font size of the primary text. defaults to `var(--ha-font-size-m)`.
* @cssprop --ha-tile-info-primary-font-weight - The font weight of the primary text. defaults to `var(--ha-font-weight-medium)`.
* @cssprop --ha-tile-info-primary-line-height - The line height of the primary text. defaults to `var(--ha-line-height-normal)`.
* @cssprop --ha-tile-info-primary-letter-spacing - The letter spacing of the primary text. defaults to `0.1px`.
* @cssprop --ha-tile-info-primary-color - The color of the primary text. defaults to `var(--primary-text-color)`.
* @cssprop --ha-tile-info-secondary-font-size - The font size of the secondary text. defaults to `var(--ha-font-size-s)`.
* @cssprop --ha-tile-info-secondary-font-weight - The font weight of the secondary text. defaults to `var(--ha-font-weight-normal)`.
* @cssprop --ha-tile-info-secondary-line-height - The line height of the secondary text. defaults to `var(--ha-line-height-condensed)`.
* @cssprop --ha-tile-info-secondary-letter-spacing - The letter spacing of the secondary text. defaults to `0.4px`.
* @cssprop --ha-tile-info-secondary-color - The color of the secondary text. defaults to `var(--primary-text-color)`.
*/
@customElement("ha-tile-info")
export class HaTileInfo extends LitElement {
@property() public primary?: string;
@@ -43,48 +21,6 @@ export class HaTileInfo extends LitElement {
}
static styles = css`
:host {
--tile-info-primary-font-size: var(
--ha-tile-info-primary-font-size,
var(--ha-font-size-m)
);
--tile-info-primary-font-weight: var(
--ha-tile-info-primary-font-weight,
var(--ha-font-weight-medium)
);
--tile-info-primary-line-height: var(
--ha-tile-info-primary-line-height,
var(--ha-line-height-normal)
);
--tile-info-primary-letter-spacing: var(
--ha-tile-info-primary-letter-spacing,
0.1px
);
--tile-info-primary-color: var(
--ha-tile-info-primary-color,
var(--primary-text-color)
);
--tile-info-secondary-font-size: var(
--ha-tile-info-secondary-font-size,
var(--ha-font-size-s)
);
--tile-info-secondary-font-weight: var(
--ha-tile-info-secondary-font-weight,
var(--ha-font-weight-normal)
);
--tile-info-secondary-line-height: var(
--ha-tile-info-secondary-line-height,
var(--ha-line-height-condensed)
);
--tile-info-secondary-letter-spacing: var(
--ha-tile-info-secondary-letter-spacing,
0.4px
);
--tile-info-secondary-color: var(
--ha-tile-info-secondary-color,
var(--primary-text-color)
);
}
.info {
width: 100%;
display: flex;
@@ -100,18 +36,18 @@ export class HaTileInfo extends LitElement {
width: 100%;
}
.primary {
font-size: var(--tile-info-primary-font-size);
font-weight: var(--tile-info-primary-font-weight);
line-height: var(--tile-info-primary-line-height);
letter-spacing: var(--tile-info-primary-letter-spacing);
color: var(--tile-info-primary-color);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-normal);
letter-spacing: 0.1px;
color: var(--primary-text-color);
}
.secondary {
font-size: var(--tile-info-secondary-font-size);
font-weight: var(--tile-info-secondary-font-weight);
line-height: var(--tile-info-secondary-line-height);
letter-spacing: var(--tile-info-secondary-letter-spacing);
color: var(--tile-info-secondary-color);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
letter-spacing: 0.4px;
color: var(--primary-text-color);
}
`;
}

View File

@@ -29,15 +29,9 @@ export type AddonState =
| null;
export type AddonRepository = "core" | "local" | string;
interface AddonFieldTranslation {
name?: string;
description?: string;
fields?: Record<string, AddonFieldTranslation>;
}
interface AddonTranslations {
network?: Record<string, string>;
configuration?: Record<string, AddonFieldTranslation>;
configuration?: Record<string, { name?: string; description?: string }>;
}
export interface HassioAddonInfo {

View File

@@ -279,5 +279,3 @@ export const setSupervisorOption = async (
data
);
};
export const coreLatestLogsUrl = "/api/hassio/core/logs/latest";

View File

@@ -4,7 +4,6 @@ export interface LovelaceBadgeConfig {
type: string;
[key: string]: any;
visibility?: Condition[];
disabled?: boolean;
}
export const ensureBadgeConfig = (

View File

@@ -39,8 +39,6 @@ import type { HomeAssistant, TranslationDict } from "../types";
import { isUnavailableState } from "./entity";
import { isTTSMediaSource } from "./tts";
import { generateEntityFilter } from "../common/entity/entity_filter";
interface MediaPlayerEntityAttributes extends HassEntityAttributeBase {
media_content_id?: string;
media_content_type?: string;
@@ -524,33 +522,3 @@ export const mediaPlayerJoin = (
export const mediaPlayerUnjoin = (hass: HomeAssistant, entity_id: string) =>
hass.callService("media_player", "unjoin", {}, { entity_id });
/**
* Compute active media player states in a specific area.
* @param hass Home Assistant object
* @param areaId Area ID to filter media players by
* @returns Array of playing media player entities
*/
export const computeActiveAreaMediaStates = (
hass: HomeAssistant,
areaId: string
): MediaPlayerEntity[] => {
const area = hass.areas[areaId];
if (!area) {
return [];
}
// Get all media_player entities in this area
const mediaFilter = generateEntityFilter(hass, {
area: areaId,
domain: "media_player",
});
const mediaEntities = Object.keys(hass.entities).filter(mediaFilter);
return mediaEntities
.map((entityId) => hass.states[entityId] as MediaPlayerEntity | undefined)
.filter(
(stateObj): stateObj is MediaPlayerEntity => stateObj?.state === "playing"
);
};

View File

@@ -66,9 +66,9 @@ export const getPanelIcon = (panel: PanelInfo): string | null => {
if (!panel.icon) {
switch (panel.component_name) {
case "profile":
return "mdi:account";
return "hass:account";
case "lovelace":
return "mdi:view-dashboard";
return "hass:view-dashboard";
}
}

View File

@@ -5,20 +5,17 @@ import type {
} from "home-assistant-js-websocket";
import type { Describe } from "superstruct";
import {
object,
optional,
string,
union,
array,
assign,
boolean,
object,
optional,
refine,
string,
union,
} from "superstruct";
import { arrayLiteralIncludes } from "../common/array/literal-includes";
import { computeObjectId } from "../common/entity/compute_object_id";
import { navigate } from "../common/navigate";
import { hasTemplate } from "../common/string/has-template";
import { createSearchParam } from "../common/url/search-params";
import type { HomeAssistant } from "../types";
import type {
Condition,
@@ -29,6 +26,9 @@ import type {
} from "./automation";
import { migrateAutomationTrigger } from "./automation";
import type { BlueprintInput } from "./blueprint";
import { computeObjectId } from "../common/entity/compute_object_id";
import { createSearchParam } from "../common/url/search-params";
import { hasTemplate } from "../common/string/has-template";
export const MODES = ["single", "restart", "queued", "parallel"] as const;
export const MODES_MAX = ["queued", "parallel"] as const;
@@ -395,37 +395,6 @@ export const hasScriptFields = (
return fields !== undefined && Object.keys(fields).length > 0;
};
export const hasRequiredScriptFields = (
hass: HomeAssistant,
entityId: string
): boolean => {
const fields = hass.services.script[computeObjectId(entityId)]?.fields;
return (
fields !== undefined &&
Object.values(fields).some((field) => field.required)
);
};
export const requiredScriptFieldsFilled = (
hass: HomeAssistant,
entityId: string,
data?: Record<string, any>
): boolean => {
const fields = hass.services.script[computeObjectId(entityId)]?.fields;
if (fields === undefined || Object.keys(fields).length === 0) {
return true;
}
if (data === undefined) {
return false;
}
return Object.entries(fields).every(([key, field]) => {
if (field.required) {
return data[key] !== undefined;
}
return true;
});
};
export const migrateAutomationAction = (
action: Action | Action[]
): Action | Action[] => {

View File

@@ -41,6 +41,7 @@ export type Selector =
| LegacyDeviceSelector
| DurationSelector
| EntitySelector
| EntityNameSelector
| LegacyEntitySelector
| FileSelector
| IconSelector
@@ -499,6 +500,12 @@ export interface UiStateContentSelector {
} | null;
}
export interface EntityNameSelector {
entity_name: {
entity_id?: string;
} | null;
}
export const expandLabelTarget = (
hass: HomeAssistant,
labelId: string,

View File

@@ -4,7 +4,7 @@ import type { Action } from "./script";
export const callExecuteScript = (
hass: HomeAssistant,
sequence: Action | Action[]
): Promise<{ context: Context; response: Record<string, any> | null }> =>
): Promise<{ context: Context; response: Record<string, any> }> =>
hass.callWS({
type: "execute_script",
sequence,

View File

@@ -325,7 +325,7 @@ class StepFlowCreateEntry extends LitElement {
.device-info {
display: flex;
align-items: center;
gap: var(--ha-space-2);
gap: 8px;
}
.device-info img {
width: 40px;

View File

@@ -26,21 +26,20 @@ class StepFlowLoading extends LitElement {
this.step
);
return html`
<div class="content">
<ha-spinner size="large"></ha-spinner>
<div class="init-spinner">
${description ? html`<div>${description}</div>` : ""}
<ha-spinner></ha-spinner>
</div>
`;
}
static styles = css`
.content {
margin-top: 0;
.init-spinner {
padding: 50px 100px;
text-align: center;
}
ha-spinner {
margin-bottom: 16px;
margin-top: 16px;
}
`;
}

View File

@@ -34,7 +34,7 @@ class StepFlowProgress extends LitElement {
)}%</ha-progress-ring
>
`
: html`<ha-spinner size="large"></ha-spinner>`}
: html` <ha-spinner size="large"></ha-spinner> `}
${this.flowConfig.renderShowFormProgressDescription(
this.hass,
this.step
@@ -48,7 +48,6 @@ class StepFlowProgress extends LitElement {
configFlowContentStyles,
css`
.content {
margin-top: 0;
padding: 50px 100px;
text-align: center;
}

View File

@@ -209,7 +209,7 @@ export class DialogEnterCode
display: grid;
grid-template-columns: repeat(var(--keypad-columns), auto);
grid-auto-rows: auto;
grid-gap: var(--ha-space-6);
grid-gap: 24px;
justify-items: center;
align-items: center;
}

View File

@@ -32,7 +32,7 @@ export class HaMoreInfoControlSelectContainer extends LitElement {
display: flex;
flex-direction: row;
justify-content: flex-start;
gap: var(--ha-space-3);
gap: 12px;
margin: auto;
overflow: auto;
-ms-overflow-style: none; /* IE and Edge */

View File

@@ -110,7 +110,7 @@ class LightRgbColorPicker extends LitElement {
? html`<ha-labeled-slider
labeled
.caption=${this.hass.localize("ui.card.light.color_brightness")}
icon="mdi:brightness-7"
icon="hass:brightness-7"
min="0"
max="100"
.value=${this._colorBrightnessSliderValue}
@@ -122,7 +122,7 @@ class LightRgbColorPicker extends LitElement {
<ha-labeled-slider
labeled
.caption=${this.hass.localize("ui.card.light.white_value")}
icon="mdi:file-word-box"
icon="hass:file-word-box"
min="0"
max="100"
.name=${"wv"}
@@ -136,7 +136,7 @@ class LightRgbColorPicker extends LitElement {
<ha-labeled-slider
labeled
.caption=${this.hass.localize("ui.card.light.cold_white_value")}
icon="mdi:file-word-box-outline"
icon="hass:file-word-box-outline"
min="0"
max="100"
.name=${"cw"}
@@ -146,7 +146,7 @@ class LightRgbColorPicker extends LitElement {
<ha-labeled-slider
labeled
.caption=${this.hass.localize("ui.card.light.warm_white_value")}
icon="mdi:file-word-box"
icon="hass:file-word-box"
min="0"
max="100"
.name=${"ww"}

View File

@@ -202,13 +202,13 @@ class MoreInfoSirenAdvancedControls extends LitElement {
.options {
display: flex;
flex-direction: column;
gap: var(--ha-space-4);
gap: 16px;
}
.controls {
display: flex;
flex-direction: row;
justify-content: center;
gap: var(--ha-space-4);
gap: 16px;
margin-top: 16px;
}
ha-control-button {

View File

@@ -34,7 +34,6 @@ export const DOMAINS_WITH_NEW_MORE_INFO = [
"valve",
"water_heater",
"weather",
"media_player",
];
/** Domains with full height more info dialog */
export const DOMAINS_FULL_HEIGHT_MORE_INFO = ["update"];

View File

@@ -107,7 +107,7 @@ class MoreInfoCamera extends LitElement {
box-sizing: border-box;
padding: 16px;
z-index: 1;
gap: var(--ha-space-2);
gap: 8px;
}
`;
}

View File

@@ -181,7 +181,7 @@ class MoreInfoLock extends LitElement {
display: flex;
align-items: center;
flex-direction: row;
gap: var(--ha-space-2);
gap: 8px;
font-weight: var(--ha-font-weight-medium);
color: var(--success-color);
}

View File

@@ -1,7 +1,6 @@
import {
mdiLoginVariant,
mdiMusicNote,
mdiMusicNoteEighth,
mdiPlayBoxMultiple,
mdiSpeakerMultiple,
mdiVolumeHigh,
@@ -9,13 +8,11 @@ import {
mdiVolumeOff,
mdiVolumePlus,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { classMap } from "lit/directives/class-map";
import { stopPropagation } from "../../../common/dom/stop_propagation";
import { stateActive } from "../../../common/entity/state_active";
import { supportsFeature } from "../../../common/entity/supports-feature";
import { formatDurationDigital } from "../../../common/datetime/format_duration";
import "../../../components/ha-icon-button";
import "../../../components/ha-list-item";
import "../../../components/ha-select";
@@ -30,17 +27,12 @@ import type {
MediaPlayerEntity,
} from "../../../data/media-player";
import {
MediaPlayerEntityFeature,
computeMediaControls,
handleMediaControlClick,
MediaPlayerEntityFeature,
mediaPlayerPlayMedia,
} from "../../../data/media-player";
import type { HomeAssistant } from "../../../types";
import HassMediaPlayerEntity from "../../../util/hass-media-player-model";
import "../../../components/ha-md-button-menu";
import "../../../components/chips/ha-assist-chip";
import "../../../components/ha-md-menu-item";
import "../../../components/ha-marquee-text";
@customElement("more-info-media_player")
class MoreInfoMediaPlayer extends LitElement {
@@ -48,503 +40,259 @@ class MoreInfoMediaPlayer extends LitElement {
@property({ attribute: false }) public stateObj?: MediaPlayerEntity;
private _formatDuration(duration: number) {
const hours = Math.floor(duration / 3600);
const minutes = Math.floor((duration % 3600) / 60);
const seconds = Math.floor(duration % 60);
return formatDurationDigital(this.hass.locale, {
hours,
minutes,
seconds,
})!;
}
protected _renderVolumeControl() {
if (!this.stateObj) {
return nothing;
}
const supportsMute = supportsFeature(
this.stateObj,
MediaPlayerEntityFeature.VOLUME_MUTE
);
const supportsSliding = supportsFeature(
this.stateObj,
MediaPlayerEntityFeature.VOLUME_SET
);
return html`${(supportsFeature(
this.stateObj!,
MediaPlayerEntityFeature.VOLUME_SET
) ||
supportsFeature(this.stateObj!, MediaPlayerEntityFeature.VOLUME_STEP)) &&
stateActive(this.stateObj!)
? html`
<div class="volume">
${supportsMute
? html`
<ha-icon-button
.path=${this.stateObj.attributes.is_volume_muted
? mdiVolumeOff
: mdiVolumeHigh}
.label=${this.hass.localize(
`ui.card.media_player.${
this.stateObj.attributes.is_volume_muted
? "media_volume_unmute"
: "media_volume_mute"
}`
)}
@click=${this._toggleMute}
></ha-icon-button>
`
: ""}
${supportsFeature(
this.stateObj,
MediaPlayerEntityFeature.VOLUME_STEP
) && !supportsSliding
? html`
<ha-icon-button
action="volume_down"
.path=${mdiVolumeMinus}
.label=${this.hass.localize(
"ui.card.media_player.media_volume_down"
)}
@click=${this._handleClick}
></ha-icon-button>
<ha-icon-button
action="volume_up"
.path=${mdiVolumePlus}
.label=${this.hass.localize(
"ui.card.media_player.media_volume_up"
)}
@click=${this._handleClick}
></ha-icon-button>
`
: nothing}
${supportsSliding
? html`
${!supportsMute
? html`<ha-svg-icon .path=${mdiVolumeHigh}></ha-svg-icon>`
: nothing}
<ha-slider
labeled
id="input"
.value=${Number(this.stateObj.attributes.volume_level) *
100}
@change=${this._selectedValueChanged}
></ha-slider>
`
: nothing}
</div>
`
: nothing}`;
}
protected _renderSourceControl() {
if (
!this.stateObj ||
!supportsFeature(this.stateObj, MediaPlayerEntityFeature.SELECT_SOURCE) ||
!this.stateObj.attributes.source_list?.length
) {
return nothing;
}
return html`<ha-md-button-menu positioning="popover">
<ha-button
slot="trigger"
appearance="plain"
variant="neutral"
size="small"
title=${this.hass.localize(`ui.card.media_player.source`)}
>
<ha-svg-icon .path=${mdiLoginVariant}></ha-svg-icon>
</ha-button>
${this.stateObj.attributes.source_list!.map(
(source) =>
html`<ha-md-menu-item
data-source=${source}
@click=${this._handleSourceClick}
@keydown=${this._handleSourceClick}
.selected=${source === this.stateObj?.attributes.source}
>
${source}
</ha-md-menu-item>`
)}
</ha-md-button-menu>`;
}
protected _renderSoundMode() {
if (
!this.stateObj ||
!supportsFeature(
this.stateObj,
MediaPlayerEntityFeature.SELECT_SOUND_MODE
) ||
!this.stateObj.attributes.sound_mode_list?.length
) {
return nothing;
}
return html`<ha-md-button-menu positioning="popover">
<ha-button
slot="trigger"
appearance="plain"
variant="neutral"
size="small"
title=${this.hass.localize(`ui.card.media_player.sound_mode`)}
>
<ha-svg-icon .path=${mdiMusicNoteEighth}></ha-svg-icon>
</ha-button>
${this.stateObj.attributes.sound_mode_list!.map(
(soundMode) =>
html`<ha-md-menu-item
data-sound-mode=${soundMode}
@click=${this._handleSoundModeClick}
@keydown=${this._handleSoundModeClick}
.selected=${soundMode === this.stateObj?.attributes.sound_mode}
>
${soundMode}
</ha-md-menu-item>`
)}
</ha-md-button-menu>`;
}
protected _renderGrouping() {
if (
!this.stateObj ||
isUnavailableState(this.stateObj.state) ||
!supportsFeature(this.stateObj, MediaPlayerEntityFeature.GROUPING)
) {
return nothing;
}
const groupMembers = this.stateObj.attributes.group_members;
const hasMultipleMembers = groupMembers && groupMembers?.length > 1;
return html`<ha-button
class="grouping"
@click=${this._showGroupMediaPlayers}
appearance="plain"
variant="neutral"
size="small"
title=${this.hass.localize("ui.card.media_player.join")}
>
<div>
<ha-svg-icon .path=${mdiSpeakerMultiple}></ha-svg-icon>
${hasMultipleMembers
? html`<span class="badge"> ${groupMembers?.length || 4} </span>`
: nothing}
</div>
</ha-button>`;
}
protected _renderEmptyCover(title: string, icon?: string) {
return html`
<div class="cover-container">
<div class="cover-image empty-cover" role="img" aria-label=${title}>
${icon ? html`<ha-svg-icon .path=${icon}></ha-svg-icon>` : title}
</div>
</div>
`;
}
protected render() {
if (!this.stateObj) {
return nothing;
}
if (isUnavailableState(this.stateObj.state)) {
return this._renderEmptyCover(this.hass.formatEntityState(this.stateObj));
}
const stateObj = this.stateObj;
const controls = computeMediaControls(stateObj, true);
const coverUrl = stateObj.attributes.entity_picture || "";
const playerObj = new HassMediaPlayerEntity(this.hass, this.stateObj);
const position = Math.max(Math.floor(playerObj.currentProgress || 0), 0);
const duration = Math.max(stateObj.attributes.media_duration || 0, 0);
const remaining = Math.max(duration - position, 0);
const remainingFormatted = this._formatDuration(remaining);
const positionFormatted = this._formatDuration(position);
const primaryTitle = playerObj.primaryTitle;
const secondaryTitle = playerObj.secondaryTitle;
const turnOn = controls?.find((c) => c.action === "turn_on");
const turnOff = controls?.find((c) => c.action === "turn_off");
const groupMembers = stateObj.attributes.group_members?.length;
return html`
${coverUrl
? html`<div class="cover-container">
<img
class=${classMap({
"cover-image": true,
"cover-image--playing": stateObj.state === "playing",
})}
src=${coverUrl}
alt=${ifDefined(primaryTitle)}
/>
</div>`
: this._renderEmptyCover(
this.hass.formatEntityState(this.stateObj),
mdiMusicNote
)}
${primaryTitle || secondaryTitle
? html`<div class="media-info-row">
${primaryTitle
? html`<ha-marquee-text
class="media-title"
speed="30"
pause-on-hover
>
${primaryTitle}
</ha-marquee-text>`
: nothing}
${secondaryTitle
? html`<ha-marquee-text
class="media-artist"
speed="30"
pause-on-hover
>
${secondaryTitle}
</ha-marquee-text>`
: nothing}
</div>`
: nothing}
${duration && duration > 0
? html`
<div class="position-bar">
<ha-slider
min="0"
max=${duration}
step="1"
.value=${position}
aria-label=${this.hass.localize(
"ui.card.media_player.track_position"
)}
@change=${this._handleMediaSeekChanged}
?disabled=${!stateActive(stateObj) ||
!supportsFeature(stateObj, MediaPlayerEntityFeature.SEEK)}
<div class="controls">
<div class="basic-controls">
${!controls
? ""
: controls.map(
(control) => html`
<ha-icon-button
action=${control.action}
@click=${this._handleClick}
.path=${control.icon}
.label=${this.hass.localize(
`ui.card.media_player.${control.action}`
)}
>
</ha-icon-button>
`
)}
</div>
${!isUnavailableState(stateObj.state) &&
supportsFeature(stateObj, MediaPlayerEntityFeature.BROWSE_MEDIA)
? html`
<ha-button
@click=${this._showBrowseMedia}
appearance="plain"
size="small"
>
<span slot="reference">${positionFormatted}</span>
<span slot="reference">${remainingFormatted}</span>
</ha-slider>
<ha-svg-icon
.path=${mdiPlayBoxMultiple}
slot="start"
></ha-svg-icon>
${this.hass.localize("ui.card.media_player.browse_media")}
</ha-button>
`
: ""}
${!isUnavailableState(stateObj.state) &&
supportsFeature(stateObj, MediaPlayerEntityFeature.GROUPING)
? html`
<ha-button
@click=${this._showGroupMediaPlayers}
appearance="plain"
size="small"
>
<ha-svg-icon
.path=${mdiSpeakerMultiple}
slot="start"
></ha-svg-icon>
${groupMembers && groupMembers > 1
? html`<span class="badge">
${stateObj.attributes.group_members?.length || 4}
</span>`
: nothing}
${this.hass.localize("ui.card.media_player.join")}
</ha-button>
`
: ""}
</div>
${(supportsFeature(stateObj, MediaPlayerEntityFeature.VOLUME_SET) ||
supportsFeature(stateObj, MediaPlayerEntityFeature.VOLUME_STEP)) &&
stateActive(stateObj)
? html`
<div class="volume">
${supportsFeature(stateObj, MediaPlayerEntityFeature.VOLUME_MUTE)
? html`
<ha-icon-button
.path=${stateObj.attributes.is_volume_muted
? mdiVolumeOff
: mdiVolumeHigh}
.label=${this.hass.localize(
`ui.card.media_player.${
stateObj.attributes.is_volume_muted
? "media_volume_unmute"
: "media_volume_mute"
}`
)}
@click=${this._toggleMute}
></ha-icon-button>
`
: ""}
${supportsFeature(
stateObj,
MediaPlayerEntityFeature.VOLUME_SET
) ||
supportsFeature(stateObj, MediaPlayerEntityFeature.VOLUME_STEP)
? html`
<ha-icon-button
action="volume_down"
.path=${mdiVolumeMinus}
.label=${this.hass.localize(
"ui.card.media_player.media_volume_down"
)}
@click=${this._handleClick}
></ha-icon-button>
<ha-icon-button
action="volume_up"
.path=${mdiVolumePlus}
.label=${this.hass.localize(
"ui.card.media_player.media_volume_up"
)}
@click=${this._handleClick}
></ha-icon-button>
`
: ""}
${supportsFeature(stateObj, MediaPlayerEntityFeature.VOLUME_SET)
? html`
<ha-slider
labeled
id="input"
.value=${Number(stateObj.attributes.volume_level) * 100}
@change=${this._selectedValueChanged}
></ha-slider>
`
: ""}
</div>
`
: ""}
${stateActive(stateObj) &&
supportsFeature(stateObj, MediaPlayerEntityFeature.SELECT_SOURCE) &&
stateObj.attributes.source_list?.length
? html`
<div class="source-input">
<ha-select
.label=${this.hass.localize("ui.card.media_player.source")}
icon
.value=${stateObj.attributes.source!}
@selected=${this._handleSourceChanged}
fixedMenuPosition
naturalMenuWidth
@closed=${stopPropagation}
>
${stateObj.attributes.source_list!.map(
(source) => html`
<ha-list-item .value=${source}>
${this.hass.formatEntityAttributeValue(
stateObj,
"source",
source
)}
</ha-list-item>
`
)}
<ha-svg-icon .path=${mdiLoginVariant} slot="icon"></ha-svg-icon>
</ha-select>
</div>
`
: nothing}
<div class="bottom-controls">
${controls && controls.length > 0
? html`<div class="main-controls">
${["repeat_set", "media_previous_track"].map((action) => {
const control = controls?.find((c) => c.action === action);
return control
? html`<ha-icon-button
action=${action}
@click=${this._handleClick}
.path=${control.icon}
.label=${this.hass.localize(
`ui.card.media_player.${control.action}`
)}
>
</ha-icon-button>`
: html`<span class="spacer"></span>`;
})}
${["media_play_pause", "media_pause", "media_play"].map(
(action) => {
const control = controls?.find((c) => c.action === action);
return control
? html`<ha-button
variant="brand"
appearance="filled"
size="medium"
action=${action}
@click=${this._handleClick}
class="center-control"
>
<ha-svg-icon
.path=${control.icon}
aria-label=${this.hass.localize(
`ui.card.media_player.${control.action}`
)}
></ha-svg-icon>
</ha-button>`
: nothing;
}
)}
${["media_next_track", "shuffle_set"].map((action) => {
const control = controls?.find((c) => c.action === action);
return control
? html`<ha-icon-button
action=${action}
@click=${this._handleClick}
.path=${control.icon}
.label=${this.hass.localize(
`ui.card.media_player.${control.action}`
)}
>
</ha-icon-button>`
: html`<span class="spacer"></span>`;
})}
</div>`
: nothing}
${this._renderVolumeControl()}
<div class="controls-row">
${!isUnavailableState(stateObj.state) &&
supportsFeature(stateObj, MediaPlayerEntityFeature.BROWSE_MEDIA)
? html`
<ha-button
@click=${this._showBrowseMedia}
appearance="plain"
variant="neutral"
size="small"
title=${this.hass.localize(
"ui.card.media_player.browse_media"
)}
>
<ha-svg-icon .path=${mdiPlayBoxMultiple}></ha-svg-icon>
</ha-button>
`
: nothing}
${this._renderGrouping()} ${this._renderSourceControl()}
${this._renderSoundMode()}
${turnOn
? html`<ha-button
action=${turnOn.action}
@click=${this._handleClick}
appearance="plain"
variant="neutral"
size="small"
title=${this.hass.localize(
`ui.card.media_player.${turnOn.action}`
)}
${stateActive(stateObj) &&
supportsFeature(stateObj, MediaPlayerEntityFeature.SELECT_SOUND_MODE) &&
stateObj.attributes.sound_mode_list?.length
? html`
<div class="sound-input">
<ha-select
.label=${this.hass.localize("ui.card.media_player.sound_mode")}
.value=${stateObj.attributes.sound_mode!}
icon
fixedMenuPosition
naturalMenuWidth
@selected=${this._handleSoundModeChanged}
@closed=${stopPropagation}
>
<ha-svg-icon .path=${turnOn.icon}></ha-svg-icon>
</ha-button>`
: nothing}
${turnOff
? html`<ha-button
action=${turnOff.action}
@click=${this._handleClick}
appearance="plain"
variant="neutral"
size="small"
title=${this.hass.localize(
`ui.card.media_player.${turnOff.action}`
${stateObj.attributes.sound_mode_list.map(
(mode) => html`
<ha-list-item .value=${mode}>
${this.hass.formatEntityAttributeValue(
stateObj,
"sound_mode",
mode
)}
</ha-list-item>
`
)}
>
<ha-svg-icon .path=${turnOff.icon}></ha-svg-icon>
</ha-button>`
: nothing}
</div>
</div>
<ha-svg-icon .path=${mdiMusicNote} slot="icon"></ha-svg-icon>
</ha-select>
</div>
`
: ""}
`;
}
static styles = css`
:host {
display: flex;
flex-direction: column;
gap: var(--ha-space-6);
margin-top: 0;
ha-slider {
flex-grow: 1;
}
.cover-container {
ha-icon-button[action="turn_off"],
ha-icon-button[action="turn_on"] {
margin-right: auto;
margin-left: inherit;
margin-inline-start: inherit;
margin-inline-end: auto;
}
.controls {
display: flex;
justify-content: center;
flex-wrap: wrap;
align-items: center;
height: 320px;
width: 100%;
--mdc-theme-primary: currentColor;
direction: ltr;
}
.cover-image {
width: 240px;
height: 240px;
max-width: 100%;
max-height: 100%;
object-fit: cover;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
position: relative;
display: flex;
align-items: center;
justify-content: center;
transition:
width 0.3s,
height 0.3s;
.basic-controls {
display: inline-flex;
flex-grow: 1;
}
.cover-image--playing {
width: 320px;
height: 320px;
.volume {
direction: ltr;
}
@media (max-height: 750px) {
.cover-container {
height: 120px;
}
.cover-image {
width: 100px;
height: 100px;
}
.cover-image--playing {
width: 120px;
height: 120px;
}
.source-input,
.sound-input {
direction: var(--direction);
}
.empty-cover {
background-color: var(--secondary-background-color);
font-size: 1.5em;
color: var(--secondary-text-color);
}
.main-controls {
.volume,
.source-input,
.sound-input {
display: flex;
align-items: center;
justify-content: space-between;
}
.center-control {
--ha-button-height: 56px;
.source-input ha-select,
.sound-input ha-select {
margin-left: 10px;
flex-grow: 1;
margin-inline-start: 10px;
margin-inline-end: initial;
direction: var(--direction);
}
.spacer {
width: 48px;
.tts {
margin-top: 16px;
font-style: italic;
}
.volume,
.position-bar,
.main-controls {
direction: ltr;
}
.volume ha-slider,
.position-bar ha-slider {
width: 100%;
}
.volume {
display: flex;
align-items: center;
gap: var(--ha-space-3);
margin-left: 8px;
}
.volume ha-svg-icon {
padding: 4px;
height: 16px;
width: 16px;
}
.volume ha-icon-button {
--mdc-icon-button-size: 32px;
--mdc-icon-size: 16px;
ha-button > ha-svg-icon {
vertical-align: text-bottom;
}
.badge {
position: absolute;
top: -10px;
left: 16px;
top: -6px;
left: 24px;
display: flex;
justify-content: center;
align-items: center;
@@ -553,63 +301,9 @@ class MoreInfoMediaPlayer extends LitElement {
border-radius: 10px;
font-weight: var(--ha-font-weight-normal);
font-size: var(--ha-font-size-xs);
background-color: var(--primary-color);
background-color: var(--accent-color);
padding: 0 4px;
color: var(--primary-text-color);
}
.position-bar {
display: flex;
flex-direction: column;
}
.position-bar ha-slider::part(references) {
color: var(--secondary-text-color);
}
.media-info-row {
display: flex;
flex-direction: column;
justify-content: space-between;
margin: 8px 0 8px 8px;
}
.media-title {
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-bold);
margin-bottom: 4px;
}
.media-artist {
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-normal);
color: var(--secondary-text-color);
}
.controls-row {
display: flex;
align-items: center;
justify-content: space-around;
}
.controls-row ha-button {
width: 32px;
}
.controls-row ha-svg-icon {
color: var(--ha-color-on-neutral-quiet);
}
.grouping::part(label) {
position: relative;
}
.bottom-controls {
display: flex;
flex-direction: column;
gap: var(--ha-space-6);
align-self: center;
width: 320px;
color: var(--text-accent-color, var(--text-primary-color));
}
`;
@@ -635,29 +329,29 @@ class MoreInfoMediaPlayer extends LitElement {
});
}
private _handleSourceClick(e: Event) {
const source = (e.currentTarget as HTMLElement).getAttribute("data-source");
if (!source || this.stateObj!.attributes.source === source) {
private _handleSourceChanged(e) {
const newVal = e.target.value;
if (!newVal || this.stateObj!.attributes.source === newVal) {
return;
}
this.hass.callService("media_player", "select_source", {
entity_id: this.stateObj!.entity_id,
source,
source: newVal,
});
}
private _handleSoundModeClick(e: Event) {
const soundMode = (e.currentTarget as HTMLElement).getAttribute(
"data-sound-mode"
);
if (!soundMode || this.stateObj!.attributes.sound_mode === soundMode) {
private _handleSoundModeChanged(e) {
const newVal = e.target.value;
if (!newVal || this.stateObj?.attributes.sound_mode === newVal) {
return;
}
this.hass.callService("media_player", "select_sound_mode", {
entity_id: this.stateObj!.entity_id,
sound_mode: soundMode,
sound_mode: newVal,
});
}
@@ -680,18 +374,6 @@ class MoreInfoMediaPlayer extends LitElement {
entityId: this.stateObj!.entity_id,
});
}
private async _handleMediaSeekChanged(e: Event): Promise<void> {
if (!this.stateObj) {
return;
}
const newValue = (e.target as any).value;
this.hass.callService("media_player", "media_seek", {
entity_id: this.stateObj.entity_id,
seek_position: newValue,
});
}
}
declare global {

View File

@@ -3,24 +3,20 @@ import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { listenMediaQuery } from "../../../common/dom/media_query";
import { computeObjectId } from "../../../common/entity/compute_object_id";
import "../../../components/entity/state-info";
import "../../../components/ha-control-button";
import "../../../components/ha-control-button-group";
import "../../../components/ha-markdown";
import "../../../components/ha-relative-time";
import "../../../components/ha-service-control";
import { isUnavailableState } from "../../../data/entity";
import type { ExtEntityRegistryEntry } from "../../../data/entity_registry";
import type { ScriptEntity } from "../../../data/script";
import {
canRun,
hasRequiredScriptFields,
requiredScriptFieldsFilled,
} from "../../../data/script";
import "../../../components/ha-control-button";
import "../../../components/ha-control-button-group";
import "../../../components/entity/state-info";
import type { HomeAssistant } from "../../../types";
import type { ScriptEntity } from "../../../data/script";
import { canRun } from "../../../data/script";
import { isUnavailableState } from "../../../data/entity";
import { computeObjectId } from "../../../common/entity/compute_object_id";
import { listenMediaQuery } from "../../../common/dom/media_query";
import "../components/ha-more-info-state-header";
import type { ExtEntityRegistryEntry } from "../../../data/entity_registry";
import "../../../components/ha-markdown";
@customElement("more-info-script")
class MoreInfoScript extends LitElement {
@@ -30,8 +26,6 @@ class MoreInfoScript extends LitElement {
@property({ attribute: false }) public entry?: ExtEntityRegistryEntry;
@property({ attribute: false }) public data?: Record<string, any>;
@state() private _scriptData: Record<string, any> = {};
@state() private narrow = false;
@@ -116,10 +110,7 @@ class MoreInfoScript extends LitElement {
hide-picker
hide-description
.hass=${this.hass}
.value=${{
...(this.data ? { data: this.data } : {}),
...this._scriptData,
}}
.value=${this._scriptData}
.showAdvanced=${this.hass.userData?.showAdvanced}
.narrow=${this.narrow}
@value-changed=${this._scriptDataChanged}
@@ -207,13 +198,7 @@ class MoreInfoScript extends LitElement {
private _canRun() {
if (
!hasRequiredScriptFields(this.hass, this.stateObj!.entity_id) ||
(requiredScriptFieldsFilled(
this.hass,
this.stateObj!.entity_id,
this._scriptData.data
) &&
canRun(this.stateObj!)) ||
canRun(this.stateObj!) ||
// Restart can also always runs. Just cancels other run.
this.stateObj!.attributes.mode === "restart"
) {

View File

@@ -508,7 +508,7 @@ class MoreInfoUpdate extends LitElement {
box-sizing: border-box;
padding: 16px;
z-index: 1;
gap: var(--ha-space-2);
gap: 8px;
}
a {

View File

@@ -64,7 +64,6 @@ export interface MoreInfoDialogParams {
view?: View;
/** @deprecated Use `view` instead */
tab?: View;
data?: Record<string, any>;
}
type View = "info" | "history" | "settings" | "related";
@@ -97,8 +96,6 @@ export class MoreInfoDialog extends LitElement {
@state() private _entityId?: string | null;
@state() private _data?: Record<string, any>;
@state() private _currView: View = DEFAULT_VIEW;
@state() private _initialView: View = DEFAULT_VIEW;
@@ -119,8 +116,6 @@ export class MoreInfoDialog extends LitElement {
this.closeDialog();
return;
}
this._data = params.data;
this._currView = params.view || DEFAULT_VIEW;
this._initialView = params.view || DEFAULT_VIEW;
this._childView = undefined;
@@ -575,7 +570,6 @@ export class MoreInfoDialog extends LitElement {
.entityId=${this._entityId}
.entry=${this._entry}
.editMode=${this._infoEditMode}
.data=${this._data}
></ha-more-info-info>
`
: this._currView === "history"

View File

@@ -59,18 +59,9 @@ export class MoreInfoHistory extends LitElement {
return html`${isComponentLoaded(this.hass, "history")
? html`<div class="header">
<div>
<h2>
${this.hass.localize("ui.dialogs.more_info_control.history")}
</h2>
${this._statistics
? html`<div class="header-secondary">
${this.hass.localize(
"ui.dialogs.more_info_control.aggregate"
)}
</div>`
: nothing}
</div>
<h2>
${this.hass.localize("ui.dialogs.more_info_control.history")}
</h2>
${__DEMO__
? nothing
: html`<a href=${this._showMoreHref}
@@ -255,10 +246,6 @@ export class MoreInfoHistory extends LitElement {
a:visited {
color: var(--primary-color);
}
.header-secondary {
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
}
h2 {
margin: 0;
}

View File

@@ -27,8 +27,6 @@ export class MoreInfoInfo extends LitElement {
@property({ attribute: false }) public editMode?: boolean;
@property({ attribute: false }) public data?: Record<string, any>;
@state() private _sensorNumericDeviceClasses?: string[] = [];
private async _loadNumericDeviceClasses() {
@@ -107,7 +105,6 @@ export class MoreInfoInfo extends LitElement {
.hass=${this.hass}
.entry=${this.entry}
.editMode=${this.editMode}
.data=${this.data}
></more-info-content>
</div>
</div>

View File

@@ -6,14 +6,14 @@ import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import "../../components/ha-badge";
import type { ExtEntityRegistryEntry } from "../../data/entity_registry";
import { supportsCoverPositionCardFeature } from "../../panels/lovelace/card-features/hui-cover-position-card-feature";
import { supportsLightBrightnessCardFeature } from "../../panels/lovelace/card-features/hui-light-brightness-card-feature";
import type { LovelaceCardFeatureConfig } from "../../panels/lovelace/card-features/types";
import type { TileCardConfig } from "../../panels/lovelace/cards/types";
import { importMoreInfoControl } from "../../panels/lovelace/custom-card-helpers";
import "../../panels/lovelace/sections/hui-section";
import type { HomeAssistant } from "../../types";
import { stateMoreInfoType } from "./state_more_info_control";
import type { LovelaceCardFeatureConfig } from "../../panels/lovelace/card-features/types";
import { supportsLightBrightnessCardFeature } from "../../panels/lovelace/card-features/hui-light-brightness-card-feature";
import { supportsCoverPositionCardFeature } from "../../panels/lovelace/card-features/hui-cover-position-card-feature";
@customElement("more-info-content")
class MoreInfoContent extends LitElement {
@@ -25,8 +25,6 @@ class MoreInfoContent extends LitElement {
@property({ attribute: false }) public editMode?: boolean;
@property({ attribute: false }) public data?: Record<string, any>;
protected render() {
let moreInfoType: string | undefined;
@@ -50,7 +48,6 @@ class MoreInfoContent extends LitElement {
stateObj: this.stateObj,
entry: this.entry,
editMode: this.editMode,
data: this.data,
})}
${this._showEntityMembers(this.stateObj)
? html`

View File

@@ -104,16 +104,6 @@ const _SHORTCUTS: Section[] = [
descriptionTranslationKey:
"ui.dialogs.shortcuts.automation_script.save",
},
{
shortcut: [CTRL_CMD, "Z"],
descriptionTranslationKey:
"ui.dialogs.shortcuts.automation_script.undo",
},
{
shortcut: [CTRL_CMD, "Y"],
descriptionTranslationKey:
"ui.dialogs.shortcuts.automation_script.redo",
},
],
},
{
@@ -269,7 +259,7 @@ class DialogShortcuts extends LitElement {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--ha-space-2);
gap: 8px;
margin: 4px 0;
}

View File

@@ -8,7 +8,6 @@ import { createCloseHeading } from "../../components/ha-dialog";
import "../../components/ha-textarea";
import type { HaTextArea } from "../../components/ha-textarea";
import { convertTextToSpeech } from "../../data/tts";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { showAlertDialog } from "../generic/show-dialog-box";
import type { TTSTryDialogParams } from "./show-dialog-tts-try";
@@ -150,24 +149,21 @@ export class TTSTryDialog extends LitElement {
});
}
static styles = [
haStyleDialog,
css`
ha-dialog {
--mdc-dialog-max-width: 500px;
}
ha-textarea,
ha-select {
width: 100%;
}
ha-select {
margin-top: 8px;
}
.loading {
height: 36px;
}
`,
];
static styles = css`
ha-dialog {
--mdc-dialog-max-width: 500px;
}
ha-textarea,
ha-select {
width: 100%;
}
ha-select {
margin-top: 8px;
}
.loading {
height: 36px;
}
`;
}
declare global {

View File

@@ -127,7 +127,7 @@ export class CloudStepIntro extends LitElement {
.features {
display: flex;
flex-direction: column;
grid-gap: var(--ha-space-4);
grid-gap: 16px;
padding: 16px;
}
.feature {

View File

@@ -427,7 +427,7 @@ export class HaVoiceAssistantSetupStepPipeline extends LitElement {
width: 100%;
height: 10px;
display: flex;
gap: var(--ha-space-1);
gap: 4px;
margin: 8px 0;
}
.segment {

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