Compare commits

..

1 Commits

Author SHA1 Message Date
Bram Kragten 09982a9238 Fix form integer when data is null 2026-04-02 23:28:06 +02:00
101 changed files with 1231 additions and 3857 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@5752577ea7cc5aefb064b0b21432f18fe4d6ba90 # master
uses: home-assistant/actions/helpers/verify-version@d56d093b9ab8d2105bc0cb6ee9bcc0ef4ec8b96d # master
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+3 -3
View File
@@ -6,9 +6,9 @@ import rootConfig from "../eslint.config.mjs";
export default tseslint.config(...rootConfig, {
rules: {
"no-console": "off",
"import-x/no-extraneous-dependencies": "off",
"import-x/extensions": "off",
"import-x/no-dynamic-require": "off",
"import/no-extraneous-dependencies": "off",
"import/extensions": "off",
"import/no-dynamic-require": "off",
"global-require": "off",
"@typescript-eslint/no-require-imports": "off",
"prefer-arrow-callback": "off",
@@ -6,6 +6,7 @@ import presetEnv from "@babel/preset-env";
import compilationTargets from "@babel/helper-compilation-targets";
import coreJSCompat from "core-js-compat";
import { logPlugin } from "@babel/preset-env/lib/debug.js";
// eslint-disable-next-line import/no-relative-packages
import shippedPolyfills from "../node_modules/babel-plugin-polyfill-corejs3/lib/shipped-proposals.js";
import { babelOptions } from "./bundle.cjs";
+2 -2
View File
@@ -11,9 +11,9 @@ export const demoConfigs: (() => Promise<DemoConfig>)[] = [
() => import("./jimpower").then((mod) => mod.demoJimpower),
];
// eslint-disable-next-line import-x/no-mutable-exports
// eslint-disable-next-line import/no-mutable-exports
export let selectedDemoConfigIndex = 0;
// eslint-disable-next-line import-x/no-mutable-exports
// eslint-disable-next-line import/no-mutable-exports
export let selectedDemoConfig: Promise<DemoConfig> =
demoConfigs[selectedDemoConfigIndex]();
+14 -59
View File
@@ -1,5 +1,6 @@
// @ts-check
/* eslint-disable import/no-extraneous-dependencies */
import unusedImports from "eslint-plugin-unused-imports";
import globals from "globals";
import path from "node:path";
@@ -12,7 +13,6 @@ import { configs as litConfigs } from "eslint-plugin-lit";
import { configs as wcConfigs } from "eslint-plugin-wc";
import { configs as a11yConfigs } from "eslint-plugin-lit-a11y";
import html from "@html-eslint/eslint-plugin";
import importX from "eslint-plugin-import-x";
const _filename = fileURLToPath(import.meta.url);
const _dirname = path.dirname(_filename);
@@ -22,27 +22,8 @@ const compat = new FlatCompat({
allConfig: js.configs.all,
});
// Load airbnb-base via FlatCompat for non-import rules only.
// eslint-plugin-import is incompatible with ESLint 10 (uses removed APIs),
// so we strip its plugin/rules/settings and use eslint-plugin-import-x instead.
const airbnbConfigs = compat.extends("airbnb-base").map((config) => {
const { plugins = {}, rules = {}, settings = {}, ...rest } = config;
return {
...rest,
plugins: Object.fromEntries(
Object.entries(plugins).filter(([key]) => key !== "import")
),
rules: Object.fromEntries(
Object.entries(rules).filter(([key]) => !key.startsWith("import/"))
),
settings: Object.fromEntries(
Object.entries(settings).filter(([key]) => !key.startsWith("import/"))
),
};
});
export default tseslint.config(
...airbnbConfigs,
...compat.extends("airbnb-base"),
eslintConfigPrettier,
litConfigs["flat/all"],
tseslint.configs.recommended,
@@ -50,7 +31,6 @@ export default tseslint.config(
tseslint.configs.stylistic,
wcConfigs["flat/recommended"],
a11yConfigs.recommended,
importX.flatConfigs.recommended,
{
plugins: {
"unused-imports": unusedImports,
@@ -78,7 +58,7 @@ export default tseslint.config(
},
settings: {
"import-x/resolver": {
"import/resolver": {
webpack: {
config: "./rspack.config.cjs",
},
@@ -107,20 +87,12 @@ export default tseslint.config(
"prefer-destructuring": "off",
"no-restricted-globals": [2, "event"],
"prefer-promise-reject-errors": "off",
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
"object-curly-newline": "off",
"default-case": "off",
"wc/no-self-class": "off",
"no-shadow": "off",
"no-use-before-define": "off",
"import/prefer-default-export": "off",
"import/no-default-export": "off",
"import/no-unresolved": "off",
"import/no-cycle": "off",
// import-x rules (migrated from eslint-plugin-import / airbnb-base)
"import-x/named": "off",
"import-x/prefer-default-export": "off",
"import-x/no-default-export": "off",
"import-x/no-unresolved": "off",
"import-x/no-cycle": "off",
"import-x/extensions": [
"import/extensions": [
"error",
"ignorePackages",
{
@@ -128,24 +100,12 @@ export default tseslint.config(
js: "never",
},
],
"import-x/no-mutable-exports": "error",
"import-x/no-amd": "error",
"import-x/first": "error",
"import-x/order": [
"error",
{ groups: [["builtin", "external", "internal"]] },
],
"import-x/newline-after-import": "error",
"import-x/no-absolute-path": "error",
"import-x/no-dynamic-require": "error",
"import-x/no-webpack-loader-syntax": "error",
"import-x/no-named-default": "error",
"import-x/no-self-import": "error",
"import-x/no-useless-path-segments": ["error", { commonjs: true }],
"import-x/no-import-module-exports": ["error", { exceptions: [] }],
"import-x/no-relative-packages": "error",
// TypeScript rules
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
"object-curly-newline": "off",
"default-case": "off",
"wc/no-self-class": "off",
"no-shadow": "off",
"@typescript-eslint/camelcase": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-use-before-define": "off",
@@ -225,6 +185,7 @@ export default tseslint.config(
allowObjectTypes: "always",
},
],
"no-use-before-define": "off",
},
},
{
@@ -233,12 +194,6 @@ export default tseslint.config(
globals: globals.audioWorklet,
},
},
{
files: ["src/entrypoints/service-worker.ts"],
languageOptions: {
globals: globals.serviceworker,
},
},
{
plugins: {
html,
+2 -79
View File
@@ -488,79 +488,6 @@ const SCHEMAS: {
},
],
},
{
title: "Tabs",
translations: {
settings: "Settings",
tab_general: "General",
tab_appearance: "Appearance",
name: "Name",
entity: "Entity",
theme: "Theme",
state_color: "Color on state",
},
schema: [
{
type: "tabs",
name: "settings",
tabs: [
{
name: "general",
icon: "mdi:cog",
schema: [
{ name: "name", selector: { text: {} } },
{ name: "entity", required: true, selector: { entity: {} } },
],
},
{
name: "appearance",
icon: "mdi:palette",
schema: [
{ name: "theme", selector: { theme: {} } },
{ name: "state_color", selector: { boolean: {} } },
],
},
],
},
],
},
{
title: "Tabs (compact)",
translations: {
settings: "Settings",
tab_general: "General",
tab_appearance: "Appearance",
name: "Name",
entity: "Entity",
theme: "Theme",
state_color: "Color on state",
},
schema: [
{
type: "tabs",
name: "settings",
fill_tabs: false,
tabs: [
{
name: "general",
icon: "mdi:cog",
schema: [
{ name: "name", selector: { text: {} } },
{ name: "entity", required: true, selector: { entity: {} } },
],
},
{
name: "appearance",
icon: "mdi:palette",
schema: [
{ name: "theme", selector: { theme: {} } },
{ name: "state_color", selector: { boolean: {} } },
],
},
],
},
],
},
];
@customElement("demo-components-ha-form")
@@ -608,12 +535,8 @@ class DemoHaForm extends LitElement {
.error=${info.error}
.disabled=${this.disabled[idx]}
.computeError=${(error) => translations[error] || error}
.computeLabel=${(schema, _data, options) => {
if (options?.tab) {
return translations[`tab_${options.tab}`] || options.tab;
}
return translations[schema.name] || schema.name;
}}
.computeLabel=${(schema) =>
translations[schema.name] || schema.name}
.computeHelper=${() => "Helper text"}
@value-changed=${this._handleValueChanged}
.sampleIdx=${idx}
@@ -1,73 +0,0 @@
---
title: Textarea
---
# Textarea `<ha-textarea>`
A multiline text input component supporting Home Assistant theming and validation, based on webawesome textarea.
Supports autogrow, hints, validation, and both material and outlined appearances.
## Implementation
### Example usage
```html
<ha-textarea label="Description" value="Hello world"></ha-textarea>
<ha-textarea
label="Notes"
placeholder="Type here..."
resize="auto"
></ha-textarea>
<ha-textarea label="Required field" required></ha-textarea>
<ha-textarea label="Disabled" disabled value="Can't edit this"></ha-textarea>
```
### API
This component is based on the webawesome textarea component.
**Slots**
- `label`: Custom label content. Overrides the `label` property.
- `hint`: Custom hint content. Overrides the `hint` property.
**Properties/Attributes**
| Name | Type | Default | Description |
| ------------------ | -------------------------------------------------------------- | ------- | ------------------------------------------------------------------------ |
| value | String | - | The current value of the textarea. |
| label | String | "" | The textarea's label text. |
| hint | String | "" | The textarea's hint/helper text. |
| placeholder | String | "" | Placeholder text shown when the textarea is empty. |
| rows | Number | 4 | The number of visible text rows. |
| resize | "none"/"vertical"/"horizontal"/"both"/"auto" | "none" | Controls the textarea's resize behavior. |
| readonly | Boolean | false | Makes the textarea readonly. |
| disabled | Boolean | false | Disables the textarea and prevents user interaction. |
| required | Boolean | false | Makes the textarea a required field. |
| auto-validate | Boolean | false | Validates the textarea on blur instead of on form submit. |
| invalid | Boolean | false | Marks the textarea as invalid. |
| validation-message | String | "" | Custom validation message shown when the textarea is invalid. |
| minlength | Number | - | The minimum length of input that will be considered valid. |
| maxlength | Number | - | The maximum length of input that will be considered valid. |
| name | String | - | The name of the textarea, submitted as a name/value pair with form data. |
| autocapitalize | "off"/"none"/"on"/"sentences"/"words"/"characters" | "" | Controls whether and how text input is automatically capitalized. |
| autocomplete | String | - | Indicates whether the browser's autocomplete feature should be used. |
| autofocus | Boolean | false | Automatically focuses the textarea when the page loads. |
| spellcheck | Boolean | true | Enables or disables the browser's spellcheck feature. |
| inputmode | "none"/"text"/"decimal"/"numeric"/"tel"/"search"/"email"/"url" | "" | Hints at the type of data for showing an appropriate virtual keyboard. |
| enterkeyhint | "enter"/"done"/"go"/"next"/"previous"/"search"/"send" | "" | Customizes the label or icon of the Enter key on virtual keyboards. |
#### CSS Parts
- `wa-base` - The underlying wa-textarea base wrapper.
- `wa-hint` - The underlying wa-textarea hint container.
- `wa-textarea` - The underlying wa-textarea textarea element.
**CSS Custom Properties**
- `--ha-textarea-padding-bottom` - Padding below the textarea host.
- `--ha-textarea-max-height` - Maximum height of the textarea when using `resize="auto"`. Defaults to `200px`.
- `--ha-textarea-required-marker` - The marker shown after the label for required fields. Defaults to `"*"`.
-151
View File
@@ -1,151 +0,0 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-textarea";
@customElement("demo-components-ha-textarea")
export class DemoHaTextarea extends LitElement {
protected render(): TemplateResult {
return html`
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-textarea in ${mode}">
<div class="card-content">
<h3>Basic</h3>
<div class="row">
<ha-textarea label="Default"></ha-textarea>
<ha-textarea
label="With value"
value="Hello world"
></ha-textarea>
<ha-textarea
label="With placeholder"
placeholder="Type here..."
></ha-textarea>
</div>
<h3>Autogrow</h3>
<div class="row">
<ha-textarea
label="Autogrow empty"
resize="auto"
></ha-textarea>
<ha-textarea
label="Autogrow with value"
resize="auto"
value="This textarea will grow as you type more content into it. Try adding more lines to see the effect."
></ha-textarea>
</div>
<h3>States</h3>
<div class="row">
<ha-textarea
label="Disabled"
disabled
value="Disabled"
></ha-textarea>
<ha-textarea
label="Readonly"
readonly
value="Readonly"
></ha-textarea>
<ha-textarea label="Required" required></ha-textarea>
</div>
<div class="row">
<ha-textarea
label="Invalid"
invalid
validation-message="This field is required"
value=""
></ha-textarea>
<ha-textarea
label="With hint"
hint="Supports Markdown"
></ha-textarea>
<ha-textarea
label="With rows"
.rows=${6}
placeholder="6 rows"
></ha-textarea>
</div>
<h3>No label</h3>
<div class="row">
<ha-textarea
placeholder="No label, just placeholder"
></ha-textarea>
<ha-textarea
resize="auto"
placeholder="No label, autogrow"
></ha-textarea>
</div>
</div>
</ha-card>
</div>
`
)}
`;
}
firstUpdated(changedProps) {
super.firstUpdated(changedProps);
applyThemesOnElement(
this.shadowRoot!.querySelector(".dark"),
{
default_theme: "default",
default_dark_theme: "default",
themes: {},
darkMode: true,
theme: "default",
},
undefined,
undefined,
true
);
}
static styles = css`
:host {
display: flex;
justify-content: center;
}
.dark,
.light {
display: block;
background-color: var(--primary-background-color);
padding: 0 50px;
}
ha-card {
margin: 24px auto;
}
.card-content {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
h3 {
margin: var(--ha-space-4) 0 var(--ha-space-1) 0;
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-medium);
}
h3:first-child {
margin-top: 0;
}
.row {
display: flex;
gap: var(--ha-space-4);
}
.row > * {
flex: 1;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-textarea": DemoHaTextarea;
}
}
@@ -1,5 +1,6 @@
import "@material/mwc-linear-progress/mwc-linear-progress";
import { mdiArrowCollapseDown, mdiDownload } from "@mdi/js";
// eslint-disable-next-line import/extensions
import { IntersectionController } from "@lit-labs/observers/intersection-controller.js";
import { LitElement, type PropertyValues, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
+1 -1
View File
@@ -1,6 +1,6 @@
export default {
"*.?(c|m){js,ts}": [
"eslint --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --fix",
"eslint --flag v10_config_lookup_from_file --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --fix",
"prettier --cache --write",
"lit-analyzer --quiet",
],
+14 -16
View File
@@ -8,8 +8,8 @@
"version": "1.0.0",
"scripts": {
"build": "script/build_frontend",
"lint:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --max-warnings=0",
"format:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
"lint:eslint": "eslint --flag v10_config_lookup_from_file \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --max-warnings=0",
"format:eslint": "eslint --flag v10_config_lookup_from_file \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
"lint:prettier": "prettier . --cache --check",
"format:prettier": "prettier . --cache --write",
"lint:types": "tsc",
@@ -34,7 +34,7 @@
"@codemirror/legacy-modes": "6.5.2",
"@codemirror/search": "6.6.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.41.0",
"@codemirror/view": "6.40.0",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.3.1",
@@ -73,6 +73,8 @@
"@material/mwc-radio": "0.27.0",
"@material/mwc-select": "0.27.0",
"@material/mwc-switch": "0.27.0",
"@material/mwc-textarea": "0.27.0",
"@material/mwc-textfield": "0.27.0",
"@material/mwc-top-app-bar": "0.27.0",
"@material/mwc-top-app-bar-fixed": "0.27.0",
"@material/top-app-bar": "=14.0.0-canary.53b3cad2f.0",
@@ -80,14 +82,14 @@
"@mdi/js": "7.4.47",
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.21",
"@swc/helpers": "0.5.20",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "3.9.1",
"@tsparticles/preset-links": "3.2.0",
"@vibrant/color": "4.0.4",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.1.2",
"barcode-detector": "3.1.1",
"cally": "0.9.2",
"color-name": "2.1.0",
"comlink": "4.4.2",
@@ -100,7 +102,7 @@
"dialog-polyfill": "0.5.6",
"echarts": "6.0.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.2.0",
"fuse.js": "7.1.0",
"google-timezones-json": "1.2.0",
"gulp-zopfli-green": "7.0.0",
"hls.js": "1.6.15",
@@ -142,18 +144,16 @@
"@babel/plugin-transform-runtime": "7.29.0",
"@babel/preset-env": "7.29.2",
"@bundle-stats/plugin-webpack-filter": "4.22.0",
"@eslint/eslintrc": "3.3.5",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.58.1",
"@lokalise/node-api": "15.6.1",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.7",
"@rspack/core": "1.7.11",
"@rsdoctor/rspack-plugin": "1.5.6",
"@rspack/core": "1.7.10",
"@rspack/dev-server": "1.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-receiver": "6.0.25",
"@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0",
"@types/culori": "4.0.1",
@@ -174,12 +174,11 @@
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
"del": "8.0.1",
"eslint": "10.2.0",
"eslint": "9.39.4",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-import-resolver-webpack": "0.13.10",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-import-x": "4.16.2",
"eslint-plugin-lit": "2.2.1",
"eslint-plugin-lit-a11y": "5.1.1",
"eslint-plugin-unused-imports": "4.4.1",
@@ -187,7 +186,6 @@
"fancy-log": "2.0.0",
"fs-extra": "11.3.4",
"glob": "13.0.6",
"globals": "17.4.0",
"gulp": "5.0.1",
"gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0",
@@ -210,7 +208,7 @@
"terser-webpack-plugin": "5.4.0",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.2",
"typescript-eslint": "8.58.0",
"typescript-eslint": "8.57.2",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.2",
"webpack-stats-plugin": "1.1.3",
-6
View File
@@ -27,7 +27,6 @@ export type DateRange =
| "this_year"
| "now-7d"
| "now-30d"
| "now-365d"
| "now-12m"
| "now-1h"
| "now-12h"
@@ -103,11 +102,6 @@ export const calcDateRange = (
),
calcDate(today, endOfMonth, locale, hassConfig),
];
case "now-365d":
return [
calcDate(today, subDays, locale, hassConfig, 365),
calcDate(today, subDays, locale, hassConfig, 0),
];
case "now-1h":
return [
calcDate(today, subHours, locale, hassConfig, 1),
-8
View File
@@ -38,14 +38,6 @@ export interface HASSDomEvent<T> extends Event {
detail: T;
}
export type HASSDomTargetEvent<T extends EventTarget> = Event & {
target: T;
};
export type HASSDomCurrentTargetEvent<T extends EventTarget> = Event & {
currentTarget: T;
};
/**
* Dispatches a custom event with an optional detail value.
*
+22 -39
View File
@@ -242,18 +242,14 @@ const FIXED_DOMAIN_ATTRIBUTE_STATES = {
},
};
export const getStatesDomain = (
export const getStates = (
hass: HomeAssistant,
domain: string,
attribute?: string | undefined
state: HassEntity,
attribute: string | undefined = undefined
): string[] => {
const domain = computeStateDomain(state);
const result: string[] = [];
if (!attribute) {
// All entities can have unavailable states
result.push(...UNAVAILABLE_STATES);
}
if (!attribute && domain in FIXED_DOMAIN_STATES) {
result.push(...FIXED_DOMAIN_STATES[domain]);
} else if (
@@ -264,7 +260,21 @@ export const getStatesDomain = (
result.push(...FIXED_DOMAIN_ATTRIBUTE_STATES[domain][attribute]);
}
// Dynamic values based on the entities
switch (domain) {
case "climate":
if (!attribute) {
result.push(...state.attributes.hvac_modes);
} else if (attribute === "fan_mode") {
result.push(...state.attributes.fan_modes);
} else if (attribute === "preset_mode") {
result.push(...state.attributes.preset_modes);
} else if (attribute === "swing_mode") {
result.push(...state.attributes.swing_modes);
} else if (attribute === "swing_horizontal_mode") {
result.push(...state.attributes.swing_horizontal_modes);
}
break;
case "device_tracker":
case "person":
if (!attribute) {
@@ -283,37 +293,6 @@ export const getStatesDomain = (
);
}
break;
}
return result;
};
export const getStates = (
hass: HomeAssistant,
state: HassEntity,
attribute: string | undefined = undefined
): string[] => {
const domain = computeStateDomain(state);
const result: string[] = [];
// Fixed values based on a domain
result.push(...getStatesDomain(hass, domain, attribute));
// Dynamic values based on the entities
switch (domain) {
case "climate":
if (!attribute) {
result.push(...state.attributes.hvac_modes);
} else if (attribute === "fan_mode") {
result.push(...state.attributes.fan_modes);
} else if (attribute === "preset_mode") {
result.push(...state.attributes.preset_modes);
} else if (attribute === "swing_mode") {
result.push(...state.attributes.swing_modes);
} else if (attribute === "swing_horizontal_mode") {
result.push(...state.attributes.swing_horizontal_modes);
}
break;
case "event":
if (attribute === "event_type") {
result.push(...state.attributes.event_types);
@@ -374,5 +353,9 @@ export const getStates = (
break;
}
if (!attribute) {
// All entities can have unavailable states
result.push(...UNAVAILABLE_STATES);
}
return [...new Set(result)];
};
+3
View File
@@ -10,10 +10,13 @@
*
* @see https://github.com/home-assistant/frontend/issues/28732
*/
// eslint-disable-next-line import/extensions
import { directive, Directive } from "lit-html/directive.js";
// eslint-disable-next-line import/extensions
import { setCommittedValue } from "lit-html/directive-helpers.js";
// eslint-disable-next-line lit/no-legacy-imports
import { nothing } from "lit-html";
// eslint-disable-next-line import/extensions
import type { Part } from "lit-html/directive.js";
class KeyedES5 extends Directive {
+31 -73
View File
@@ -17,10 +17,6 @@ import memoizeOne from "memoize-one";
import { STRINGS_SEPARATOR_DOT } from "../../common/const";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import { fireEvent } from "../../common/dom/fire_event";
import type {
HASSDomCurrentTargetEvent,
HASSDomTargetEvent,
} from "../../common/dom/fire_event";
import { stringCompare } from "../../common/string/compare";
import type { LocalizeFunc } from "../../common/translations/localize";
import { debounce } from "../../common/util/debounce";
@@ -107,7 +103,6 @@ export interface DataTableRowData {
export type SortableColumnContainer = Record<string, ClonedDataTableColumnData>;
const UNDEFINED_GROUP_KEY = "zzzzz_undefined";
const AUTO_FOCUS_ALLOWED_ACTIVE_TAGS = ["BODY", "HTML", "HOME-ASSISTANT"];
@customElement("ha-data-table")
export class HaDataTable extends LitElement {
@@ -171,10 +166,6 @@ export class HaDataTable extends LitElement {
@query("slot[name='header']") private _header!: HTMLSlotElement;
@query(".mdc-data-table__header-row") private _headerRow?: HTMLDivElement;
@query("lit-virtualizer") private _scroller?: HTMLElement;
@state() private _collapsedGroups: string[] = [];
@state() private _lastSelectedRowId: string | null = null;
@@ -251,28 +242,16 @@ export class HaDataTable extends LitElement {
this.updateComplete.then(() => this._calcTableHeight());
}
protected updated(changedProps: PropertyValues) {
if (!this._headerRow) {
protected updated() {
const header = this.renderRoot.querySelector(".mdc-data-table__header-row");
if (!header) {
return;
}
if (this._headerRow.scrollWidth > this._headerRow.clientWidth) {
this.style.setProperty(
"--table-row-width",
`${this._headerRow.scrollWidth}px`
);
if (header.scrollWidth > header.clientWidth) {
this.style.setProperty("--table-row-width", `${header.scrollWidth}px`);
} else {
this.style.removeProperty("--table-row-width");
}
if (
changedProps.has("selectable") ||
(!this.autoHeight &&
document.activeElement &&
AUTO_FOCUS_ALLOWED_ACTIVE_TAGS.includes(document.activeElement.tagName))
) {
this._focusScroller();
}
}
public willUpdate(properties: PropertyValues) {
@@ -538,7 +517,6 @@ export class HaDataTable extends LitElement {
<lit-virtualizer
scroller
class="mdc-data-table__content scroller ha-scrollbar"
tabindex=${ifDefined(!this.autoHeight ? "0" : undefined)}
@scroll=${this._saveScrollPos}
.items=${this._groupData(
this._filteredData,
@@ -851,10 +829,8 @@ export class HaDataTable extends LitElement {
): Promise<DataTableRowData[]> => filterData(data, columns, filter)
);
private _handleHeaderClick(
ev: HASSDomCurrentTargetEvent<HTMLElement & { columnId: string }>
) {
const columnId = ev.currentTarget.columnId;
private _handleHeaderClick(ev: Event) {
const columnId = (ev.currentTarget as any).columnId;
if (!this.columns[columnId].sortable) {
return;
}
@@ -872,12 +848,11 @@ export class HaDataTable extends LitElement {
column: columnId,
direction: this.sortDirection,
});
this._focusScroller();
}
private _handleHeaderRowCheckboxClick(ev: HASSDomTargetEvent<HaCheckbox>) {
if (ev.target.checked) {
private _handleHeaderRowCheckboxClick(ev: Event) {
const checkbox = ev.target as HaCheckbox;
if (checkbox.checked) {
this.selectAll();
} else {
this._checkedRows = [];
@@ -886,10 +861,9 @@ export class HaDataTable extends LitElement {
this._lastSelectedRowId = null;
}
private _handleRowCheckboxClicked = (
ev: HASSDomCurrentTargetEvent<HaCheckbox & { rowId: string }>
) => {
const rowId = ev.currentTarget.rowId;
private _handleRowCheckboxClicked = (ev: Event) => {
const checkbox = ev.currentTarget as HaCheckbox;
const rowId = (checkbox as any).rowId;
const groupedData = this._groupData(
this._filteredData,
@@ -926,7 +900,7 @@ export class HaDataTable extends LitElement {
...this._selectRange(groupedData, lastSelectedRowIndex, rowIndex),
];
}
} else if (!ev.currentTarget.checked) {
} else if (!checkbox.checked) {
if (!this._checkedRows.includes(rowId)) {
this._checkedRows = [...this._checkedRows, rowId];
}
@@ -964,9 +938,7 @@ export class HaDataTable extends LitElement {
return checkedRows;
}
private _handleRowClick = (
ev: HASSDomCurrentTargetEvent<HTMLElement & { rowId: string }>
) => {
private _handleRowClick = (ev: Event) => {
if (
ev
.composedPath()
@@ -982,13 +954,14 @@ export class HaDataTable extends LitElement {
) {
return;
}
const rowId = ev.currentTarget.rowId;
const rowId = (ev.currentTarget as any).rowId;
fireEvent(this, "row-click", { id: rowId }, { bubbles: false });
};
private _setTitle(ev: HASSDomCurrentTargetEvent<HTMLElement>) {
if (ev.currentTarget.scrollWidth > ev.currentTarget.offsetWidth) {
ev.currentTarget.setAttribute("title", ev.currentTarget.innerText);
private _setTitle(ev: Event) {
const target = ev.currentTarget as HTMLElement;
if (target.scrollWidth > target.offsetWidth) {
target.setAttribute("title", target.innerText);
}
}
@@ -1010,12 +983,6 @@ export class HaDataTable extends LitElement {
this._debounceSearch((ev.target as HTMLInputElement).value);
}
private _focusScroller(): void {
this._scroller?.focus({
preventScroll: true,
});
}
private async _calcTableHeight() {
if (this.autoHeight) {
return;
@@ -1025,27 +992,23 @@ export class HaDataTable extends LitElement {
}
@eventOptions({ passive: true })
private _saveScrollPos(e: HASSDomTargetEvent<HTMLDivElement>) {
this._savedScrollPos = e.target.scrollTop;
private _saveScrollPos(e: Event) {
this._savedScrollPos = (e.target as HTMLDivElement).scrollTop;
if (this._headerRow) {
this._headerRow.scrollLeft = e.target.scrollLeft;
}
this.renderRoot.querySelector(".mdc-data-table__header-row")!.scrollLeft = (
e.target as HTMLDivElement
).scrollLeft;
}
@eventOptions({ passive: true })
private _scrollContent(e: HASSDomTargetEvent<HTMLDivElement>) {
if (!this._scroller) {
return;
}
this._scroller.scrollLeft = e.target.scrollLeft;
private _scrollContent(e: Event) {
this.renderRoot.querySelector("lit-virtualizer")!.scrollLeft = (
e.target as HTMLDivElement
).scrollLeft;
}
private _collapseGroup = (
ev: HASSDomCurrentTargetEvent<HTMLElement & { group: string }>
) => {
const groupName = ev.currentTarget.group;
private _collapseGroup = (ev: Event) => {
const groupName = (ev.currentTarget as any).group;
if (this._collapsedGroups.includes(groupName)) {
this._collapsedGroups = this._collapsedGroups.filter(
(grp) => grp !== groupName
@@ -1468,11 +1431,6 @@ export class HaDataTable extends LitElement {
contain: size layout !important;
overscroll-behavior: contain;
}
lit-virtualizer:focus,
lit-virtualizer:focus-visible {
outline: none;
}
`,
];
}
@@ -133,8 +133,7 @@ export class HaDateRangePicker extends LitElement {
${!this.minimal
? html`<ha-textarea
id="field"
rows="1"
resize="auto"
mobile-multiline
@click=${this._openPicker}
@keydown=${this._handleKeydown}
.value=${(isThisYear(this.startDate)
@@ -337,7 +336,14 @@ export class HaDateRangePicker extends LitElement {
private _setTextareaFocusStyle(focused: boolean) {
const textarea = this.renderRoot.querySelector("ha-textarea");
if (textarea) {
textarea.setFocused(focused);
const foundation = (textarea as any).mdcFoundation;
if (foundation) {
if (focused) {
foundation.activateFocus();
} else {
foundation.deactivateFocus();
}
}
}
}
@@ -38,8 +38,6 @@ export class HaEntityStatePicker extends LitElement {
@property() public helper?: string;
@property({ attribute: "no-entity", type: Boolean }) public noEntity = false;
private _getItems = memoizeOne(
(
hass: HomeAssistant,
@@ -126,8 +124,7 @@ export class HaEntityStatePicker extends LitElement {
<ha-generic-picker
.hass=${this.hass}
.allowCustomValue=${this.allowCustomValue}
.disabled=${this.disabled ||
(!this.entityId && this.noEntity === false)}
.disabled=${this.disabled || !this.entityId}
.autofocus=${this.autofocus}
.required=${this.required}
.label=${this.label ??
@@ -38,17 +38,6 @@ export const computeInitialHaFormData = (
// Only add expandable data if it's required or any of its children have initial values.
data[field.name] = expandableData;
}
} else if (field.type === "tabs") {
const tabsData: Record<string, unknown> = {};
for (const tab of field.tabs) {
Object.assign(tabsData, computeInitialHaFormData(tab.schema));
}
const flattenTabs = field.flatten ?? !field.name;
if (flattenTabs) {
Object.assign(data, tabsData);
} else if (field.required || Object.keys(tabsData).length) {
data[field.name] = tabsData;
}
} else if (!field.required) {
// Do nothing.
} else if (field.type === "boolean") {
+3 -1
View File
@@ -100,7 +100,9 @@ export class HaFormInteger extends LitElement implements HaFormElement {
inputMode="numeric"
.label=${this.label}
.hint=${this.helper}
.value=${this.data?.toString() ?? ""}
.value=${this.data !== undefined && this.data !== null
? this.data.toString()
: ""}
.disabled=${this.disabled}
.required=${this.schema.required}
.autoValidate=${this.schema.required}
-193
View File
@@ -1,193 +0,0 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { HomeAssistant } from "../../types";
import "../ha-icon";
import "../ha-svg-icon";
import "../ha-tab-group";
import "../ha-tab-group-tab";
import "./ha-form";
import type { HaForm } from "./ha-form";
import type {
HaFormDataContainer,
HaFormElement,
HaFormSchema,
HaFormTabsSchema,
} from "./types";
@customElement("ha-form-tabs")
export class HaFormTabs extends LitElement implements HaFormElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public data!: HaFormDataContainer;
@property({ attribute: false }) public schema!: HaFormTabsSchema;
@property({ type: Boolean }) public disabled = false;
@property({ attribute: false }) public computeLabel?: (
schema: HaFormSchema,
data?: HaFormDataContainer,
options?: { path?: string[]; tab?: string }
) => string;
@property({ attribute: false }) public computeHelper?: (
schema: HaFormSchema,
options?: { path?: string[] }
) => string;
@property({ attribute: false }) public localizeValue?: (
key: string
) => string;
@state() private _activeTab?: string;
private _handleTabShow = (ev: CustomEvent<{ name: string }>) => {
const name = ev.detail?.name;
if (name !== undefined) {
this._activeTab = name;
}
};
protected willUpdate(changedProps: Map<PropertyKey, unknown>): void {
super.willUpdate(changedProps);
if (changedProps.has("schema") && this.schema.tabs.length) {
const first = this.schema.tabs[0]!.name;
if (
this._activeTab === undefined ||
!this.schema.tabs.some((t) => t.name === this._activeTab)
) {
this._activeTab = first;
}
}
}
public reportValidity(): boolean {
const forms = this.renderRoot.querySelectorAll<HaForm>("ha-form");
let valid = true;
forms.forEach((form) => {
if (!form.reportValidity()) {
valid = false;
}
});
return valid;
}
private _computeLabel = (
schema: HaFormSchema,
data?: HaFormDataContainer,
options?: { path?: string[] }
) => {
if (!this.computeLabel) {
return undefined;
}
return this.computeLabel(schema, data, {
...options,
path: [...(options?.path || []), this.schema.name],
});
};
private _computeHelper = (
schema: HaFormSchema,
options?: { path?: string[] }
) => {
if (!this.computeHelper) {
return undefined;
}
return this.computeHelper(schema, {
...options,
path: [...(options?.path || []), this.schema.name],
});
};
private _tabTitle(tabName: string): string {
if (!this.computeLabel) {
return tabName;
}
return (
this.computeLabel(this.schema, this.data, {
path: [...(this.schema.name ? [this.schema.name] : [])],
tab: tabName,
}) ?? tabName
);
}
protected render() {
const tabs = this.schema.tabs;
if (!tabs.length) {
return nothing;
}
const active = this._activeTab ?? tabs[0]!.name;
const fillTabs = this.schema.fill_tabs !== false;
return html`
<ha-tab-group ?fill-tabs=${fillTabs} @wa-tab-show=${this._handleTabShow}>
${tabs.map(
(tab) => html`
<ha-tab-group-tab
slot="nav"
.panel=${tab.name}
.active=${active === tab.name}
>
${tab.icon
? html`<ha-icon .icon=${tab.icon}></ha-icon>`
: tab.iconPath
? html`<ha-svg-icon .path=${tab.iconPath}></ha-svg-icon>`
: nothing}
${this._tabTitle(tab.name)}
</ha-tab-group-tab>
`
)}
</ha-tab-group>
<div class="panels">
${tabs.map((tab) => {
const hidden = active !== tab.name;
return html`
<div class="panel" ?hidden=${hidden}>
<ha-form
.hass=${this.hass}
.data=${this.data}
.schema=${tab.schema}
.disabled=${this.disabled}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
.localizeValue=${this.localizeValue}
></ha-form>
</div>
`;
})}
</div>
`;
}
static styles = css`
:host {
display: flex !important;
flex-direction: column;
}
.panels {
padding-top: var(--ha-space-4);
}
.panel[hidden] {
display: none !important;
}
:host ha-form {
display: block;
}
ha-tab-group {
display: block;
}
ha-tab-group-tab ha-icon,
ha-tab-group-tab ha-svg-icon {
flex-shrink: 0;
margin-inline-end: var(--ha-space-2);
color: var(--secondary-text-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-form-tabs": HaFormTabs;
}
}
-1
View File
@@ -14,7 +14,6 @@ const LOAD_ELEMENTS = {
float: () => import("./ha-form-float"),
grid: () => import("./ha-form-grid"),
expandable: () => import("./ha-form-expandable"),
tabs: () => import("./ha-form-tabs"),
integer: () => import("./ha-form-integer"),
multi_select: () => import("./ha-form-multi_select"),
positive_time_period_dict: () =>
+2 -32
View File
@@ -14,8 +14,7 @@ export type HaFormSchema =
| HaFormSelector
| HaFormGridSchema
| HaFormExpandableSchema
| HaFormOptionalActionsSchema
| HaFormTabsSchema;
| HaFormOptionalActionsSchema;
export interface HaFormBaseSchema {
name: string;
@@ -55,26 +54,6 @@ export interface HaFormOptionalActionsSchema extends HaFormBaseSchema {
schema: readonly HaFormSchema[];
}
/** One tab pane inside a {@link HaFormTabsSchema} (not a standalone form field). */
export interface HaFormTabDefinition {
name: string;
icon?: string;
iconPath?: string;
schema: readonly HaFormSchema[];
}
export interface HaFormTabsSchema extends HaFormBaseSchema {
type: "tabs";
/** When true (default), tab field values merge into the parent data object. */
flatten?: boolean;
/**
* When true (default), tab labels share width equally across the tab bar.
* Set to false for compact tabs that only use their natural width.
*/
fill_tabs?: boolean;
tabs: readonly HaFormTabDefinition[];
}
export interface HaFormSelector extends HaFormBaseSchema {
type?: never;
selector: Selector;
@@ -125,13 +104,6 @@ export interface HaFormTimeSchema extends HaFormBaseSchema {
}
// Type utility to unionize a schema array by flattening any grid schemas
type SchemaUnionTabs<T extends readonly HaFormTabDefinition[]> =
T[number] extends infer Tab
? Tab extends HaFormTabDefinition
? SchemaUnion<Tab["schema"]>
: never
: never;
export type SchemaUnion<
SchemaArray extends readonly HaFormSchema[],
Schema = SchemaArray[number],
@@ -140,9 +112,7 @@ export type SchemaUnion<
| HaFormExpandableSchema
| HaFormOptionalActionsSchema
? SchemaUnion<Schema["schema"]> | Schema
: Schema extends HaFormTabsSchema
? SchemaUnionTabs<Schema["tabs"]> | Schema
: Schema;
: Schema;
export type HaFormDataContainer = Record<string, HaFormData>;
+4 -4
View File
@@ -171,7 +171,7 @@ export class HaGauge extends LitElement {
? svg`
<path
class="needle"
d="M -34,-3 L -48,-1 A 1,1,0,0,0,-48,1 L -34,3 A 2,2,0,0,0,-34,-3 Z"
d="M -36,-2 L -44,-1 A 1,1,0,0,0,-44,1 L -36,2 A 2,2,0,0,0,-36,-2 Z"
style=${styleMap({ transform: `rotate(${this._angle}deg)` })}
/>
@@ -243,19 +243,19 @@ export class HaGauge extends LitElement {
.levels-base {
fill: none;
stroke: var(--primary-background-color);
stroke-width: 12;
stroke-width: 6;
stroke-linecap: butt;
}
.level {
fill: none;
stroke-width: 12;
stroke-width: 6;
stroke-linecap: butt;
}
.value {
fill: none;
stroke-width: 12;
stroke-width: 6;
stroke: var(--gauge-color);
stroke-linecap: butt;
transition: stroke-dashoffset 1s ease 0s;
+1
View File
@@ -1,6 +1,7 @@
import type { PropertyValues } from "lit";
import { ReactiveElement, render, html } from "lit";
import { customElement, property } from "lit/decorators";
// eslint-disable-next-line import/extensions
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import hash from "object-hash";
import { fireEvent } from "../common/dom/fire_event";
@@ -98,7 +98,6 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
.noEntity=${this.selector.state?.no_entity ?? false}
.disabled=${this.disabled}
.required=${this.required}
allow-custom-value
@@ -65,14 +65,15 @@ export class HaTextSelector extends LitElement {
.label=${this.label}
.placeholder=${this.placeholder}
.value=${this.value || ""}
.hint=${this.helper}
.helper=${this.helper}
helperPersistent
.disabled=${this.disabled}
@input=${this._handleChange}
autocapitalize="none"
.autocomplete=${this.selector.text?.autocomplete}
spellcheck="false"
.required=${this.required}
resize="auto"
autogrow
></ha-textarea>`;
}
return html`<ha-input
-5
View File
@@ -26,11 +26,6 @@ export class HaTabGroupTab extends Tab {
opacity: 1;
}
.tab {
width: var(--ha-tab-base-width, auto);
justify-content: var(--ha-tab-base-justify-content, flex-start);
}
@media (hover: hover) {
:host(:hover:not([disabled]):not([active])) .tab {
color: var(--wa-color-brand-on-quiet);
-29
View File
@@ -13,28 +13,6 @@ export class HaTabGroup extends TabGroup {
@property({ attribute: "tab-only", type: Boolean }) tabOnly = true;
/** When true (default), each tab trigger grows to fill the tab row evenly. */
@property({ type: Boolean, reflect: true, attribute: "fill-tabs" })
fillTabs = true;
connectedCallback(): void {
super.connectedCallback();
// Prevent the tab group from consuming Alt+Arrow and Cmd+Arrow keys,
// which browsers use for back/forward navigation.
this.addEventListener("keydown", this._handleKeyDown, true);
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener("keydown", this._handleKeyDown, true);
}
private _handleKeyDown = (event: KeyboardEvent) => {
if (event.altKey || event.metaKey) {
event.stopPropagation();
}
};
protected override handleClick(event: MouseEvent) {
if (this._dragScrollController.scrolled) {
return;
@@ -74,13 +52,6 @@ export class HaTabGroup extends TabGroup {
.scroll-button::part(base):hover {
background-color: transparent;
}
:host([fill-tabs]) .tab-group-top .tabs ::slotted(ha-tab-group-tab),
:host([fill-tabs]) .tab-group-bottom .tabs ::slotted(ha-tab-group-tab) {
flex: 1;
--ha-tab-base-width: 100%;
--ha-tab-base-justify-content: center;
}
`,
];
}
+48 -231
View File
@@ -1,249 +1,66 @@
import "@home-assistant/webawesome/dist/components/textarea/textarea";
import type WaTextarea from "@home-assistant/webawesome/dist/components/textarea/textarea";
import { HasSlotController } from "@home-assistant/webawesome/dist/internal/slot";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { WaInputMixin, waInputStyles } from "./input/wa-input-mixin";
import { TextAreaBase } from "@material/mwc-textarea/mwc-textarea-base";
import { styles as textfieldStyles } from "@material/mwc-textfield/mwc-textfield.css";
import { styles as textareaStyles } from "@material/mwc-textarea/mwc-textarea.css";
import type { PropertyValues } from "lit";
import { css } from "lit";
import { customElement, property } from "lit/decorators";
/**
* Home Assistant textarea component
*
* @element ha-textarea
* @extends {LitElement}
*
* @summary
* A multi-line text input component supporting Home Assistant theming and validation, based on webawesome textarea.
*
* @slot label - Custom label content. Overrides the `label` property.
* @slot hint - Custom hint content. Overrides the `hint` property.
*
* @csspart wa-base - The underlying wa-textarea base wrapper.
* @csspart wa-hint - The underlying wa-textarea hint container.
* @csspart wa-textarea - The underlying wa-textarea textarea element.
*
* @cssprop --ha-textarea-padding-bottom - Padding below the textarea host.
* @cssprop --ha-textarea-max-height - Maximum height of the textarea when using `resize="auto"`. Defaults to `200px`.
* @cssprop --ha-textarea-required-marker - The marker shown after the label for required fields. Defaults to `"*"`.
*
* @attr {string} label - The textarea's label text.
* @attr {string} hint - The textarea's hint/helper text.
* @attr {string} placeholder - Placeholder text shown when the textarea is empty.
* @attr {boolean} readonly - Makes the textarea readonly.
* @attr {boolean} disabled - Disables the textarea and prevents user interaction.
* @attr {boolean} required - Makes the textarea a required field.
* @attr {number} rows - Number of visible text rows.
* @attr {number} minlength - Minimum number of characters required.
* @attr {number} maxlength - Maximum number of characters allowed.
* @attr {("none"|"vertical"|"horizontal"|"both"|"auto")} resize - Controls the textarea's resize behavior. Defaults to `"none"`.
* @attr {boolean} auto-validate - Validates the textarea on blur instead of on form submit.
* @attr {boolean} invalid - Marks the textarea as invalid.
* @attr {string} validation-message - Custom validation message shown when the textarea is invalid.
*/
@customElement("ha-textarea")
export class HaTextArea extends WaInputMixin(LitElement) {
@property({ type: Number })
public rows?: number;
export class HaTextArea extends TextAreaBase {
@property({ type: Boolean, reflect: true }) autogrow = false;
@property()
public resize: "none" | "vertical" | "horizontal" | "both" | "auto" = "none";
@query("wa-textarea")
private _textarea?: WaTextarea;
private readonly _hasSlotController = new HasSlotController(
this,
"label",
"hint"
);
protected get _formControl(): WaTextarea | undefined {
return this._textarea;
}
protected readonly _requiredMarkerCSSVar = "--ha-textarea-required-marker";
/** Programmatically toggle focus styling (used by ha-date-range-picker). */
public setFocused(focused: boolean): void {
if (focused) {
this.toggleAttribute("focused", true);
} else {
this.removeAttribute("focused");
updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (this.autogrow && changedProperties.has("value")) {
this.mdcRoot.dataset.value = this.value + '=\u200B"'; // add a zero-width space to correctly wrap
}
}
protected render() {
const hasLabelSlot = this.label
? false
: this._hasSlotController.test("label");
const hasHintSlot = this.hint
? false
: this._hasSlotController.test("hint");
return html`
<wa-textarea
.value=${this.value ?? null}
.placeholder=${this.placeholder}
.readonly=${this.readonly}
.required=${this.required}
.rows=${this.rows ?? 4}
.resize=${this.resize}
.disabled=${this.disabled}
name=${ifDefined(this.name)}
autocapitalize=${ifDefined(this.autocapitalize || undefined)}
autocomplete=${ifDefined(this.autocomplete)}
.autofocus=${this.autofocus}
.spellcheck=${this.spellcheck}
inputmode=${ifDefined(this.inputmode || undefined)}
enterkeyhint=${ifDefined(this.enterkeyhint || undefined)}
minlength=${ifDefined(this.minlength)}
maxlength=${ifDefined(this.maxlength)}
class=${classMap({
input: true,
invalid: this.invalid || this._invalid,
"label-raised":
(this.value !== undefined && this.value !== "") ||
(this.label && this.placeholder),
"no-label": !this.label,
"hint-hidden":
!this.hint &&
!hasHintSlot &&
!this.required &&
!this._invalid &&
!this.invalid,
})}
@input=${this._handleInput}
@change=${this._handleChange}
@blur=${this._handleBlur}
@wa-invalid=${this._handleInvalid}
exportparts="base:wa-base, hint:wa-hint, textarea:wa-textarea"
>
${this.label || hasLabelSlot
? html`<slot name="label" slot="label"
>${this.label
? this._renderLabel(this.label, this.required)
: nothing}</slot
>`
: nothing}
<div
slot="hint"
class=${classMap({
error: this.invalid || this._invalid,
})}
role=${ifDefined(this.invalid || this._invalid ? "alert" : undefined)}
aria-live="polite"
>
${this._invalid || this.invalid
? this.validationMessage || this._textarea?.validationMessage
: this.hint ||
(hasHintSlot ? html`<slot name="hint"></slot>` : nothing)}
</div>
</wa-textarea>
`;
}
static styles = [
waInputStyles,
static override styles = [
textfieldStyles,
textareaStyles,
css`
:host {
display: flex;
align-items: flex-start;
padding-bottom: var(--ha-textarea-padding-bottom);
--mdc-text-field-fill-color: var(--ha-color-form-background);
}
/* Label styling */
wa-textarea::part(label) {
width: calc(100% - var(--ha-space-2));
background-color: var(--ha-color-form-background);
transition:
all var(--wa-transition-normal) ease-in-out,
background-color var(--wa-transition-normal) ease-in-out;
padding-inline-start: var(--ha-space-3);
padding-inline-end: var(--ha-space-3);
margin: var(--ha-space-1) var(--ha-space-1) 0;
padding-top: var(--ha-space-4);
white-space: nowrap;
overflow: hidden;
:host([autogrow]) .mdc-text-field {
position: relative;
min-height: 74px;
min-width: 178px;
max-height: 200px;
}
:host(:focus-within) wa-textarea::part(label),
:host([focused]) wa-textarea::part(label) {
color: var(--primary-color);
:host([autogrow]) .mdc-text-field:after {
content: attr(data-value);
margin-top: 23px;
margin-bottom: 9px;
line-height: var(--ha-line-height-normal);
min-height: 42px;
padding: 0px 32px 0 16px;
letter-spacing: var(
--mdc-typography-subtitle1-letter-spacing,
0.009375em
);
visibility: hidden;
white-space: pre-wrap;
}
wa-textarea.label-raised::part(label),
:host(:focus-within) wa-textarea::part(label),
:host([focused]) wa-textarea::part(label) {
padding-top: var(--ha-space-2);
font-size: var(--ha-font-size-xs);
}
wa-textarea.no-label::part(label) {
height: 0;
padding: 0;
}
/* Base styling */
wa-textarea::part(base) {
min-height: 56px;
padding-top: var(--ha-space-6);
padding-bottom: var(--ha-space-2);
}
wa-textarea.no-label::part(base) {
padding-top: var(--ha-space-3);
}
wa-textarea::part(base)::after {
content: "";
:host([autogrow]) .mdc-text-field__input {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background-color: var(--ha-color-border-neutral-loud);
transition:
height var(--wa-transition-normal) ease-in-out,
background-color var(--wa-transition-normal) ease-in-out;
height: calc(100% - 32px);
}
:host(:focus-within) wa-textarea::part(base)::after,
:host([focused]) wa-textarea::part(base)::after {
height: 2px;
background-color: var(--primary-color);
:host([autogrow]) .mdc-text-field.mdc-text-field--no-label:after {
margin-top: 16px;
margin-bottom: 16px;
}
:host(:focus-within) wa-textarea.invalid::part(base)::after,
wa-textarea.invalid:not([disabled])::part(base)::after {
background-color: var(--ha-color-border-danger-normal);
.mdc-floating-label {
inset-inline-start: 16px !important;
inset-inline-end: initial !important;
transform-origin: var(--float-start) top;
}
/* Textarea element styling */
wa-textarea::part(textarea) {
padding: 0 var(--ha-space-4);
font-family: var(--ha-font-family-body);
font-size: var(--ha-font-size-m);
}
:host([resize="auto"]) wa-textarea::part(textarea) {
max-height: var(--ha-textarea-max-height, 200px);
overflow-y: auto;
}
wa-textarea:hover::part(base),
wa-textarea:hover::part(label) {
background-color: var(--ha-color-form-background-hover);
}
wa-textarea[disabled]::part(textarea) {
cursor: not-allowed;
}
wa-textarea[disabled]::part(base),
wa-textarea[disabled]::part(label) {
background-color: var(--ha-color-form-background-disabled);
@media only screen and (min-width: 459px) {
:host([mobile-multiline]) .mdc-text-field__input {
white-space: nowrap;
max-height: 16px;
}
}
`,
];
@@ -5,7 +5,6 @@ import {
import { supportsPassiveEventListener } from "@material/mwc-base/utils";
import type { MDCTopAppBarAdapter } from "@material/top-app-bar/adapter";
import { strings } from "@material/top-app-bar/constants";
// eslint-disable-next-line import-x/no-named-as-default
import MDCFixedTopAppBarFoundation from "@material/top-app-bar/fixed/foundation";
import { html, css, nothing } from "lit";
import { property, query, customElement } from "lit/decorators";
+359 -119
View File
@@ -11,14 +11,15 @@ import {
html,
nothing,
} from "lit";
import { customElement, property, query } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import { stopPropagation } from "../../common/dom/stop_propagation";
import "../ha-icon-button";
import "../ha-svg-icon";
import "../ha-tooltip";
import { WaInputMixin, waInputStyles } from "./wa-input-mixin";
import { nativeElementInternalsSupported } from "../../common/feature-detect/support-native-element-internals";
export type InputType =
| "date"
@@ -77,16 +78,35 @@ export type InputType =
* @attr {string} validation-message - Custom validation message shown when the input is invalid.
*/
@customElement("ha-input")
export class HaInput extends WaInputMixin(LitElement) {
export class HaInput extends LitElement {
@property({ reflect: true }) appearance: "material" | "outlined" = "material";
@property({ reflect: true })
public type: InputType = "text";
@property()
public value?: string;
/** The input's label. */
@property()
public label = "";
/** The input's hint. */
@property()
public hint? = "";
/** Adds a clear button when the input is not empty. */
@property({ type: Boolean, attribute: "with-clear" })
public withClear = false;
/** Placeholder text to show as a hint when the input is empty. */
@property()
public placeholder = "";
/** Makes the input readonly. */
@property({ type: Boolean })
public readonly = false;
/** Adds a button to toggle the password's visibility. */
@property({ type: Boolean, attribute: "password-toggle" })
public passwordToggle = false;
@@ -99,10 +119,22 @@ export class HaInput extends WaInputMixin(LitElement) {
@property({ type: Boolean, attribute: "without-spin-buttons" })
public withoutSpinButtons = false;
/** Makes the input a required field. */
@property({ type: Boolean, reflect: true })
public required = false;
/** A regular expression pattern to validate input against. */
@property()
public pattern?: string;
/** The minimum length of input that will be considered valid. */
@property({ type: Number })
public minlength?: number;
/** The maximum length of input that will be considered valid. */
@property({ type: Number })
public maxlength?: number;
/** The input's minimum value. Only applies to date and number input types. */
@property()
public min?: number | string;
@@ -115,13 +147,88 @@ export class HaInput extends WaInputMixin(LitElement) {
@property()
public step?: number | "any";
/** Controls whether and how text input is automatically capitalized. */
@property()
// eslint-disable-next-line lit/no-native-attributes
public autocapitalize:
| "off"
| "none"
| "on"
| "sentences"
| "words"
| "characters"
| "" = "";
/** Indicates whether the browser's autocorrect feature is on or off. */
@property({ type: Boolean })
public autocorrect = false;
/** Specifies what permission the browser has to provide assistance in filling out form field values. */
@property()
public autocomplete?: string;
/** Indicates that the input should receive focus on page load. */
@property({ type: Boolean })
// eslint-disable-next-line lit/no-native-attributes
public autofocus = false;
/** Used to customize the label or icon of the Enter key on virtual keyboards. */
@property()
// eslint-disable-next-line lit/no-native-attributes
public enterkeyhint:
| "enter"
| "done"
| "go"
| "next"
| "previous"
| "search"
| "send"
| "" = "";
/** Enables spell checking on the input. */
@property({ type: Boolean })
// eslint-disable-next-line lit/no-native-attributes
public spellcheck = true;
/** Tells the browser what type of data will be entered by the user. */
@property()
// eslint-disable-next-line lit/no-native-attributes
public inputmode:
| "none"
| "text"
| "decimal"
| "numeric"
| "tel"
| "search"
| "email"
| "url"
| "" = "";
/** The name of the input, submitted as a name/value pair with form data. */
@property()
public name?: string;
/** Disables the form control. */
@property({ type: Boolean })
public disabled = false;
/** Custom validation message to show when the input is invalid. */
@property({ attribute: "validation-message" })
public validationMessage? = "";
/** When true, validates the input on blur instead of on form submit. */
@property({ type: Boolean, attribute: "auto-validate" })
public autoValidate = false;
@property({ type: Boolean })
public invalid = false;
@property({ type: Boolean, attribute: "inset-label" })
public insetLabel = false;
@state()
private _invalid = false;
@query("wa-input")
private _input?: WaInput;
@@ -133,8 +240,37 @@ export class HaInput extends WaInputMixin(LitElement) {
"start"
);
protected get _formControl(): WaInput | undefined {
return this._input;
static shadowRootOptions: ShadowRootInit = {
mode: "open",
delegatesFocus: true,
};
/** Selects all the text in the input. */
public select(): void {
this._input?.select();
}
/** Sets the start and end positions of the text selection (0-based). */
public setSelectionRange(
selectionStart: number,
selectionEnd: number,
selectionDirection?: "forward" | "backward" | "none"
): void {
this._input?.setSelectionRange(
selectionStart,
selectionEnd,
selectionDirection
);
}
/** Replaces a range of text with a new string. */
public setRangeText(
replacement: string,
start?: number,
end?: number,
selectMode?: "select" | "start" | "end" | "preserve"
): void {
this._input?.setRangeText(replacement, start, end, selectMode);
}
/** Displays the browser picker for an input element. */
@@ -152,6 +288,19 @@ export class HaInput extends WaInputMixin(LitElement) {
this._input?.stepDown();
}
public checkValidity(): boolean {
return nativeElementInternalsSupported
? (this._input?.checkValidity() ?? true)
: true;
}
public reportValidity(): boolean {
const valid = this.checkValidity();
this._invalid = !valid;
return valid;
}
protected override async firstUpdated(
changedProperties: PropertyValues
): Promise<void> {
@@ -202,7 +351,6 @@ export class HaInput extends WaInputMixin(LitElement) {
.name=${this.name}
.disabled=${this.disabled}
class=${classMap({
input: true,
invalid: this.invalid || this._invalid,
"label-raised":
(this.value !== undefined && this.value !== "") ||
@@ -224,9 +372,7 @@ export class HaInput extends WaInputMixin(LitElement) {
>
${this.label || hasLabelSlot
? html`<slot name="label" slot="label"
>${this.label
? this._renderLabel(this.label, this.required)
: nothing}</slot
>${this._renderLabel(this.label, this.required)}</slot
>`
: nothing}
<slot name="start" slot="start" @slotchange=${this._syncStartSlotWidth}>
@@ -273,6 +419,27 @@ export class HaInput extends WaInputMixin(LitElement) {
return nothing;
}
private _handleInput() {
this.value = this._input?.value ?? undefined;
if (this._invalid && this._input?.checkValidity()) {
this._invalid = false;
}
}
private _handleChange() {
this.value = this._input?.value ?? undefined;
}
private _handleBlur() {
if (this.autoValidate) {
this._invalid = !this._input?.checkValidity();
}
}
private _handleInvalid() {
this._invalid = true;
}
private _syncStartSlotWidth = () => {
const startEl = this._input?.shadowRoot?.querySelector(
'[part~="start"]'
@@ -293,133 +460,206 @@ export class HaInput extends WaInputMixin(LitElement) {
}
};
static styles = [
waInputStyles,
css`
:host {
display: flex;
align-items: flex-start;
padding-top: var(--ha-input-padding-top);
padding-bottom: var(--ha-input-padding-bottom, var(--ha-space-2));
text-align: var(--ha-input-text-align, start);
}
:host([appearance="outlined"]) {
padding-bottom: var(--ha-input-padding-bottom);
}
private _renderLabel = memoizeOne((label: string, required: boolean) => {
if (!required) {
return label;
}
wa-input::part(label) {
padding-inline-start: calc(
var(--start-slot-width, 0px) + var(--ha-space-4)
);
padding-inline-end: var(--ha-space-4);
padding-top: var(--ha-space-5);
}
let marker = getComputedStyle(this).getPropertyValue(
"--ha-input-required-marker"
);
:host([appearance="material"]:focus-within) wa-input::part(label) {
color: var(--primary-color);
}
if (!marker) {
marker = "*";
}
wa-input.label-raised::part(label),
:host(:focus-within) wa-input::part(label),
:host([type="date"]) wa-input::part(label) {
padding-top: var(--ha-space-3);
font-size: var(--ha-font-size-xs);
}
if (marker.startsWith('"') && marker.endsWith('"')) {
marker = marker.slice(1, -1);
}
wa-input::part(base) {
height: 56px;
padding: 0 var(--ha-space-4);
}
if (!marker) {
return label;
}
:host([appearance="outlined"]) wa-input.no-label::part(base) {
height: 32px;
padding: 0 var(--ha-space-2);
}
return `${label}${marker}`;
});
:host([appearance="outlined"]) wa-input::part(base) {
border: 1px solid var(--ha-color-border-neutral-quiet);
background-color: var(--card-background-color);
border-radius: var(--ha-border-radius-md);
transition: border-color var(--wa-transition-normal) ease-in-out;
}
static styles = css`
:host {
display: flex;
align-items: flex-start;
padding-top: var(--ha-input-padding-top);
padding-bottom: var(--ha-input-padding-bottom, var(--ha-space-2));
text-align: var(--ha-input-text-align, start);
}
:host([appearance="outlined"]) {
padding-bottom: var(--ha-input-padding-bottom);
}
wa-input {
flex: 1;
min-width: 0;
--wa-transition-fast: var(--wa-transition-normal);
position: relative;
}
:host([appearance="material"]) ::part(base)::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background-color: var(--ha-color-border-neutral-loud);
transition:
height var(--wa-transition-normal) ease-in-out,
background-color var(--wa-transition-normal) ease-in-out;
}
wa-input::part(label) {
position: absolute;
top: 0;
font-weight: var(--ha-font-weight-normal);
font-family: var(--ha-font-family-body);
transition: all var(--wa-transition-normal) ease-in-out;
color: var(--secondary-text-color);
line-height: var(--ha-line-height-condensed);
z-index: 1;
pointer-events: none;
padding-inline-start: calc(
var(--start-slot-width, 0px) + var(--ha-space-4)
);
padding-inline-end: var(--ha-space-4);
padding-top: var(--ha-space-5);
font-size: var(--ha-font-size-m);
}
:host([appearance="material"]:focus-within) wa-input::part(base)::after {
height: 2px;
background-color: var(--primary-color);
}
:host([appearance="material"]:focus-within) wa-input::part(label) {
color: var(--primary-color);
}
:host([appearance="material"]:focus-within)
wa-input.invalid::part(base)::after,
:host([appearance="material"])
wa-input.invalid:not([disabled])::part(base)::after {
background-color: var(--ha-color-border-danger-normal);
}
:host(:focus-within) wa-input.invalid::part(label),
wa-input.invalid:not([disabled])::part(label) {
color: var(--ha-color-fill-danger-loud-resting);
}
wa-input::part(input) {
padding-top: var(--ha-space-3);
padding-inline-start: var(--input-padding-inline-start, 0);
}
wa-input.label-raised::part(label),
:host(:focus-within) wa-input::part(label),
:host([type="date"]) wa-input::part(label) {
padding-top: var(--ha-space-3);
font-size: var(--ha-font-size-xs);
}
wa-input.no-label::part(input) {
padding-top: 0;
}
:host([type="color"]) wa-input::part(input) {
padding-top: var(--ha-space-6);
padding-bottom: 2px;
cursor: pointer;
}
:host([type="color"]) wa-input.no-label::part(input) {
padding: var(--ha-space-2);
}
:host([type="color"]) wa-input.no-label::part(base) {
padding: 0;
}
wa-input::part(input)::placeholder {
color: var(--ha-color-neutral-60);
}
wa-input::part(base) {
height: 56px;
background-color: var(--ha-color-form-background);
border-top-left-radius: var(--ha-border-radius-sm);
border-top-right-radius: var(--ha-border-radius-sm);
border-bottom-left-radius: var(--ha-border-radius-square);
border-bottom-right-radius: var(--ha-border-radius-square);
border: none;
padding: 0 var(--ha-space-4);
position: relative;
transition: background-color var(--wa-transition-normal) ease-in-out;
}
wa-input::part(base):hover {
background-color: var(--ha-color-form-background-hover);
}
:host([appearance="outlined"]) wa-input.no-label::part(base) {
height: 32px;
padding: 0 var(--ha-space-2);
}
:host([appearance="outlined"]) wa-input::part(base):hover {
border-color: var(--ha-color-border-neutral-normal);
}
:host([appearance="outlined"]:focus-within) wa-input::part(base) {
border-color: var(--primary-color);
}
:host([appearance="outlined"]) wa-input::part(base) {
border: 1px solid var(--ha-color-border-neutral-quiet);
background-color: var(--card-background-color);
border-radius: var(--ha-border-radius-md);
transition: border-color var(--wa-transition-normal) ease-in-out;
}
wa-input:disabled::part(base) {
background-color: var(--ha-color-form-background-disabled);
}
:host([appearance="material"]) ::part(base)::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background-color: var(--ha-color-border-neutral-loud);
transition:
height var(--wa-transition-normal) ease-in-out,
background-color var(--wa-transition-normal) ease-in-out;
}
wa-input:disabled::part(label) {
opacity: 0.5;
}
:host([appearance="material"]:focus-within) wa-input::part(base)::after {
height: 2px;
background-color: var(--primary-color);
}
wa-input::part(end) {
color: var(--ha-color-text-secondary);
}
:host([appearance="material"]:focus-within)
wa-input.invalid::part(base)::after,
:host([appearance="material"])
wa-input.invalid:not([disabled])::part(base)::after {
background-color: var(--ha-color-border-danger-normal);
}
:host([appearance="outlined"]) wa-input.no-label {
--ha-icon-button-size: 24px;
--mdc-icon-size: 18px;
}
`,
];
wa-input::part(input) {
padding-top: var(--ha-space-3);
padding-inline-start: var(--input-padding-inline-start, 0);
}
wa-input.no-label::part(input) {
padding-top: 0;
}
:host([type="color"]) wa-input::part(input) {
padding-top: var(--ha-space-6);
padding-bottom: 2px;
cursor: pointer;
}
:host([type="color"]) wa-input.no-label::part(input) {
padding: var(--ha-space-2);
}
:host([type="color"]) wa-input.no-label::part(base) {
padding: 0;
}
wa-input::part(input)::placeholder {
color: var(--ha-color-neutral-60);
}
:host(:focus-within) wa-input::part(base) {
outline: none;
}
wa-input::part(base):hover {
background-color: var(--ha-color-form-background-hover);
}
:host([appearance="outlined"]) wa-input::part(base):hover {
border-color: var(--ha-color-border-neutral-normal);
}
:host([appearance="outlined"]:focus-within) wa-input::part(base) {
border-color: var(--primary-color);
}
wa-input:disabled::part(base) {
background-color: var(--ha-color-form-background-disabled);
}
wa-input:disabled::part(label) {
opacity: 0.5;
}
wa-input::part(hint) {
min-height: var(--ha-space-5);
margin-block-start: 0;
margin-inline-start: var(--ha-space-3);
font-size: var(--ha-font-size-xs);
display: flex;
align-items: center;
color: var(--ha-color-text-secondary);
}
wa-input.hint-hidden::part(hint) {
height: 0;
min-height: 0;
}
.error {
color: var(--ha-color-on-danger-quiet);
}
wa-input::part(end) {
color: var(--ha-color-text-secondary);
}
:host([appearance="outlined"]) wa-input.no-label {
--ha-icon-button-size: 24px;
--mdc-icon-size: 18px;
}
`;
}
declare global {
-357
View File
@@ -1,357 +0,0 @@
import { type LitElement, css } from "lit";
import { property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { Constructor } from "../../types";
import { nativeElementInternalsSupported } from "../../common/feature-detect/support-native-element-internals";
/**
* Minimal interface for the inner wa-input / wa-textarea element.
*/
export interface WaInput {
value: string | null;
select(): void;
setSelectionRange(
start: number,
end: number,
direction?: "forward" | "backward" | "none"
): void;
setRangeText(
replacement: string,
start?: number,
end?: number,
selectMode?: "select" | "start" | "end" | "preserve"
): void;
checkValidity(): boolean;
validationMessage: string;
}
export interface WaInputMixinInterface {
value?: string;
label: string;
hint?: string;
placeholder: string;
readonly: boolean;
required: boolean;
minlength?: number;
maxlength?: number;
autocapitalize:
| "off"
| "none"
| "on"
| "sentences"
| "words"
| "characters"
| "";
autocomplete?: string;
autofocus: boolean;
spellcheck: boolean;
inputmode:
| "none"
| "text"
| "decimal"
| "numeric"
| "tel"
| "search"
| "email"
| "url"
| "";
enterkeyhint:
| "enter"
| "done"
| "go"
| "next"
| "previous"
| "search"
| "send"
| "";
name?: string;
disabled: boolean;
validationMessage?: string;
autoValidate: boolean;
invalid: boolean;
select(): void;
setSelectionRange(
start: number,
end: number,
direction?: "forward" | "backward" | "none"
): void;
setRangeText(
replacement: string,
start?: number,
end?: number,
selectMode?: "select" | "start" | "end" | "preserve"
): void;
checkValidity(): boolean;
reportValidity(): boolean;
}
export const WaInputMixin = <T extends Constructor<LitElement>>(
superClass: T
) => {
class FormControlMixinClass extends superClass {
@property()
public value?: string;
@property()
public label? = "";
@property()
public hint? = "";
@property()
public placeholder? = "";
@property({ type: Boolean })
public readonly = false;
@property({ type: Boolean, reflect: true })
public required = false;
@property({ type: Number })
public minlength?: number;
@property({ type: Number })
public maxlength?: number;
@property()
// eslint-disable-next-line lit/no-native-attributes
public autocapitalize:
| "off"
| "none"
| "on"
| "sentences"
| "words"
| "characters"
| "" = "";
@property()
public autocomplete?: string;
@property({ type: Boolean })
// eslint-disable-next-line lit/no-native-attributes
public autofocus = false;
@property({ type: Boolean })
// eslint-disable-next-line lit/no-native-attributes
public spellcheck = true;
@property()
// eslint-disable-next-line lit/no-native-attributes
public inputmode:
| "none"
| "text"
| "decimal"
| "numeric"
| "tel"
| "search"
| "email"
| "url"
| "" = "";
@property()
// eslint-disable-next-line lit/no-native-attributes
public enterkeyhint:
| "enter"
| "done"
| "go"
| "next"
| "previous"
| "search"
| "send"
| "" = "";
@property()
public name?: string;
@property({ type: Boolean })
public disabled = false;
@property({ attribute: "validation-message" })
public validationMessage? = "";
@property({ type: Boolean, attribute: "auto-validate" })
public autoValidate = false;
@property({ type: Boolean })
public invalid = false;
@state()
protected _invalid = false;
static shadowRootOptions: ShadowRootInit = {
mode: "open",
delegatesFocus: true,
};
/**
* Override in subclass to return the inner wa-input / wa-textarea element.
*/
protected get _formControl(): WaInput | undefined {
throw new Error("_formControl getter must be implemented by subclass");
}
/**
* Override in subclass to set the CSS custom property name
* used for the required-marker character (e.g. "--ha-input-required-marker").
*/
protected readonly _requiredMarkerCSSVar: string =
"--ha-input-required-marker";
public select(): void {
this._formControl?.select();
}
public setSelectionRange(
selectionStart: number,
selectionEnd: number,
selectionDirection?: "forward" | "backward" | "none"
): void {
this._formControl?.setSelectionRange(
selectionStart,
selectionEnd,
selectionDirection
);
}
public setRangeText(
replacement: string,
start?: number,
end?: number,
selectMode?: "select" | "start" | "end" | "preserve"
): void {
this._formControl?.setRangeText(replacement, start, end, selectMode);
}
public checkValidity(): boolean {
return nativeElementInternalsSupported
? (this._formControl?.checkValidity() ?? true)
: true;
}
public reportValidity(): boolean {
const valid = this.checkValidity();
this._invalid = !valid;
return valid;
}
protected _handleInput(): void {
this.value = this._formControl?.value ?? undefined;
if (this._invalid && this._formControl?.checkValidity()) {
this._invalid = false;
}
}
protected _handleChange(): void {
this.value = this._formControl?.value ?? undefined;
}
protected _handleBlur(): void {
if (this.autoValidate) {
this._invalid = !this._formControl?.checkValidity();
}
}
protected _handleInvalid(): void {
this._invalid = true;
}
protected _renderLabel = memoizeOne((label: string, required: boolean) => {
if (!required) {
return label;
}
let marker = getComputedStyle(this).getPropertyValue(
this._requiredMarkerCSSVar
);
if (!marker) {
marker = "*";
}
if (marker.startsWith('"') && marker.endsWith('"')) {
marker = marker.slice(1, -1);
}
if (!marker) {
return label;
}
return `${label}${marker}`;
});
}
return FormControlMixinClass;
};
/**
* Shared styles for form controls (ha-input / ha-textarea).
* Both components add the `control` CSS class to the inner wa-input / wa-textarea
* element so these rules can target them with a single selector.
*/
export const waInputStyles = css`
/* Inner element reset */
.input {
flex: 1;
min-width: 0;
--wa-transition-fast: var(--wa-transition-normal);
position: relative;
}
/* Label base */
.input::part(label) {
position: absolute;
top: 0;
font-weight: var(--ha-font-weight-normal);
font-family: var(--ha-font-family-body);
transition: all var(--wa-transition-normal) ease-in-out;
color: var(--secondary-text-color);
line-height: var(--ha-line-height-condensed);
z-index: 1;
pointer-events: none;
font-size: var(--ha-font-size-m);
}
/* Invalid label */
:host(:focus-within) .input.invalid::part(label),
.input.invalid:not([disabled])::part(label) {
color: var(--ha-color-fill-danger-loud-resting);
}
/* Base common */
.input::part(base) {
background-color: var(--ha-color-form-background);
border-top-left-radius: var(--ha-border-radius-sm);
border-top-right-radius: var(--ha-border-radius-sm);
border-bottom-left-radius: var(--ha-border-radius-square);
border-bottom-right-radius: var(--ha-border-radius-square);
border: none;
position: relative;
transition: background-color var(--wa-transition-normal) ease-in-out;
}
/* Focus outline removal */
:host(:focus-within) .input::part(base) {
outline: none;
}
/* Hint */
.input::part(hint) {
min-height: var(--ha-space-5);
margin-block-start: 0;
margin-inline-start: var(--ha-space-3);
font-size: var(--ha-font-size-xs);
display: flex;
align-items: center;
color: var(--ha-color-text-secondary);
}
.input.hint-hidden::part(hint) {
height: 0;
min-height: 0;
}
/* Error hint text */
.error {
color: var(--ha-color-on-danger-quiet);
}
`;
@@ -58,7 +58,7 @@ class BrowseMediaTTS extends LitElement {
<ha-card>
<div class="card-content">
<ha-textarea
resize="auto"
autogrow
.label=${this.hass.localize(
"ui.components.media-browser.tts.message"
)}
@@ -200,7 +200,7 @@ class BrowseMediaTTS extends LitElement {
}
private async _ttsClicked(): Promise<void> {
const message = this.shadowRoot!.querySelector("ha-textarea")!.value ?? "";
const message = this.shadowRoot!.querySelector("ha-textarea")!.value;
this._message = message;
const item = { ...this.item };
const query = new URLSearchParams();
+44 -95
View File
@@ -60,9 +60,6 @@ export const enum CalendarEntityFeature {
UPDATE_EVENT = 4,
}
/** Type for date values that can come from REST API or subscription */
type CalendarDateValue = string | { dateTime: string } | { date: string };
export const fetchCalendarEvents = async (
hass: HomeAssistant,
start: Date,
@@ -75,11 +72,11 @@ export const fetchCalendarEvents = async (
const calEvents: CalendarEvent[] = [];
const errors: string[] = [];
const promises: Promise<CalendarEventApiData[]>[] = [];
const promises: Promise<CalendarEvent[]>[] = [];
calendars.forEach((cal) => {
promises.push(
hass.callApi<CalendarEventApiData[]>(
hass.callApi<CalendarEvent[]>(
"GET",
`calendars/${cal.entity_id}${params}`
)
@@ -87,7 +84,7 @@ export const fetchCalendarEvents = async (
});
for (const [idx, promise] of promises.entries()) {
let result: CalendarEventApiData[];
let result: CalendarEvent[];
try {
// eslint-disable-next-line no-await-in-loop
result = await promise;
@@ -97,16 +94,54 @@ export const fetchCalendarEvents = async (
}
const cal = calendars[idx];
result.forEach((ev) => {
const normalized = normalizeSubscriptionEventData(ev, cal);
if (normalized) {
calEvents.push(normalized);
const eventStart = getCalendarDate(ev.start);
const eventEnd = getCalendarDate(ev.end);
if (!eventStart || !eventEnd) {
return;
}
const eventData: CalendarEventData = {
uid: ev.uid,
summary: ev.summary,
description: ev.description,
location: ev.location,
dtstart: eventStart,
dtend: eventEnd,
recurrence_id: ev.recurrence_id,
rrule: ev.rrule,
};
const event: CalendarEvent = {
start: eventStart,
end: eventEnd,
title: ev.summary,
backgroundColor: cal.backgroundColor,
borderColor: cal.backgroundColor,
calendar: cal.entity_id,
eventData: eventData,
};
calEvents.push(event);
});
}
return { events: calEvents, errors };
};
const getCalendarDate = (dateObj: any): string | undefined => {
if (typeof dateObj === "string") {
return dateObj;
}
if (dateObj.dateTime) {
return dateObj.dateTime;
}
if (dateObj.date) {
return dateObj.date;
}
return undefined;
};
export const getCalendars = (
hass: HomeAssistant,
element: Element,
@@ -185,89 +220,3 @@ export const deleteCalendarEvent = (
recurrence_id,
recurrence_range,
});
/**
* Calendar event data from both REST API and WebSocket subscription.
* Both APIs use the same data format.
*/
export interface CalendarEventApiData {
summary: string;
start: CalendarDateValue;
end: CalendarDateValue;
description?: string | null;
location?: string | null;
uid?: string | null;
recurrence_id?: string | null;
rrule?: string | null;
}
export interface CalendarEventSubscription {
events: CalendarEventApiData[] | null;
}
export const subscribeCalendarEvents = (
hass: HomeAssistant,
entity_id: string,
start: Date,
end: Date,
callback: (update: CalendarEventSubscription) => void
) =>
hass.connection.subscribeMessage<CalendarEventSubscription>(callback, {
type: "calendar/event/subscribe",
entity_id,
start: start.toISOString(),
end: end.toISOString(),
});
const getCalendarDate = (dateObj: CalendarDateValue): string | undefined => {
if (typeof dateObj === "string") {
return dateObj;
}
if ("dateTime" in dateObj) {
return dateObj.dateTime;
}
if ("date" in dateObj) {
return dateObj.date;
}
return undefined;
};
/**
* Normalize calendar event data from API format to internal format.
* Handles both REST API format (with dateTime/date objects) and subscription format (strings).
* Converts to internal format with { dtstart, dtend, ... }
*/
export const normalizeSubscriptionEventData = (
eventData: CalendarEventApiData,
calendar: Calendar
): CalendarEvent | null => {
const eventStart = getCalendarDate(eventData.start);
const eventEnd = getCalendarDate(eventData.end);
if (!eventStart || !eventEnd) {
return null;
}
const normalizedEventData: CalendarEventData = {
summary: eventData.summary,
dtstart: eventStart,
dtend: eventEnd,
description: eventData.description ?? undefined,
uid: eventData.uid ?? undefined,
recurrence_id: eventData.recurrence_id ?? undefined,
rrule: eventData.rrule ?? undefined,
};
return {
start: eventStart,
end: eventEnd,
title: eventData.summary,
backgroundColor: calendar.backgroundColor,
borderColor: calendar.backgroundColor,
calendar: calendar.entity_id,
eventData: normalizedEventData,
};
};
-1
View File
@@ -458,7 +458,6 @@ export interface StateSelector {
attribute?: string;
hide_states?: string[];
multiple?: boolean;
no_entity?: boolean;
} | null;
}
@@ -516,7 +516,7 @@ class DataEntryFlowDialog extends LitElement {
}
} else if (_step.next_flow[0] === "config_subentries_flow") {
if (_step.type === "create_entry") {
showSubConfigFlowDialog(this, _step.result!, "", {
showSubConfigFlowDialog(this, _step.result!, _step.next_flow[0], {
continueFlowId: _step.next_flow[1],
navigateToResult: this._params!.navigateToResult,
dialogClosedCallback: this._params!.dialogClosedCallback,
@@ -99,16 +99,6 @@ export const showConfigFlowDialog = (
);
}
if (field.type === "tabs" && options?.tab) {
const sectionPrefix = field.name ? `sections.${field.name}.` : "";
return (
hass.localize(
`component.${step.handler}.config.step.${step.step_id}.${sectionPrefix}tabs.${options.tab}.name`,
step.description_placeholders
) || options.tab
);
}
const prefix = options?.path?.[0] ? `sections.${options.path[0]}.` : "";
return (
@@ -127,17 +117,6 @@ export const showConfigFlowDialog = (
);
}
if (field.type === "tabs" && options?.tab) {
const sectionPrefix = field.name ? `sections.${field.name}.` : "";
const tabDescription = hass.localize(
`component.${step.translation_domain || step.handler}.config.step.${step.step_id}.${sectionPrefix}tabs.${options.tab}.description`,
step.description_placeholders
);
return tabDescription
? html`<ha-markdown breaks .content=${tabDescription}></ha-markdown>`
: "";
}
const prefix = options?.path?.[0] ? `sections.${options.path[0]}.` : "";
const description = hass.localize(
@@ -62,14 +62,14 @@ export interface FlowConfig {
hass: HomeAssistant,
step: DataEntryFlowStepForm,
field: HaFormSchema,
options: { path?: string[]; tab?: string; [key: string]: any }
options: { path?: string[]; [key: string]: any }
): string;
renderShowFormStepFieldHelper(
hass: HomeAssistant,
step: DataEntryFlowStepForm,
field: HaFormSchema,
options: { path?: string[]; tab?: string; [key: string]: any }
options: { path?: string[]; [key: string]: any }
): TemplateResult | string;
renderShowFormStepFieldError(
@@ -103,16 +103,6 @@ export const showOptionsFlowDialog = (
);
}
if (field.type === "tabs" && options?.tab) {
const sectionPrefix = field.name ? `sections.${field.name}.` : "";
return (
hass.localize(
`component.${configEntry.domain}.options.step.${step.step_id}.${sectionPrefix}tabs.${options.tab}.name`,
step.description_placeholders
) || options.tab
);
}
const prefix = options?.path?.[0] ? `sections.${options.path[0]}.` : "";
return (
@@ -131,20 +121,6 @@ export const showOptionsFlowDialog = (
);
}
if (field.type === "tabs" && options?.tab) {
const sectionPrefix = field.name ? `sections.${field.name}.` : "";
const tabDescription = hass.localize(
`component.${step.translation_domain || configEntry.domain}.options.step.${step.step_id}.${sectionPrefix}tabs.${options.tab}.description`,
step.description_placeholders
);
return tabDescription
? html`<ha-markdown
breaks
.content=${tabDescription}
></ha-markdown>`
: "";
}
const prefix = options?.path?.[0] ? `sections.${options.path[0]}.` : "";
const description = hass.localize(
@@ -39,7 +39,6 @@ export const showSubConfigFlowDialog = (
},
fetchFlow: async (hass, flowId) => {
const step = await fetchSubConfigFlow(hass, flowId);
flowType = (step.handler as unknown as [string, string])[1];
await hass.loadFragmentTranslation("config");
await hass.loadBackendTranslation(
"config_subentries",
@@ -95,16 +94,6 @@ export const showSubConfigFlowDialog = (
);
}
if (field.type === "tabs" && options?.tab) {
const sectionPrefix = field.name ? `sections.${field.name}.` : "";
return (
hass.localize(
`component.${configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.${sectionPrefix}tabs.${options.tab}.name`,
step.description_placeholders
) || options.tab
);
}
const prefix = options?.path?.[0] ? `sections.${options.path[0]}.` : "";
return (
@@ -123,17 +112,6 @@ export const showSubConfigFlowDialog = (
);
}
if (field.type === "tabs" && options?.tab) {
const sectionPrefix = field.name ? `sections.${field.name}.` : "";
const tabDescription = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.${sectionPrefix}tabs.${options.tab}.description`,
step.description_placeholders
);
return tabDescription
? html`<ha-markdown breaks .content=${tabDescription}></ha-markdown>`
: "";
}
const prefix = options?.path?.[0] ? `sections.${options.path[0]}.` : "";
const description = hass.localize(
+1 -11
View File
@@ -75,17 +75,7 @@ class StepFlowForm extends LitElement {
handleReadOnlyField(sectionField)
),
}
: field.type === "tabs" && field.tabs
? {
...field,
tabs: field.tabs.map((tab) => ({
...tab,
schema: tab.schema.map((tabField) =>
handleReadOnlyField(tabField)
),
})),
}
: handleReadOnlyField(field)
: handleReadOnlyField(field)
);
});
+1 -1
View File
@@ -87,7 +87,7 @@ export class DialogEnterCode
private _numberClick(e: MouseEvent): void {
const val = (e.currentTarget! as any).value;
this._input!.value = (this._input!.value ?? "") + val;
this._input!.value = this._input!.value + val;
this._showClearButton = true;
}
+1 -4
View File
@@ -177,10 +177,7 @@ export const showDialog = async (
throw new Error("Unknown dialog type loaded");
}
const targetParent = (parentElement || element).shadowRoot!;
if (dialogElement!.parentNode !== targetParent) {
targetParent.appendChild(dialogElement!);
}
(parentElement || element).shadowRoot!.appendChild(dialogElement!);
return true;
};
+1 -1
View File
@@ -76,7 +76,7 @@ export class TTSTryDialog extends LitElement {
@closed=${this._dialogClosed}
>
<ha-textarea
resize="auto"
autogrow
id="message"
.label=${this.hass.localize("ui.dialogs.tts-try.message")}
.placeholder=${this.hass.localize(
+1
View File
@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/triple-slash-reference */
/// <reference path="../types/service-worker.d.ts" />
/* eslint-env serviceworker */
import type { RouteHandler } from "workbox-core";
import { cacheNames } from "workbox-core";
import { CacheableResponsePlugin } from "workbox-cacheable-response";
+1 -18
View File
@@ -1,12 +1,6 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import {
customElement,
eventOptions,
property,
query,
state,
} from "lit/decorators";
import { customElement, eventOptions, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { canShowPage } from "../common/config/can_show_page";
@@ -81,8 +75,6 @@ export class HassTabsSubpage extends LitElement {
@state() private _activeTab?: PageNavigation;
@query(".content") private _content?: HTMLDivElement;
// @ts-ignore
@restoreScroll(".content") private _savedScrollPos?: number;
@@ -219,15 +211,6 @@ export class HassTabsSubpage extends LitElement {
this._savedScrollPos = (e.target as HTMLDivElement).scrollTop;
}
public focusContentScroller() {
if (!this._content) {
return;
}
this._content.style.outline = "none";
this._content.focus({ preventScroll: true });
}
private _backTapped(): void {
if (this.backCallback) {
this.backCallback();
-1
View File
@@ -34,7 +34,6 @@ const COMPONENTS = {
light: () => import("../panels/light/ha-panel-light"),
security: () => import("../panels/security/ha-panel-security"),
climate: () => import("../panels/climate/ha-panel-climate"),
maintenance: () => import("../panels/maintenance/ha-panel-maintenance"),
home: () => import("../panels/home/ha-panel-home"),
notfound: () => import("../panels/notfound/ha-panel-notfound"),
};
-13
View File
@@ -36,19 +36,11 @@ class NotificationManager extends LitElement {
@query("ha-toast")
private _toast!: HTMLElementTagNameMap["ha-toast"] | undefined;
private _showDialogId = 0;
public async showDialog(parameters: ShowToastParams) {
const showId = ++this._showDialogId;
if (!parameters.id || this._parameters?.id !== parameters.id) {
await this._toast?.hide();
}
if (showId !== this._showDialogId) {
return;
}
if (parameters.duration === 0) {
this._parameters = undefined;
return;
@@ -64,11 +56,6 @@ class NotificationManager extends LitElement {
}
await this.updateComplete;
if (showId !== this._showDialogId) {
return;
}
this._toast?.show();
}
@@ -197,7 +197,7 @@ class DialogCalendarEventEditor extends LitElement {
)}
.value=${this._description}
@change=${this._handleDescriptionChanged}
resize="auto"
autogrow
></ha-textarea>
<ha-entity-picker
name="calendar"
+38 -113
View File
@@ -21,17 +21,8 @@ import "../../components/ha-spinner";
import "../../components/ha-state-icon";
import "../../components/ha-svg-icon";
import "../../components/ha-two-pane-top-app-bar-fixed";
import type {
Calendar,
CalendarEvent,
CalendarEventSubscription,
CalendarEventApiData,
} from "../../data/calendar";
import {
getCalendars,
normalizeSubscriptionEventData,
subscribeCalendarEvents,
} from "../../data/calendar";
import type { Calendar, CalendarEvent } from "../../data/calendar";
import { fetchCalendarEvents, getCalendars } from "../../data/calendar";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import { subscribeEntityRegistry } from "../../data/entity/entity_registry";
import { fetchIntegrationManifest } from "../../data/integration";
@@ -55,8 +46,6 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
@state() private _error?: string = undefined;
@state() private _errorCalendars: string[] = [];
@state() private _entityRegistry?: EntityRegistryEntry[];
@state()
@@ -70,8 +59,6 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
private _end?: Date;
private _unsubs: Record<string, Promise<UnsubscribeFunc>> = {};
private _showPaneController = new ResizeController(this, {
callback: (entries) => entries[0]?.contentRect.width > 750,
});
@@ -91,7 +78,6 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
super.disconnectedCallback();
this._mql?.removeListener(this._setIsMobile!);
this._mql = undefined;
this._unsubscribeAll();
}
private _setIsMobile = (ev: MediaQueryListEvent) => {
@@ -104,11 +90,15 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
this._entityRegistry = entities;
// Refresh calendars when entity registry updates (includes color changes)
this._calendars = getCalendars(this.hass, this, this._entityRegistry);
// Resubscribe events if view dates are available (handles both initial load and color updates)
// Refetch events if view dates are available (handles both initial load and color updates)
if (this._start && this._end) {
this._unsubscribeAll().then(() => {
this._events = [];
this._subscribeCalendarEvents(this._selectedCalendars);
this._fetchEvents(
this._start,
this._end,
this._selectedCalendars
).then((result) => {
this._events = result.events;
this._handleErrors(result.errors);
});
}
}),
@@ -222,95 +212,19 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
.map((cal) => cal);
}
private _subscribeCalendarEvents(calendars: Calendar[]): void {
if (!this._start || !this._end || calendars.length === 0) {
return;
private async _fetchEvents(
start: Date | undefined,
end: Date | undefined,
calendars: Calendar[]
): Promise<{ events: CalendarEvent[]; errors: string[] }> {
if (!calendars.length || !start || !end) {
return { events: [], errors: [] };
}
this._error = undefined;
calendars.forEach((calendar) => {
// Unsubscribe existing subscription if any
if (calendar.entity_id in this._unsubs) {
this._unsubs[calendar.entity_id]
.then((unsubFunc) => unsubFunc())
.catch(() => {
// Subscription may have already been closed
});
}
const unsub = subscribeCalendarEvents(
this.hass,
calendar.entity_id,
this._start!,
this._end!,
(update: CalendarEventSubscription) => {
this._handleCalendarUpdate(calendar, update);
}
);
this._unsubs[calendar.entity_id] = unsub;
});
return fetchCalendarEvents(this.hass, start, end, calendars);
}
private _handleCalendarUpdate(
calendar: Calendar,
update: CalendarEventSubscription
): void {
// Remove events from this calendar
this._events = this._events.filter(
(event) => event.calendar !== calendar.entity_id
);
if (update.events === null) {
// Error fetching events
if (!this._errorCalendars.includes(calendar.entity_id)) {
this._errorCalendars = [...this._errorCalendars, calendar.entity_id];
}
this._handleErrors(this._errorCalendars);
return;
}
// Remove from error list if successfully loaded
this._errorCalendars = this._errorCalendars.filter(
(id) => id !== calendar.entity_id
);
this._handleErrors(this._errorCalendars);
// Add new events from this calendar
const newEvents: CalendarEvent[] = update.events
.map((eventData: CalendarEventApiData) =>
normalizeSubscriptionEventData(eventData, calendar)
)
.filter((event): event is CalendarEvent => event !== null);
this._events = [...this._events, ...newEvents];
}
private async _unsubscribeAll(): Promise<void> {
await Promise.all(
Object.values(this._unsubs).map((unsub) =>
unsub
.then((unsubFunc) => unsubFunc())
.catch(() => {
// Subscription may have already been closed
})
)
);
this._unsubs = {};
}
private _unsubscribeCalendar(entityId: string): void {
if (entityId in this._unsubs) {
this._unsubs[entityId]
.then((unsubFunc) => unsubFunc())
.catch(() => {
// Subscription may have already been closed
});
delete this._unsubs[entityId];
}
}
private _requestSelected(ev: Event) {
private async _requestSelected(ev: Event) {
ev.stopPropagation();
const item = ev.currentTarget as HaDropdownItem;
const entityId = item.value as string;
@@ -326,10 +240,13 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
if (!calendar) {
return;
}
this._subscribeCalendarEvents([calendar]);
const result = await this._fetchEvents(this._start, this._end, [
calendar,
]);
this._events = [...this._events, ...result.events];
this._handleErrors(result.errors);
} else {
this._deSelectedCalendars = [...this._deSelectedCalendars, entityId];
this._unsubscribeCalendar(entityId);
this._events = this._events.filter(
(event) => event.calendar !== entityId
);
@@ -354,15 +271,23 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
): Promise<void> {
this._start = ev.detail.start;
this._end = ev.detail.end;
await this._unsubscribeAll();
this._events = [];
this._subscribeCalendarEvents(this._selectedCalendars);
const result = await this._fetchEvents(
this._start,
this._end,
this._selectedCalendars
);
this._events = result.events;
this._handleErrors(result.errors);
}
private async _handleRefresh(): Promise<void> {
await this._unsubscribeAll();
this._events = [];
this._subscribeCalendarEvents(this._selectedCalendars);
const result = await this._fetchEvents(
this._start,
this._end,
this._selectedCalendars
);
this._events = result.events;
this._handleErrors(result.errors);
}
private _handleErrors(error_entity_ids: string[]) {
+33 -28
View File
@@ -32,7 +32,7 @@ class PanelClimate extends LitElement {
@state() private _lovelace?: Lovelace;
@state() private _searchParams = new URLSearchParams(window.location.search);
@state() private _searchParms = new URLSearchParams(window.location.search);
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
@@ -99,36 +99,41 @@ class PanelClimate extends LitElement {
return html`
<div class="header ${classMap({ narrow: this.narrow })}">
<div class="toolbar">
${this._searchParams.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`}
${
this._searchParms.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`
}
<div class="main-title">${this.hass.localize("panel.climate")}</div>
</div>
</div>
${this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}> </hui-view-background>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view
></hui-view-container>
`
: nothing}
${
this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}> </hui-view-background>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view
></hui-view-container>
`
: nothing
}
</hui-view-container>
`;
}
@@ -165,9 +165,10 @@ class DialogAutomationSave extends LitElement implements HassDialog {
"ui.panel.config.automation.editor.description.placeholder"
)}
name="description"
resize="auto"
autogrow
.value=${this._newDescription}
.hint=${supportsMarkdownHelper(this.hass.localize)}
.helper=${supportsMarkdownHelper(this.hass.localize)}
helperPersistent
@input=${this._valueChanged}
></ha-textarea>`
: nothing}
@@ -73,16 +73,13 @@ export class HaPlatformCondition extends LitElement {
}
if (
oldValue?.condition !== this.condition?.condition &&
this.condition &&
oldValue?.condition !== this.condition.condition &&
this.description?.fields
) {
const hadOptions = "options" in this.condition;
const updatedOptions = this.condition.options
? { ...this.condition.options }
: {};
const loadDefaults = !hadOptions;
let updatedDefaultValue = false;
const updatedOptions = {};
const loadDefaults = !("options" in this.condition);
// Set mandatory bools without a default value to false
Object.entries(this.description.fields).forEach(([key, field]) => {
if (
@@ -109,7 +106,7 @@ export class HaPlatformCondition extends LitElement {
updatedOptions[key] = field.default;
}
});
if (!hadOptions || updatedDefaultValue) {
if (updatedDefaultValue) {
fireEvent(this, "value-changed", {
value: {
...this.condition,
@@ -1,5 +1,6 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../../../components/ha-textarea";
import type { TemplateCondition } from "../../../../../data/automation";
import type { HomeAssistant } from "../../../../../types";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
@@ -108,16 +108,13 @@ export class HaPlatformTrigger extends LitElement {
}
if (
oldValue?.trigger !== this.trigger?.trigger &&
this.trigger &&
oldValue?.trigger !== this.trigger.trigger &&
this.description?.fields
) {
const hadOptions = "options" in this.trigger;
const updatedOptions = this.trigger.options
? { ...this.trigger.options }
: {};
const loadDefaults = !hadOptions;
let updatedDefaultValue = false;
const updatedOptions = {};
const loadDefaults = !("options" in this.trigger);
// Set mandatory bools without a default value to false
Object.entries(this.description.fields).forEach(([key, field]) => {
if (
@@ -145,7 +142,7 @@ export class HaPlatformTrigger extends LitElement {
}
});
if (!hadOptions || updatedDefaultValue) {
if (updatedDefaultValue) {
fireEvent(this, "value-changed", {
value: {
...this.trigger,
@@ -1,3 +1,4 @@
import "../../../../../components/ha-textarea";
import type { PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
@@ -8,6 +8,7 @@ import "../../../../components/ha-markdown-element";
import "../../../../components/ha-dialog";
import "../../../../components/ha-select";
import "../../../../components/ha-spinner";
import "../../../../components/ha-textarea";
import { fetchSupportPackage } from "../../../../data/cloud";
import type { HomeAssistant } from "../../../../types";
import { fileDownload } from "../../../../util/file_download";
@@ -101,7 +101,7 @@ export class DialogTryTts extends LitElement {
>
<div>
<ha-textarea
resize="auto"
autogrow
id="message"
autofocus
.label=${this.hass.localize(
@@ -69,7 +69,7 @@ class HaPanelDevAssist extends SubscribeMixin(LitElement) {
}
private async _parse() {
const sentences = (this._sentencesInput.value || "")
const sentences = this._sentencesInput.value
.split("\n")
.filter((a) => a !== "");
const { results } = await debugAgent(this.hass, sentences, this._language!);
@@ -139,7 +139,7 @@ class HaPanelDevAssist extends SubscribeMixin(LitElement) {
`
: nothing}
<ha-textarea
resize="auto"
autogrow
.label=${this.hass.localize(
"ui.panel.config.developer-tools.tabs.assist.sentences"
)}
@@ -11,6 +11,7 @@ import {
NetworkType,
getMatterNodeDiagnostics,
} from "../../../../../../data/matter";
import { getMatterLockInfo } from "../../../../../../data/matter-lock";
import type { HomeAssistant } from "../../../../../../types";
import { showMatterManageFabricsDialog } from "../../../../integrations/integration-panels/matter/show-dialog-matter-manage-fabrics";
import { showMatterOpenCommissioningWindowDialog } from "../../../../integrations/integration-panels/matter/show-dialog-matter-open-commissioning-window";
@@ -108,14 +109,23 @@ export const getMatterDeviceActions = async (
);
if (lockEntity) {
actions.push({
label: hass.localize("ui.panel.config.matter.device_actions.manage_lock"),
icon: mdiAccountLock,
action: () =>
showMatterLockManageDialog(el, {
entity_id: lockEntity.entity_id,
}),
});
try {
const lockInfo = await getMatterLockInfo(hass, lockEntity.entity_id);
if (lockInfo.supports_user_management) {
actions.push({
label: hass.localize(
"ui.panel.config.matter.device_actions.manage_lock"
),
icon: mdiAccountLock,
action: () =>
showMatterLockManageDialog(el, {
entity_id: lockEntity.entity_id,
}),
});
}
} catch {
// Lock info not available, skip lock management action
}
}
return actions;
@@ -445,9 +445,9 @@ export class HaConfigDeviceDashboard extends LitElement {
);
const labels = labelReg && device?.labels;
const labelsEntries = (labels || [])
.map((lbl) => labelReg!.find((label) => label.label_id === lbl))
.filter((entry): entry is LabelRegistryEntry => entry !== undefined);
const labelsEntries = (labels || []).map(
(lbl) => labelReg!.find((label) => label.label_id === lbl)!
);
let floorName;
if (
@@ -55,7 +55,6 @@ import {
import type { ImprovDiscoveredDevice } from "../../../external_app/external_messaging";
import "../../../layouts/hass-loading-screen";
import "../../../layouts/hass-tabs-subpage";
import type { HassTabsSubpage } from "../../../layouts/hass-tabs-subpage";
import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
@@ -171,8 +170,6 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
@query("ha-input-search") private _searchInput!: HaInputSearch;
@query("hass-tabs-subpage") private _tabsSubpage?: HassTabsSubpage;
public disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener(
@@ -427,10 +424,6 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
this.configEntries.map((entry) => entry.domain)
);
}
if (this.configEntries && this.configEntriesInProgress) {
this._tabsSubpage?.focusContentScroller();
}
}
protected render() {
@@ -3,7 +3,6 @@ import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-button";
import "../../../../../components/ha-dialog-footer";
import "../../../../../components/ha-icon-button";
@@ -96,60 +95,18 @@ class DialogMatterLockManage extends LitElement {
? html`<div class="center">
<ha-spinner></ha-spinner>
</div>`
: this._lockInfo && !this._lockInfo.supports_user_management
? html`<div class="content">
<ha-alert alert-type="warning">
${this.hass.localize(
"ui.panel.config.matter.lock.errors.no_user_management"
)}
</ha-alert>
${this._renderDocsLink()}
</div>`
: html`<div class="content">${this._renderUsers()}</div>`}
: html`<div class="content">${this._renderUsers()}</div>`}
</ha-dialog>
`;
}
private _renderDocsLink() {
return html`<a
class="docs-link"
href="https://www.home-assistant.io/integrations/matter/#lock-user-and-credential-management"
target="_blank"
rel="noopener noreferrer"
>
${this.hass.localize("ui.panel.config.matter.lock.errors.learn_more")}
</a>`;
}
private get _supportsPinCredential(): boolean {
return this._lockInfo?.supported_credential_types?.includes("pin") ?? false;
}
private _renderUsers() {
const occupiedUsers = this._users.filter(
(u) => u.user_status !== "available"
);
const hasNoManageableCredentials =
!this._lockInfo?.supported_credential_types?.length;
return html`
<div class="users-content">
${hasNoManageableCredentials
? html`<ha-alert alert-type="warning">
${this.hass.localize(
"ui.panel.config.matter.lock.errors.no_credential_types_supported"
)}
</ha-alert>
${this._renderDocsLink()}`
: !this._supportsPinCredential
? html`<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.matter.lock.errors.pin_not_supported"
)}
</ha-alert>
${this._renderDocsLink()}`
: nothing}
${occupiedUsers.length === 0
? html`<p class="empty">
${this.hass.localize(
@@ -190,14 +147,12 @@ class DialogMatterLockManage extends LitElement {
)}
</ha-md-list>
`}
${this._supportsPinCredential
? html`<div class="actions">
<ha-button @click=${this._addUser}>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.matter.lock.users.add")}
</ha-button>
</div>`
: nothing}
<div class="actions">
<ha-button @click=${this._addUser}>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.matter.lock.users.add")}
</ha-button>
</div>
</div>
`;
}
@@ -295,15 +250,6 @@ class DialogMatterLockManage extends LitElement {
.content {
min-height: 300px;
}
.content > ha-alert {
margin: var(--ha-space-4);
}
.docs-link {
display: block;
padding: 0 var(--ha-space-4);
color: var(--primary-color);
font-size: 14px;
}
.users-content {
padding: var(--ha-space-4) 0;
}
@@ -65,9 +65,6 @@ class DialogMatterLockUserEdit extends LitElement {
}
const isNew = !this._params.user;
const supportsPinCredential =
this._params.lockInfo?.supported_credential_types?.includes("pin") ??
false;
const title = isNew
? this.hass.localize("ui.panel.config.matter.lock.users.add")
: this.hass.localize("ui.panel.config.matter.lock.users.edit");
@@ -95,7 +92,7 @@ class DialogMatterLockUserEdit extends LitElement {
maxlength="10"
></ha-input>
${isNew && supportsPinCredential
${isNew
? html`
<ha-input
.label=${this.hass.localize(
@@ -116,13 +113,6 @@ class DialogMatterLockUserEdit extends LitElement {
></ha-input>
`
: nothing}
${isNew && !supportsPinCredential
? html`<ha-alert alert-type="warning">
${this.hass.localize(
"ui.panel.config.matter.lock.errors.no_credential_types_supported"
)}
</ha-alert>`
: nothing}
<div class="user-type-section">
<label
@@ -150,7 +140,7 @@ class DialogMatterLockUserEdit extends LitElement {
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${this._saving || (isNew && !supportsPinCredential)}
.disabled=${this._saving}
>
${this._saving
? html`<ha-spinner size="small"></ha-spinner>`
@@ -161,7 +161,7 @@ class ZHAAddDevicesPage extends LitElement {
? html`<ha-textarea
readonly
class="log"
resize="auto"
autogrow
.value=${this._formattedEvents}
>
</ha-textarea>`
+1
View File
@@ -22,6 +22,7 @@ import {
} from "lit";
import { classMap } from "lit/directives/class-map";
// eslint-disable-next-line import/extensions
import { IntersectionController } from "@lit-labs/observers/intersection-controller.js";
import { customElement, property, query, state } from "lit/decorators";
import "../../../components/chips/ha-assist-chip";
@@ -72,7 +72,6 @@ export const PANEL_DASHBOARDS = [
"security",
"climate",
"energy",
"maintenance",
] as string[];
type DataTableItem = Pick<
@@ -132,7 +132,7 @@ export default class HaScriptFieldRow extends LitElement {
</ha-dropdown-item>
</ha-dropdown>
<h3 slot="header">${this.field.name ?? this.key}</h3>
<h3 slot="header">${this.key}</h3>
<slot name="icons" slot="icons"></slot>
</ha-automation-row>
@@ -322,7 +322,7 @@ export class VoiceAssistantsExpose extends LitElement {
(entry?.device_id ? devices[entry.device_id!]?.area_id : undefined);
const area = areaId ? areas[areaId] : undefined;
const _assistants = Object.keys(
exposedEntities?.[entityState.entity_id] ?? {}
exposedEntities?.[entityState.entity_id]
).filter(
(key) =>
showAssistants.includes(key) &&
@@ -384,7 +384,7 @@ export class VoiceAssistantsExpose extends LitElement {
assistants: [
...(exposedEntities
? Object.keys(
exposedEntities?.[entityState.entity_id] ?? {}
exposedEntities?.[entityState.entity_id]
).filter(
(key) =>
showAssistants.includes(key) &&
@@ -74,7 +74,11 @@ export class EnergyDashboardStrategy extends ReactiveElement {
_config: EnergyDashboardStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceConfig> {
const prefs = await fetchEnergyPrefs(hass);
const [prefs, localize] = await Promise.all([
fetchEnergyPrefs(hass),
hass.loadFragmentTranslation("energy"),
]);
const localizeFunc = localize ?? hass.localize;
if (
!prefs ||
@@ -138,7 +142,7 @@ export class EnergyDashboardStrategy extends ReactiveElement {
...view,
title:
view.title ||
hass.localize(`ui.panel.energy.title.${view.path}` as LocalizeKeys),
localizeFunc(`ui.panel.energy.title.${view.path}` as LocalizeKeys),
})),
};
}
+32 -27
View File
@@ -99,36 +99,41 @@ class PanelLight extends LitElement {
return html`
<div class="header ${classMap({ narrow: this.narrow })}">
<div class="toolbar">
${this._searchParms.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`}
${
this._searchParms.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`
}
<div class="main-title">${this.hass.localize("panel.light")}</div>
</div>
</div>
${this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}> </hui-view-background>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view
></hui-view-container>
`
: nothing}
${
this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}> </hui-view-background>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view
></hui-view-container>
`
: nothing
}
</hui-view-container>
`;
}
+22 -94
View File
@@ -13,16 +13,8 @@ import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { debounce } from "../../../common/util/debounce";
import "../../../components/ha-card";
import "../../../components/ha-spinner";
import type {
Calendar,
CalendarEvent,
CalendarEventSubscription,
CalendarEventApiData,
} from "../../../data/calendar";
import {
normalizeSubscriptionEventData,
subscribeCalendarEvents,
} from "../../../data/calendar";
import type { Calendar, CalendarEvent } from "../../../data/calendar";
import { fetchCalendarEvents } from "../../../data/calendar";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import { subscribeEntityRegistry } from "../../../data/entity/entity_registry";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
@@ -85,8 +77,6 @@ export class HuiCalendarCard
@state() private _error?: string = undefined;
@state() private _errorCalendars: string[] = [];
@state() private _entityRegistry?: EntityRegistryEntry[];
@state() private _eventsLoaded = false;
@@ -97,8 +87,6 @@ export class HuiCalendarCard
private _resizeObserver?: ResizeObserver;
private _unsubs: Record<string, Promise<UnsubscribeFunc>> = {};
public setConfig(config: CalendarCardConfig): void {
if (!config.entities?.length) {
throw new Error("Entities must be specified");
@@ -109,8 +97,7 @@ export class HuiCalendarCard
}
if (this._config?.entities !== config.entities) {
this._unsubscribeAll();
// Subscription will happen when view-changed event fires
this._fetchCalendarEvents();
}
this._config = { initial_view: "dayGridMonth", ...config };
@@ -192,7 +179,6 @@ export class HuiCalendarCard
if (this._resizeObserver) {
this._resizeObserver.disconnect();
}
this._unsubscribeAll();
}
protected render() {
@@ -245,11 +231,9 @@ export class HuiCalendarCard
return;
}
// Resubscribe when entity registry changes (to update colors)
// Refetch events when entity registry changes (to update colors)
if (changedProps.has("_entityRegistry") && this._entityRegistry) {
this._unsubscribeAll().then(() => {
this._subscribeCalendarEvents();
});
this._fetchCalendarEvents();
}
// If no calendars configured, mark events as loaded to hide spinner
@@ -276,94 +260,38 @@ export class HuiCalendarCard
}
}
private async _handleViewChanged(
ev: HASSDomEvent<CalendarViewChanged>
): Promise<void> {
private _handleViewChanged(ev: HASSDomEvent<CalendarViewChanged>): void {
this._startDate = ev.detail.start;
this._endDate = ev.detail.end;
this._eventsLoaded = false;
await this._unsubscribeAll();
this._subscribeCalendarEvents();
this._fetchCalendarEvents();
}
private _subscribeCalendarEvents(): void {
if (!this.hass || !this._startDate || !this._endDate) {
private async _fetchCalendarEvents(): Promise<void> {
if (!this._startDate || !this._endDate) {
return;
}
this._error = undefined;
this._calendars.forEach((calendar) => {
const unsub = subscribeCalendarEvents(
this.hass!,
calendar.entity_id,
this._startDate!,
this._endDate!,
(update: CalendarEventSubscription) => {
this._handleCalendarUpdate(calendar, update);
}
);
this._unsubs[calendar.entity_id] = unsub;
});
}
private _handleCalendarUpdate(
calendar: Calendar,
update: CalendarEventSubscription
): void {
// Remove events from this calendar
this._events = this._events.filter(
(event) => event.calendar !== calendar.entity_id
const result = await fetchCalendarEvents(
this.hass!,
this._startDate,
this._endDate,
this._calendars
);
this._events = result.events;
// Wait for component update and one animation frame for FullCalendar to render
this.updateComplete.then(() => {
requestAnimationFrame(() => {
this._eventsLoaded = true;
});
});
if (update.events === null) {
// Error fetching events
if (!this._errorCalendars.includes(calendar.entity_id)) {
this._errorCalendars = [...this._errorCalendars, calendar.entity_id];
}
if (result.errors.length > 0) {
this._error = `${this.hass!.localize(
"ui.components.calendar.event_retrieval_error"
)}`;
return;
}
// Remove from error list if successfully loaded
this._errorCalendars = this._errorCalendars.filter(
(id) => id !== calendar.entity_id
);
if (this._errorCalendars.length === 0) {
this._error = undefined;
}
// Add new events from this calendar
const newEvents: CalendarEvent[] = update.events
.map((eventData: CalendarEventApiData) =>
normalizeSubscriptionEventData(eventData, calendar)
)
.filter((event): event is CalendarEvent => event !== null);
this._events = [...this._events, ...newEvents];
if (!this._eventsLoaded) {
this.updateComplete.then(() => {
requestAnimationFrame(() => {
this._eventsLoaded = true;
});
});
}
}
private async _unsubscribeAll(): Promise<void> {
await Promise.all(
Object.values(this._unsubs).map((unsub) =>
unsub
.then((unsubFunc) => unsubFunc())
.catch(() => {
// Subscription may have already been closed
})
)
);
this._unsubs = {};
}
private _measureCard() {
@@ -34,7 +34,6 @@ import {
HOME_SUMMARIES_ICONS,
type HomeSummary,
} from "../strategies/home/helpers/home-summaries";
import { filterNeedsAttentionEntities } from "../../maintenance/strategies/maintenance-view-strategy";
import type { LovelaceCard, LovelaceGridOptions } from "../types";
import { tileCardStyle } from "./tile/tile-card-style";
import type { HomeSummaryCard } from "./types";
@@ -44,7 +43,6 @@ const COLORS: Record<HomeSummary, string> = {
climate: "deep-orange",
security: "blue-grey",
media_players: "blue",
maintenance: "grey",
energy: "amber",
persons: "green",
};
@@ -257,31 +255,6 @@ export class HuiHomeSummaryCard
})
: this.hass.localize("ui.card.home-summary.no_media_playing");
}
case "maintenance": {
const maintenanceFilters = HOME_SUMMARIES_FILTERS.maintenance.map(
(filter) => generateEntityFilter(this.hass!, filter)
);
const maintenanceEntities = findEntities(
allEntities,
maintenanceFilters
);
const needsAttentionEntities = filterNeedsAttentionEntities(
this.hass!,
maintenanceEntities
);
if (needsAttentionEntities.length > 0) {
return this.hass.localize(
"ui.card.home-summary.count_maintenance_issues",
{
count: needsAttentionEntities.length,
}
);
}
return this.hass.localize("ui.card.home-summary.all_maintenance_good");
}
case "energy": {
if (!this._energyData) {
return "";
-1
View File
@@ -415,7 +415,6 @@ export interface MapCardConfig extends LovelaceCardConfig {
dark_mode?: boolean;
theme_mode?: ThemeMode;
cluster?: boolean;
conditions?: Condition[];
}
export interface MarkdownCardConfig extends LovelaceCardConfig {
@@ -460,8 +460,8 @@ export function addEntityToCondition(
condition.condition === "numeric_state"
) {
return {
...condition,
entity: entityId,
...condition,
};
}
return condition;
@@ -57,7 +57,6 @@ import "../../../components/ha-button";
import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-ripple";
import "../../../components/ha-spinner";
import "../../../components/ha-svg-icon";
import type { EnergyData } from "../../../data/energy";
import {
@@ -77,7 +76,6 @@ const RANGE_KEYS: DateRange[] = [
"this_year",
"now-7d",
"now-30d",
"now-365d",
"now-12m",
];
@@ -125,10 +123,6 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
@state() private _collapseButtons = false;
@state() private _loading = false;
private _loadingTimer?: ReturnType<typeof setTimeout>;
private _resizeObserver?: ResizeObserver;
public hassSubscribe(): UnsubscribeFunc[] {
@@ -351,13 +345,6 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
: ``}
</div>`
: nothing}
<ha-spinner
class=${classMap({
"loading-indicator": true,
"is-loading": this._loading,
})}
size="small"
></ha-spinner>
</section>
<section class="date-actions">
<div class="overflow">
@@ -508,7 +495,6 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
});
energyCollection.setPeriod(this._startDate!, this._endDate!);
energyCollection.refresh();
this._scheduleLoadingIndicator();
}
private _dateRangeChanged(ev) {
@@ -685,8 +671,6 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
}
private _updateDates(energyData: EnergyData): void {
clearTimeout(this._loadingTimer);
this._loading = false;
this._compare = energyData.startCompare !== undefined;
this._startDate = energyData.start;
this._endDate = energyData.end || endOfToday();
@@ -705,16 +689,6 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
this._compare ? CompareMode.PREVIOUS : CompareMode.NONE
);
energyCollection.refresh();
this._scheduleLoadingIndicator();
}
private _scheduleLoadingIndicator() {
// Add a delay before showing the loading indicator
// Basically to ensure there's no "flash loading" when data is loaded quickly
clearTimeout(this._loadingTimer);
this._loadingTimer = setTimeout(() => {
this._loading = true;
}, 200);
}
private _getDatePickerPlacement = memoizeOne(
@@ -764,7 +738,6 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
position: relative;
}
.header-title {
font-size: var(--ha-font-size-xl);
@@ -789,18 +762,6 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
height: 100%;
display: flex;
flex-direction: row;
align-items: center;
}
.loading-indicator {
display: flex;
position: absolute;
right: var(--ha-space-2);
align-items: center;
opacity: 0;
transition: opacity var(--ha-animation-duration-normal) ease-in-out;
}
.loading-indicator.is-loading {
opacity: 1;
}
.date-actions .overflow {
display: flex;
@@ -21,7 +21,6 @@ import { handleStructError } from "../../../../common/structs/handle-errors";
import "../../../../components/ha-alert";
import "../../../../components/ha-card";
import "../../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
@@ -34,45 +33,13 @@ import { ICON_CONDITION } from "../../common/icon-condition";
import type {
Condition,
LegacyCondition,
OrCondition,
AndCondition,
NotCondition,
} from "../../common/validate-condition";
import {
checkConditionsMet,
validateConditionalConfig,
} from "../../common/validate-condition";
import type { LovelaceConditionEditorConstructor } from "./types";
import type { PresetState } from "./types/ha-card-condition-state";
const NO_ENTITY_CONDITIONS = ["state", "numeric_state"];
const CONTAINER_CONDITIONS = ["and", "or", "not"];
const NO_ENTITY_CONDITIONS_EXT = [
...CONTAINER_CONDITIONS,
...NO_ENTITY_CONDITIONS,
];
const isNoEntityCondition = (condition: string, noEntity: boolean): boolean =>
NO_ENTITY_CONDITIONS.includes(condition) && noEntity;
export const getConditionClassName = (condition: string, noEntity: boolean) => {
if (isNoEntityCondition(condition, noEntity)) {
return `ha-card-condition-${condition}-no_entity`;
}
return `ha-card-condition-${condition}`;
};
const containsNoEntityCondition = (
condition: Condition,
noEntity: boolean
): boolean =>
noEntity &&
CONTAINER_CONDITIONS.includes(condition.condition) &&
(condition as OrCondition | AndCondition | NotCondition).conditions?.some(
(c) => NO_ENTITY_CONDITIONS.includes(c.condition)
) === true;
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
@customElement("ha-card-condition-editor")
export class HaCardConditionEditor extends LitElement {
@@ -80,10 +47,6 @@ export class HaCardConditionEditor extends LitElement {
@property({ attribute: false }) condition!: Condition | LegacyCondition;
@property({ attribute: "no-entity", type: Boolean }) public noEntity = false;
@property({ attribute: false }) public presetStates: PresetState[] = [];
@storage({
key: "dashboardConditionClipboard",
state: false,
@@ -105,7 +68,7 @@ export class HaCardConditionEditor extends LitElement {
private get _editor() {
if (!this._condition) return undefined;
return customElements.get(
getConditionClassName(this._condition.condition, this.noEntity)
`ha-card-condition-${this._condition.condition}`
) as LovelaceConditionEditorConstructor | undefined;
}
@@ -176,15 +139,12 @@ export class HaCardConditionEditor extends LitElement {
>
</ha-icon-button>
${isNoEntityCondition(condition.condition, this.noEntity) ||
containsNoEntityCondition(condition, this.noEntity)
? nothing
: html`<ha-dropdown-item value="test">
${this.hass.localize(
"ui.panel.lovelace.editor.condition-editor.test"
)}
<ha-svg-icon slot="icon" .path=${mdiFlask}></ha-svg-icon>
</ha-dropdown-item>`}
<ha-dropdown-item value="test">
${this.hass.localize(
"ui.panel.lovelace.editor.condition-editor.test"
)}
<ha-svg-icon slot="icon" .path=${mdiFlask}></ha-svg-icon>
</ha-dropdown-item>
<ha-dropdown-item value="duplicate">
${this.hass.localize(
@@ -257,22 +217,10 @@ export class HaCardConditionEditor extends LitElement {
></ha-yaml-editor>
`
: html`
${dynamicElement(
getConditionClassName(condition.condition, this.noEntity),
{
hass: this.hass,
condition: condition,
...(this.noEntity &&
NO_ENTITY_CONDITIONS_EXT.includes(condition.condition)
? {
noEntity: this.noEntity,
...(condition.condition === "numeric_state"
? {}
: { presetStates: this.presetStates }),
}
: {}),
}
)}
${dynamicElement(`ha-card-condition-${condition.condition}`, {
hass: this.hass,
condition: condition,
})}
`}
</div>
</ha-expansion-panel>
@@ -7,7 +7,6 @@ import { storage } from "../../../../common/decorators/storage";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-button";
import "../../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import "../../../../components/ha-svg-icon";
import type { HomeAssistant } from "../../../../types";
@@ -17,23 +16,18 @@ import type {
LegacyCondition,
} from "../../common/validate-condition";
import "./ha-card-condition-editor";
import {
type HaCardConditionEditor,
getConditionClassName,
} from "./ha-card-condition-editor";
import type { HaCardConditionEditor } from "./ha-card-condition-editor";
import type { LovelaceConditionEditorConstructor } from "./types";
import "./types/ha-card-condition-and";
import "./types/ha-card-condition-location";
import "./types/ha-card-condition-not";
import "./types/ha-card-condition-numeric_state";
import "./types/ha-card-condition-numeric_state-no_entity";
import "./types/ha-card-condition-or";
import "./types/ha-card-condition-screen";
import "./types/ha-card-condition-state";
import type { PresetState } from "./types/ha-card-condition-state";
import "./types/ha-card-condition-state-no_entity";
import "./types/ha-card-condition-time";
import "./types/ha-card-condition-user";
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
const UI_CONDITION = [
"location",
@@ -64,10 +58,6 @@ export class HaCardConditionsEditor extends LitElement {
| LegacyCondition
)[];
@property({ attribute: "no-entity", type: Boolean }) public noEntity = false;
@property({ attribute: false }) public presetStates: PresetState[] = [];
private _focusLastConditionOnChange = false;
protected firstUpdated() {
@@ -111,8 +101,6 @@ export class HaCardConditionsEditor extends LitElement {
@value-changed=${this._conditionChanged}
.hass=${this.hass}
.condition=${cond}
.noEntity=${this.noEntity}
.presetStates=${this.presetStates}
></ha-card-condition-editor>
`
)}
@@ -168,12 +156,13 @@ export class HaCardConditionsEditor extends LitElement {
const newCondition = deepClone(this._clipboard!);
conditions.push(newCondition);
} else {
const elClass = customElements.get(
getConditionClassName(condition, this.noEntity)
) as LovelaceConditionEditorConstructor | undefined;
const elClass = customElements.get(`ha-card-condition-${condition}`) as
| LovelaceConditionEditorConstructor
| undefined;
const defaultConfig = elClass?.defaultConfig;
conditions.push(defaultConfig ? { ...defaultConfig } : { condition });
conditions.push(
elClass?.defaultConfig ? { ...elClass.defaultConfig } : { condition }
);
}
this._focusLastConditionOnChange = true;
@@ -10,7 +10,6 @@ import type {
StateCondition,
} from "../../../common/validate-condition";
import "../ha-card-conditions-editor";
import type { PresetState } from "./ha-card-condition-state";
const andConditionStruct = object({
condition: literal("and"),
@@ -25,10 +24,6 @@ export class HaCardConditionNumericAnd extends LitElement {
@property({ type: Boolean }) public disabled = false;
@property({ attribute: "no-entity", type: Boolean }) public noEntity = false;
@property({ attribute: false }) public presetStates: PresetState[] = [];
public static get defaultConfig(): AndCondition {
return { condition: "and", conditions: [] };
}
@@ -43,8 +38,6 @@ export class HaCardConditionNumericAnd extends LitElement {
nested
.hass=${this.hass}
.conditions=${this.condition.conditions}
.noEntity=${this.noEntity}
.presetStates=${this.presetStates}
@value-changed=${this._valueChanged}
>
</ha-card-conditions-editor>
@@ -10,7 +10,6 @@ import type {
StateCondition,
} from "../../../common/validate-condition";
import "../ha-card-conditions-editor";
import type { PresetState } from "./ha-card-condition-state";
const notConditionStruct = object({
condition: literal("not"),
@@ -25,10 +24,6 @@ export class HaCardConditionNot extends LitElement {
@property({ type: Boolean }) public disabled = false;
@property({ attribute: "no-entity", type: Boolean }) public noEntity = false;
@property({ attribute: false }) public presetStates: PresetState[] = [];
public static get defaultConfig(): NotCondition {
return { condition: "not", conditions: [] };
}
@@ -43,8 +38,6 @@ export class HaCardConditionNot extends LitElement {
nested
.hass=${this.hass}
.conditions=${this.condition.conditions}
.noEntity=${this.noEntity}
.presetStates=${this.presetStates}
@value-changed=${this._valueChanged}
>
</ha-card-conditions-editor>
@@ -1,16 +0,0 @@
import { customElement } from "lit/decorators";
import type { NumericStateCondition } from "../../../common/validate-condition";
import { HaCardConditionNumericState } from "./ha-card-condition-numeric_state";
@customElement("ha-card-condition-numeric_state-no_entity")
export class HaCardConditionNumericStateNoEntity extends HaCardConditionNumericState {
public static get defaultConfig(): NumericStateCondition {
return { condition: "numeric_state" };
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-card-condition-numeric_state-no_entity": HaCardConditionNumericStateNoEntity;
}
}
@@ -30,8 +30,6 @@ export class HaCardConditionNumericState extends LitElement {
@property({ type: Boolean }) public disabled = false;
@property({ attribute: "no-entity", type: Boolean }) public noEntity = false;
public static get defaultConfig(): NumericStateCondition {
return { condition: "numeric_state", entity: "" };
}
@@ -41,9 +39,9 @@ export class HaCardConditionNumericState extends LitElement {
}
private _schema = memoizeOne(
(noEntity: boolean, stateObj?: HassEntity) =>
(stateObj?: HassEntity) =>
[
...(noEntity ? [] : [{ name: "entity", selector: { entity: {} } }]),
{ name: "entity", selector: { entity: {} } },
{
name: "",
type: "grid",
@@ -82,7 +80,7 @@ export class HaCardConditionNumericState extends LitElement {
<ha-form
.hass=${this.hass}
.data=${this.condition}
.schema=${this._schema(this.noEntity, stateObj)}
.schema=${this._schema(stateObj)}
.disabled=${this.disabled}
@value-changed=${this._valueChanged}
.computeLabel=${this._computeLabelCallback}
@@ -10,7 +10,6 @@ import type {
StateCondition,
} from "../../../common/validate-condition";
import "../ha-card-conditions-editor";
import type { PresetState } from "./ha-card-condition-state";
const orConditionStruct = object({
condition: literal("or"),
@@ -25,10 +24,6 @@ export class HaCardConditionOr extends LitElement {
@property({ type: Boolean }) public disabled = false;
@property({ attribute: "no-entity", type: Boolean }) public noEntity = false;
@property({ attribute: false }) public presetStates: PresetState[] = [];
public static get defaultConfig(): OrCondition {
return { condition: "or", conditions: [] };
}
@@ -43,8 +38,6 @@ export class HaCardConditionOr extends LitElement {
nested
.hass=${this.hass}
.conditions=${this.condition.conditions}
.noEntity=${this.noEntity}
.presetStates=${this.presetStates}
@value-changed=${this._valueChanged}
>
</ha-card-conditions-editor>
@@ -1,19 +0,0 @@
import { customElement } from "lit/decorators";
import type { StateCondition } from "../../../common/validate-condition";
import { HaCardConditionState } from "./ha-card-condition-state";
@customElement("ha-card-condition-state-no_entity")
export class HaCardConditionStateNoEntity extends HaCardConditionState {
public static get defaultConfig(): StateCondition {
return {
condition: "state",
state: "",
};
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-card-condition-state-no_entity": HaCardConditionStateNoEntity;
}
}
@@ -27,11 +27,6 @@ interface StateConditionData {
state?: string | string[];
}
export interface PresetState {
value: string;
label: string;
}
@customElement("ha-card-condition-state")
export class HaCardConditionState extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -40,10 +35,6 @@ export class HaCardConditionState extends LitElement {
@property({ type: Boolean }) public disabled = false;
@property({ attribute: "no-entity", type: Boolean }) public noEntity = false;
@property({ attribute: false }) public presetStates: PresetState[] = [];
public static get defaultConfig(): StateCondition {
return { condition: "state", entity: "", state: "" };
}
@@ -64,9 +55,9 @@ export class HaCardConditionState extends LitElement {
}
private _schema = memoizeOne(
(noEntity: boolean, localize: LocalizeFunc, presetStates: PresetState[]) =>
(localize: LocalizeFunc) =>
[
...(noEntity ? [] : [{ name: "entity", selector: { entity: {} } }]),
{ name: "entity", selector: { entity: {} } },
{
name: "",
type: "grid",
@@ -95,25 +86,13 @@ export class HaCardConditionState extends LitElement {
},
},
{
...(noEntity
? {
name: "state",
selector: {
state: {
extra_options: presetStates,
no_entity: true,
},
},
}
: {
name: "state",
selector: {
state: {},
},
context: {
filter_entity: "entity",
},
}),
name: "state",
selector: {
state: {},
},
context: {
filter_entity: "entity",
},
},
],
},
@@ -134,11 +113,7 @@ export class HaCardConditionState extends LitElement {
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${this._schema(
this.noEntity,
this.hass.localize,
this.presetStates
)}
.schema=${this._schema(this.hass.localize)}
.disabled=${this.disabled}
@value-changed=${this._valueChanged}
.computeLabel=${this._computeLabelCallback}
@@ -15,48 +15,36 @@ import {
string,
union,
} from "superstruct";
import { arrayLiteralIncludes } from "../../../../common/array/literal-includes";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeDomain } from "../../../../common/entity/compute_domain";
import {
FIXED_DOMAIN_STATES,
getStatesDomain,
} from "../../../../common/entity/get_states";
import { hasLocation } from "../../../../common/entity/has_location";
import type {
LocalizeFunc,
LocalizeKeys,
} from "../../../../common/translations/localize";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import { orderProperties } from "../../../../common/util/order-properties";
import "../../../../components/ha-form/ha-form";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import { MAP_CARD_MARKER_LABEL_MODES } from "../../../../components/map/ha-map";
import "../../../../components/ha-formfield";
import "../../../../components/ha-selector/ha-selector-select";
import "../../../../components/ha-switch";
import { MAP_CARD_MARKER_LABEL_MODES } from "../../../../components/map/ha-map";
import { UNAVAILABLE_STATES } from "../../../../data/entity/entity";
import type { SelectSelector } from "../../../../data/selector";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import { THEME_MODES } from "../../../../types";
import { DEFAULT_HOURS_TO_SHOW, DEFAULT_ZOOM } from "../../cards/hui-map-card";
import type { MapCardConfig, MapEntityConfig } from "../../cards/types";
import type { Condition } from "../../common/validate-condition";
import "../../components/hui-entity-editor";
import type { LovelaceCardEditor } from "../../types";
import "../conditions/ha-card-condition-editor";
import type { PresetState } from "../conditions/types/ha-card-condition-state";
import "../hui-sub-element-editor";
import { processEditorEntities } from "../process-editor-entities";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import type {
EditDetailElementEvent,
EntitiesEditorEvent,
SubElementEditorConfig,
EntitiesEditorEvent,
} from "../types";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import type { LovelaceCardEditor } from "../../types";
import { processEditorEntities } from "../process-editor-entities";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { configElementStyle } from "./config-elements-style";
export const mapEntitiesConfigStruct = union([
@@ -117,8 +105,6 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
private _locationEntities: string[] = [];
@state() private _presetStates: PresetState[] = [];
private _schema = memoizeOne(
(localize: LocalizeFunc) =>
[
@@ -240,31 +226,6 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
})
);
private _preparePresetStates() {
if (!this.hass || this._presetStates.length) {
return;
}
const states = getStatesDomain(this.hass!, "device_tracker");
this._presetStates = states.map((stateRaw) => {
let stateTranslated;
if (arrayLiteralIncludes(UNAVAILABLE_STATES)(stateRaw)) {
stateTranslated = this.hass!.localize(
`state.default.${stateRaw}` as LocalizeKeys
);
} else if (
arrayLiteralIncludes(FIXED_DOMAIN_STATES.device_tracker)(stateRaw)
) {
stateTranslated = this.hass!.localize(
`component.device_tracker.entity_component._.state.${stateRaw}`
);
} else {
stateTranslated = stateRaw;
}
return { value: stateRaw, label: stateTranslated };
});
}
public setConfig(config: MapCardConfig): void {
assert(config, cardConfigStruct);
@@ -298,7 +259,6 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
: Object.keys(this.hass!.states).filter((entity_id) =>
hasLocation(this.hass!.states[entity_id])
);
this._preparePresetStates();
}
protected render() {
@@ -366,45 +326,9 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
this.hass.localize
)}
></ha-selector-select>
${this.renderConditions()}
`;
}
renderConditions() {
const conditions = this._config?.conditions ?? [];
return html`
<h3>
${this.hass!.localize("ui.panel.lovelace.editor.card.map.conditions")}
</h3>
<p class="intro">
${this.hass!.localize(
"ui.panel.lovelace.editor.card.map.conditions_helper"
)}
</p>
<ha-card-conditions-editor
no-entity
.hass=${this.hass!}
.conditions=${conditions}
.presetStates=${this._presetStates}
@value-changed=${this._conditionsChanged}
>
</ha-card-conditions-editor>
`;
}
private _conditionsChanged(ev: CustomEvent): void {
ev.stopPropagation();
const conditions = ev.detail.value as Condition[];
const newConfig: MapCardConfig = {
...this._config!,
conditions: conditions,
};
if (newConfig.conditions?.length === 0) {
delete newConfig.conditions;
}
fireEvent(this, "config-changed", { config: newConfig });
}
private _goBack(): void {
this._subElementEditorConfig = undefined;
}
@@ -622,16 +546,7 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
}
static get styles(): CSSResultGroup {
return [
configElementStyle,
css`
.intro {
margin: 0;
margin-bottom: var(--ha-space-1);
color: var(--secondary-text-color);
}
`,
];
return [configElementStyle, css``];
}
}
@@ -85,7 +85,6 @@ export class HuiPictureHeaderFooter
img {
display: block;
width: 100%;
-webkit-touch-callout: none;
}
`;
@@ -59,8 +59,6 @@ const STRATEGIES: Record<LovelaceStrategyConfigType, Record<string, any>> = {
light: () => import("../../light/strategies/light-view-strategy"),
security: () => import("../../security/strategies/security-view-strategy"),
climate: () => import("../../climate/strategies/climate-view-strategy"),
maintenance: () =>
import("../../maintenance/strategies/maintenance-view-strategy"),
},
section: {
"common-controls": () =>
@@ -1,6 +1,5 @@
import type { EntityFilter } from "../../../../../common/entity/entity_filter";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import { maintenanceEntityFilters } from "../../../../maintenance/strategies/maintenance-view-strategy";
import { climateEntityFilters } from "../../../../climate/strategies/climate-view-strategy";
import { lightEntityFilters } from "../../../../light/strategies/light-view-strategy";
import { securityEntityFilters } from "../../../../security/strategies/security-view-strategy";
@@ -10,7 +9,6 @@ export const HOME_SUMMARIES = [
"climate",
"security",
"media_players",
"maintenance",
"energy",
"persons",
] as const;
@@ -22,7 +20,6 @@ export const HOME_SUMMARIES_ICONS: Record<HomeSummary, string> = {
climate: "mdi:home-thermometer",
security: "mdi:security",
media_players: "mdi:multimedia",
maintenance: "mdi:wrench",
energy: "mdi:lightning-bolt",
persons: "mdi:account-multiple",
};
@@ -32,7 +29,6 @@ export const HOME_SUMMARIES_FILTERS: Record<HomeSummary, EntityFilter[]> = {
climate: climateEntityFilters,
security: securityEntityFilters,
media_players: [{ domain: "media_player", entity_category: "none" }],
maintenance: maintenanceEntityFilters,
energy: [], // Uses energy collection data
persons: [{ domain: "person" }],
};
@@ -41,12 +37,7 @@ export const getSummaryLabel = (
localize: LocalizeFunc,
summary: HomeSummary
) => {
if (
summary === "light" ||
summary === "climate" ||
summary === "security" ||
summary === "maintenance"
) {
if (summary === "light" || summary === "climate" || summary === "security") {
return localize(`panel.${summary}`);
}
return localize(`ui.panel.lovelace.strategy.home.summary_list.${summary}`);
@@ -223,10 +223,6 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
generateEntityFilter(hass, filter)
);
const maintenanceFilters = HOME_SUMMARIES_FILTERS.maintenance.map(
(filter) => generateEntityFilter(hass, filter)
);
const hasLights =
hass.panels.light && findEntities(allEntities, lightsFilters).length > 0;
const hasMediaPlayers =
@@ -237,9 +233,6 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
const hasSecurity =
hass.panels.security &&
findEntities(allEntities, securityFilters).length > 0;
const hasMaintenance =
hass.panels.maintenance &&
findEntities(allEntities, maintenanceFilters).length > 0;
const weatherFilter = generateEntityFilter(hass, {
domain: "weather",
@@ -329,17 +322,6 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
navigation_path: "media-players",
},
} satisfies HomeSummaryCard),
hasMaintenance &&
({
type: "home-summary",
summary: "maintenance",
tap_action: {
action: "navigate",
navigation_path: config.home_panel
? "/maintenance?historyBack=1&backPath=/home"
: "/maintenance?historyBack=1",
},
} satisfies HomeSummaryCard),
weatherEntity &&
!hiddenSummaries.has("weather") &&
({
@@ -1,259 +0,0 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { goBack } from "../../common/navigate";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-menu-button";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
import type { Lovelace } from "../lovelace/types";
import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-background";
import "../lovelace/views/hui-view-container";
const MAINTENANCE_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
strategy: {
type: "maintenance",
},
};
@customElement("ha-panel-maintenance")
class PanelMaintenance extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@state() private _viewIndex = 0;
@state() private _lovelace?: Lovelace;
@state() private _searchParams = new URLSearchParams(window.location.search);
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
// Initial setup
if (!this.hasUpdated) {
this._setup();
return;
}
if (!changedProps.has("hass")) {
return;
}
const oldHass = changedProps.get("hass") as this["hass"];
if (oldHass && oldHass.localize !== this.hass.localize) {
this._setLovelace();
return;
}
if (oldHass && this.hass) {
// If the entity registry changed, ask the user if they want to refresh the config
if (
oldHass.entities !== this.hass.entities ||
oldHass.devices !== this.hass.devices ||
oldHass.areas !== this.hass.areas ||
oldHass.floors !== this.hass.floors ||
oldHass.panels !== this.hass.panels
) {
if (this.hass.config.state === "RUNNING") {
this._debounceRegistriesChanged();
return;
}
}
// If ha started, refresh the config
if (
this.hass.config.state === "RUNNING" &&
oldHass.config.state !== "RUNNING"
) {
this._setLovelace();
}
}
}
private async _setup() {
await this.hass.loadFragmentTranslation("lovelace");
this._setLovelace();
}
private _debounceRegistriesChanged = debounce(
() => this._registriesChanged(),
200
);
private _registriesChanged = async () => {
this._setLovelace();
};
private _back(ev: Event) {
ev.stopPropagation();
goBack();
}
protected render() {
return html`
<div class="header ${classMap({ narrow: this.narrow })}">
<div class="toolbar">
${this._searchParams.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`}
<div class="main-title">
${this.hass.localize("panel.maintenance")}
</div>
</div>
</div>
${this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}> </hui-view-background>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view>
</hui-view-container>
`
: nothing}
`;
}
private async _setLovelace() {
const viewConfig = await generateLovelaceViewStrategy(
MAINTENANCE_LOVELACE_VIEW_CONFIG,
this.hass
);
const config = { views: [viewConfig] };
const rawConfig = { views: [MAINTENANCE_LOVELACE_VIEW_CONFIG] };
if (deepEqual(config, this._lovelace?.config)) {
return;
}
this._lovelace = {
config: config,
rawConfig: rawConfig,
editMode: false,
urlPath: "maintenance",
mode: "generated",
locale: this.hass.locale,
enableFullEditMode: () => undefined,
saveConfig: async () => undefined,
deleteConfig: async () => undefined,
setEditMode: () => undefined,
showToast: () => undefined,
};
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
-ms-user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
}
.header {
background-color: var(--app-header-background-color);
color: var(--app-header-text-color, white);
position: fixed;
top: 0;
width: calc(
var(--mdc-top-app-bar-width, 100%) - var(
--safe-area-inset-right,
0px
)
);
z-index: 4;
display: flex;
flex-direction: row;
-webkit-backdrop-filter: var(--app-header-backdrop-filter, none);
backdrop-filter: var(--app-header-backdrop-filter, none);
padding-top: var(--safe-area-inset-top);
padding-right: var(--safe-area-inset-right);
}
:host([narrow]) .header {
width: calc(
var(--mdc-top-app-bar-width, 100%) - var(
--safe-area-inset-left,
0px
) - var(--safe-area-inset-right, 0px)
);
padding-left: var(--safe-area-inset-left);
}
:host([scrolled]) .header {
box-shadow: var(
--mdc-top-app-bar-fixed-box-shadow,
0px 2px 4px -1px rgba(0, 0, 0, 0.2),
0px 4px 5px 0px rgba(0, 0, 0, 0.14),
0px 1px 10px 0px rgba(0, 0, 0, 0.12)
);
}
.toolbar {
height: var(--header-height);
display: flex;
flex: 1;
align-items: center;
font-size: var(--ha-font-size-xl);
padding: 0px 12px;
font-weight: var(--ha-font-weight-normal);
box-sizing: border-box;
border-bottom: var(--app-header-border-bottom, none);
}
:host([narrow]) .toolbar {
padding: 0 4px;
}
.main-title {
margin-inline-start: var(--ha-space-6);
line-height: var(--ha-line-height-normal);
flex-grow: 1;
}
.narrow .main-title {
margin-inline-start: var(--ha-space-2);
}
hui-view-container {
position: relative;
display: flex;
min-height: 100vh;
box-sizing: border-box;
padding-top: calc(var(--header-height) + var(--safe-area-inset-top));
padding-right: var(--safe-area-inset-right);
padding-inline-end: var(--safe-area-inset-right);
padding-bottom: var(--safe-area-inset-bottom);
}
:host([narrow]) hui-view-container {
padding-left: var(--safe-area-inset-left);
padding-inline-start: var(--safe-area-inset-left);
}
hui-view {
flex: 1 1 100%;
max-width: 100%;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-panel-maintenance": PanelMaintenance;
}
}
@@ -1,221 +0,0 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { getAreasFloorHierarchy } from "../../../common/areas/areas-floor-hierarchy";
import {
findEntities,
generateEntityFilter,
type EntityFilter,
} from "../../../common/entity/entity_filter";
import { floorDefaultIcon } from "../../../components/ha-floor-icon";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { LovelaceSectionRawConfig } from "../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import type { TileCardConfig } from "../../lovelace/cards/types";
export interface MaintenanceViewStrategyConfig {
type: "maintenance";
}
export const maintenanceEntityFilters: EntityFilter[] = [
{
domain: "sensor",
device_class: ["battery"],
},
{
domain: "binary_sensor",
device_class: ["battery", "battery_charging"],
entity_category: "none",
},
];
const LOW_BATTERY_THRESHOLD = 20;
export const filterNeedsAttentionEntities = (
hass: HomeAssistant,
entityIds: string[]
): string[] =>
entityIds.filter((entityId) => {
const stateValue = parseFloat(hass.states[entityId]?.state ?? "");
return !isNaN(stateValue) && stateValue <= LOW_BATTERY_THRESHOLD;
});
const computeBatteryTileCard = (entityId: string): TileCardConfig => ({
type: "tile",
entity: entityId,
name: { type: "device" },
});
const processAreasForBattery = (
areaIds: string[],
hass: HomeAssistant,
entities: string[]
): LovelaceCardConfig[] => {
const cards: LovelaceCardConfig[] = [];
for (const areaId of areaIds) {
const area = hass.areas[areaId];
if (!area) continue;
const areaFilter = generateEntityFilter(hass, {
area: area.area_id,
});
const areaBatteryEntities = entities.filter(areaFilter);
const areaCards: LovelaceCardConfig[] = [];
for (const entityId of areaBatteryEntities) {
areaCards.push(computeBatteryTileCard(entityId));
}
if (areaCards.length > 0) {
cards.push({
heading_style: "subtitle",
type: "heading",
heading: area.name,
tap_action: hass.panels.home
? {
action: "navigate",
navigation_path: `/home/areas-${area.area_id}`,
}
: undefined,
});
cards.push(...areaCards);
}
}
return cards;
};
const processUnassignedEntities = (
hass: HomeAssistant,
entities: string[]
): LovelaceCardConfig[] => {
const unassignedFilter = generateEntityFilter(hass, {
area: null,
});
const unassignedEntities = entities.filter(unassignedFilter);
const cards: LovelaceCardConfig[] = [];
for (const entityId of unassignedEntities) {
cards.push(computeBatteryTileCard(entityId));
}
return cards;
};
@customElement("maintenance-view-strategy")
export class MaintenanceViewStrategy extends ReactiveElement {
static async generate(
_config: MaintenanceViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const areas = Object.values(hass.areas);
const floors = Object.values(hass.floors);
const hierarchy = getAreasFloorHierarchy(floors, areas);
const sections: LovelaceSectionRawConfig[] = [];
const allEntities = Object.keys(hass.states);
const batteryFilters = maintenanceEntityFilters.map((filter) =>
generateEntityFilter(hass, filter)
);
const entities = findEntities(allEntities, batteryFilters);
const floorCount =
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
// Process floors
for (const floorStructure of hierarchy.floors) {
const floorId = floorStructure.id;
const areaIds = floorStructure.areas;
const floor = hass.floors[floorId];
const section: LovelaceSectionRawConfig = {
type: "grid",
column_span: 2,
cards: [
{
type: "heading",
heading:
floorCount > 1
? floor.name
: hass.localize("ui.panel.lovelace.strategy.home.areas"),
icon: floor.icon || floorDefaultIcon(floor),
},
],
};
const areaCards = processAreasForBattery(areaIds, hass, entities);
if (areaCards.length > 0) {
section.cards!.push(...areaCards);
sections.push(section);
}
}
// Process unassigned areas
if (hierarchy.areas.length > 0) {
const section: LovelaceSectionRawConfig = {
type: "grid",
column_span: 2,
cards: [
{
type: "heading",
heading:
floorCount > 1
? hass.localize(
"ui.panel.lovelace.strategy.maintenance.other_devices"
)
: hass.localize("ui.panel.lovelace.strategy.home.areas"),
},
],
};
const areaCards = processAreasForBattery(hierarchy.areas, hass, entities);
if (areaCards.length > 0) {
section.cards!.push(...areaCards);
sections.push(section);
}
}
// Process unassigned entities
const unassignedCards = processUnassignedEntities(hass, entities);
if (unassignedCards.length > 0) {
const section: LovelaceSectionRawConfig = {
type: "grid",
column_span: 2,
cards: [
{
type: "heading",
heading:
sections.length > 0
? hass.localize(
"ui.panel.lovelace.strategy.maintenance.other_devices"
)
: hass.localize(
"ui.panel.lovelace.strategy.maintenance.devices"
),
},
...unassignedCards,
],
};
sections.push(section);
}
return {
type: "sections",
max_columns: 2,
sections: sections,
};
}
}
declare global {
interface HTMLElementTagNameMap {
"maintenance-view-strategy": MaintenanceViewStrategy;
}
}
+32 -27
View File
@@ -99,36 +99,41 @@ class PanelSecurity extends LitElement {
return html`
<div class="header ${classMap({ narrow: this.narrow })}">
<div class="toolbar">
${this._searchParms.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`}
${
this._searchParms.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`
}
<div class="main-title">${this.hass.localize("panel.security")}</div>
</div>
</div>
${this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}> </hui-view-background>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view
></hui-view-container>
`
: nothing}
${
this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}> </hui-view-background>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view
></hui-view-container>
`
: nothing
}
</hui-view-container>
`;
}
+2 -2
View File
@@ -180,10 +180,10 @@ class DialogTodoItemEditor extends LitElement {
.label=${this.hass.localize(
"ui.components.todo.item.description"
)}
.hint=${supportsMarkdownHelper(this.hass.localize)}
.helper=${supportsMarkdownHelper(this.hass.localize)}
.value=${this._description}
@input=${this._handleDescriptionChanged}
resize="auto"
autogrow
.disabled=${!canUpdate}
></ha-textarea>`
: nothing}
+1 -1
View File
@@ -1,4 +1,3 @@
/* eslint-disable import-x/namespace -- echarts/core uses complex re-exports that static analysis can't resolve */
// Import the echarts core module, which provides the necessary interfaces for using echarts.
import * as echarts from "echarts/core";
@@ -27,6 +26,7 @@ import { CanvasRenderer } from "echarts/renderers";
// Import graphic utilities from zrender for use in charts
// This avoids importing from the full "echarts" package which has a separate registry
// zrender is a direct dependency of echarts and always available
// eslint-disable-next-line import/no-extraneous-dependencies
import LinearGradient from "zrender/lib/graphic/LinearGradient";
import "./axis-proxy-patch";
+1 -1
View File
@@ -73,7 +73,7 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
"Safe mode",
text:
this.hass!.localize("ui.dialogs.safe_mode.text") ||
"Home Assistant is running in safe mode, custom integrations and community frontend modules are not available. Restart Home Assistant to exit safe mode.",
"Home Assistant is running in safe mode, custom integrations and modules are not available. Restart Home Assistant to exit safe mode.",
});
}
);
+3 -17
View File
@@ -68,11 +68,6 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
private __loadedFragmentTranslations = new Set<string>();
private __inflightFragmentTranslations = new Map<
string,
Promise<LocalizeFunc>
>();
private __loadedTranslations: Record<string, LoadedTranslationCategory> =
{};
@@ -263,7 +258,6 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
this._applyDirection(hass);
this._loadCoreTranslations(hass.language);
this.__loadedFragmentTranslations = new Set();
this.__inflightFragmentTranslations = new Map();
this._loadFragmentTranslations(hass.language, hass.panelUrl);
}
@@ -386,20 +380,12 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
return undefined;
}
if (this.__inflightFragmentTranslations.has(fragment)) {
return this.__inflightFragmentTranslations.get(fragment)!;
}
if (this.__loadedFragmentTranslations.has(fragment)) {
return this.hass!.localize;
}
const promise = getTranslation(fragment, language).then((result) =>
this._updateResources(language, result.data).finally(() => {
this.__inflightFragmentTranslations.delete(fragment);
this.__loadedFragmentTranslations.add(fragment);
})
);
this.__inflightFragmentTranslations.set(fragment, promise);
return promise;
this.__loadedFragmentTranslations.add(fragment);
const result = await getTranslation(fragment, language);
return this._updateResources(language, result.data);
}
private async _loadCoreTranslations(language: string) {
+8 -23
View File
@@ -16,7 +16,6 @@
"security": "Security",
"climate": "Climate",
"home": "Overview",
"maintenance": "Maintenance",
"notfound": "Page not found"
},
"state": {
@@ -218,8 +217,6 @@
"all_secure": "All secure",
"no_media_playing": "No media playing",
"count_media_playing": "{count} {count, plural,\n one {playing}\n other {playing}\n}",
"count_maintenance_issues": "{count} {count, plural,\n one {issue}\n other {issues}\n}",
"all_maintenance_good": "All good",
"count_persons_home": "{count} {count, plural,\n one {person}\n other {people}\n} home",
"nobody_home": "No one home"
},
@@ -1063,7 +1060,6 @@
"this_month": "This month",
"now-7d": "Last 7 days",
"now-30d": "Last 30 days",
"now-365d": "Last 365 days",
"this_year": "This year",
"now-12m": "Last 12 months"
}
@@ -1444,7 +1440,7 @@
},
"safe_mode": {
"title": "Safe mode",
"text": "Home Assistant is running in safe mode, custom integrations and community frontend modules are not available. Restart Home Assistant to exit safe mode."
"text": "Home Assistant is running in safe mode, custom integrations and frontend modules are not available. Restart Home Assistant to exit safe mode."
},
"quick-bar": {
"commands_title": "Commands",
@@ -2011,9 +2007,9 @@
},
"restart-safe-mode": {
"title": "Restart Home Assistant in safe mode",
"description": "Restarts Home Assistant without loading any custom integrations and community frontend modules.",
"description": "Restarts Home Assistant without loading any custom integrations and frontend modules.",
"confirm_title": "Restart Home Assistant in safe mode?",
"confirm_description": "This will restart Home Assistant without loading any custom integrations and community frontend modules.",
"confirm_description": "This will restart Home Assistant without loading any custom integrations and frontend modules.",
"confirm_action": "Restart",
"confirm_action_backup": "Wait and Restart",
"failed": "Failed to restart Home Assistant"
@@ -7813,7 +7809,7 @@
"ping_device": "Ping device",
"open_commissioning_window": "Share device",
"manage_fabrics": "Manage fabrics",
"manage_lock": "Manage access",
"manage_lock": "Manage lock",
"view_thread_network": "View Thread network"
},
"manage_fabrics": {
@@ -7856,8 +7852,8 @@
"copy_code": "Copy code"
},
"lock": {
"manage": "Manage access",
"dialog_title": "Manage access",
"manage": "Manage lock",
"dialog_title": "Manage lock",
"users": {
"title": "Users",
"add": "Add user",
@@ -7941,10 +7937,6 @@
"load_failed": "Failed to load lock information",
"save_failed": "Failed to save changes",
"not_supported": "This feature is not supported by your lock",
"no_user_management": "This lock does not support user management. Users and credentials cannot be managed here.",
"pin_not_supported": "This lock does not support PIN codes. You can still view and manage existing users.",
"no_credential_types_supported": "This lock does not support any credential types that can be managed here. You can still view existing users.",
"learn_more": "Learn more about lock management",
"user_not_found": "User not found",
"name_required": "Please enter a name for this user.",
"pin_required": "Please enter a PIN code.",
@@ -8262,7 +8254,6 @@
"media_players": "Media players",
"other_devices": "Other devices",
"weather": "Weather",
"maintenance": "Maintenance",
"energy": "Today's energy",
"persons": "Presence"
},
@@ -8301,10 +8292,6 @@
"devices": "Devices",
"other_devices": "Other devices"
},
"maintenance": {
"devices": "Devices",
"other_devices": "Other devices"
},
"home_media_players": {
"media_players": "Media players",
"other_media_players": "Other media players"
@@ -9423,8 +9410,6 @@
"state": "State",
"attribute": "Attribute"
},
"conditions": "Entity visibility conditions",
"conditions_helper": "Each selected entity will be shown when all conditions below are met. If no conditions are set, entities are always shown.",
"description": "This card allows you to display entities on a map."
},
"markdown": {
@@ -10017,7 +10002,7 @@
},
"badge_picker": {
"no_description": "No description available.",
"custom_badge": "Community",
"custom_badge": "Custom",
"domain": "Domain",
"entity": "Entity",
"by_entity": "By entity",
@@ -10716,7 +10701,7 @@
"external_panel": {
"question_trust": "Do you trust the external panel {name} at {link}?",
"complete_access": "It will have access to all data in Home Assistant.",
"hide_message": "Check docs for the 'Custom panel' integration to hide this message"
"hide_message": "Check docs for the panel_custom component to hide this message"
}
},
"energy": {
@@ -1,147 +0,0 @@
import { describe, it, expect } from "vitest";
import type { MatterLockInfo } from "../../../../../src/data/matter-lock";
/**
* These tests verify the display logic for the lock management dialog,
* ensuring the correct alert is shown based on lock capabilities.
*/
type AlertState =
| "no_user_management"
| "no_credential_types_supported"
| "pin_not_supported"
| "full_support";
/**
* Mirrors the branching logic in dialog-matter-lock-manage.ts render()
* and _renderUsers() to determine which alert (if any) to display.
*/
function getAlertState(lockInfo: MatterLockInfo | undefined): AlertState {
if (lockInfo && !lockInfo.supports_user_management) {
return "no_user_management";
}
const hasNoManageableCredentials =
!lockInfo?.supported_credential_types?.length;
if (hasNoManageableCredentials) {
return "no_credential_types_supported";
}
const supportsPinCredential =
lockInfo?.supported_credential_types?.includes("pin") ?? false;
if (!supportsPinCredential) {
return "pin_not_supported";
}
return "full_support";
}
describe("dialog-matter-lock-manage alert logic", () => {
it("shows no_user_management when lock does not support user management", () => {
const lockInfo: MatterLockInfo = {
supports_user_management: false,
supported_credential_types: [],
max_users: null,
max_pin_users: null,
max_rfid_users: null,
max_credentials_per_user: null,
min_pin_length: null,
max_pin_length: null,
min_rfid_length: null,
max_rfid_length: null,
};
expect(getAlertState(lockInfo)).toBe("no_user_management");
});
it("shows no_user_management even if credential types are listed", () => {
const lockInfo: MatterLockInfo = {
supports_user_management: false,
supported_credential_types: ["pin"],
max_users: null,
max_pin_users: null,
max_rfid_users: null,
max_credentials_per_user: null,
min_pin_length: 4,
max_pin_length: 8,
min_rfid_length: null,
max_rfid_length: null,
};
expect(getAlertState(lockInfo)).toBe("no_user_management");
});
it("shows no_credential_types_supported when user management is supported but no credential types", () => {
const lockInfo: MatterLockInfo = {
supports_user_management: true,
supported_credential_types: [],
max_users: 10,
max_pin_users: null,
max_rfid_users: null,
max_credentials_per_user: null,
min_pin_length: null,
max_pin_length: null,
min_rfid_length: null,
max_rfid_length: null,
};
expect(getAlertState(lockInfo)).toBe("no_credential_types_supported");
});
it("shows pin_not_supported when user management is supported with non-pin credentials only", () => {
const lockInfo: MatterLockInfo = {
supports_user_management: true,
supported_credential_types: ["rfid"],
max_users: 10,
max_pin_users: null,
max_rfid_users: 5,
max_credentials_per_user: 3,
min_pin_length: null,
max_pin_length: null,
min_rfid_length: 4,
max_rfid_length: 8,
};
expect(getAlertState(lockInfo)).toBe("pin_not_supported");
});
it("shows full_support when user management and pin are both supported", () => {
const lockInfo: MatterLockInfo = {
supports_user_management: true,
supported_credential_types: ["pin"],
max_users: 10,
max_pin_users: 10,
max_rfid_users: null,
max_credentials_per_user: 5,
min_pin_length: 4,
max_pin_length: 8,
min_rfid_length: null,
max_rfid_length: null,
};
expect(getAlertState(lockInfo)).toBe("full_support");
});
it("shows full_support when both pin and rfid are supported", () => {
const lockInfo: MatterLockInfo = {
supports_user_management: true,
supported_credential_types: ["pin", "rfid"],
max_users: 10,
max_pin_users: 10,
max_rfid_users: 5,
max_credentials_per_user: 5,
min_pin_length: 4,
max_pin_length: 8,
min_rfid_length: 4,
max_rfid_length: 8,
};
expect(getAlertState(lockInfo)).toBe("full_support");
});
it("handles undefined lockInfo as no_credential_types_supported", () => {
expect(getAlertState(undefined)).toBe("no_credential_types_supported");
});
});

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