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
271 changed files with 3682 additions and 7226 deletions
-3
View File
@@ -3,9 +3,6 @@ contact_links:
- name: Request a feature for the UI / Dashboards
url: https://github.com/orgs/home-assistant/discussions
about: Request a new feature for the Home Assistant frontend.
- name: Discuss UI or UX design
url: https://github.com/OpenHomeFoundation/ux-design/discussions
about: Share design feedback and discuss visual or UX changes with the design team.
- name: Report a bug that is NOT related to the UI / Dashboards
url: https://github.com/home-assistant/core/issues
about: This is the issue tracker for our frontend. Please report other issues in the backend ("core") repository.
+2 -2
View File
@@ -98,13 +98,13 @@ jobs:
env:
IS_TEST: "true"
- name: Upload bundle stats
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: frontend-bundle-stats
path: build/stats/*.json
if-no-files-found: error
- name: Upload frontend build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: frontend-build
path: hass_frontend/
+2 -2
View File
@@ -59,14 +59,14 @@ jobs:
run: tar -czvf translations.tar.gz translations
- name: Upload build artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: wheels
path: dist/home_assistant_frontend*.whl
if-no-files-found: error
- name: Upload translations
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: translations
path: translations.tar.gz
+1 -1
View File
@@ -18,6 +18,6 @@ jobs:
pull-requests: read
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0
- uses: release-drafter/release-drafter@139054aeaa9adc52ab36ddf67437541f039b88e2 # v7.1.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+2 -2
View File
@@ -36,7 +36,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@f6f29a7ee3fa0eccadf3620a7b9ee00ab54ec03b # master
uses: home-assistant/actions/helpers/verify-version@d56d093b9ab8d2105bc0cb6ee9bcc0ef4ec8b96d # master
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -58,7 +58,7 @@ jobs:
script/release
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
with:
skip-existing: true
+1 -1
View File
@@ -57,4 +57,4 @@ test/coverage/
# AI tooling
.claude
.cursor
.opencode
+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,
@@ -57,7 +57,7 @@ Check the [webawesome documentation](https://webawesome.com/docs/components/butt
| ---------- | ---------------------------------------------- | -------- | --------------------------------------------------------------------------------- |
| appearance | "accent"/"filled"/"plain" | "accent" | Sets the button appearance. |
| variants | "brand"/"danger"/"neutral"/"warning"/"success" | "brand" | Sets the button color variant. "brand" is default. |
| size | "small"/"medium"/"large" | "medium" | Sets the button size. |
| size | "small"/"medium" | "medium" | Sets the button size. |
| loading | Boolean | false | Shows a loading indicator instead of the buttons label and disable buttons click. |
| disabled | Boolean | false | Disables the button and prevents user interaction. |
+4 -12
View File
@@ -10,7 +10,7 @@ import "../../../../src/components/input/ha-input";
import "../../../../src/components/input/ha-input-copy";
import "../../../../src/components/input/ha-input-multi";
import "../../../../src/components/input/ha-input-search";
import { internationalizationContext } from "../../../../src/data/context";
import { localizeContext } from "../../../../src/data/context";
const LOCALIZE_KEYS: Record<string, string> = {
"ui.common.copy": "Copy",
@@ -26,19 +26,11 @@ const LOCALIZE_KEYS: Record<string, string> = {
export class DemoHaInput extends LitElement {
constructor() {
super();
// Provides internationalizationContext for ha-input-copy, ha-input-multi and ha-input-search
// Provides localizeContext for ha-input-copy, ha-input-multi and ha-input-search
// eslint-disable-next-line no-new
new ContextProvider(this, {
context: internationalizationContext,
initialValue: {
localize: ((key: string) => LOCALIZE_KEYS[key] ?? key) as any,
language: "en",
selectedLanguage: null,
locale: {} as any,
translationMetadata: {} as any,
loadBackendTranslation: (async () => (key: string) => key) as any,
loadFragmentTranslation: (async () => (key: string) => key) as any,
},
context: localizeContext,
initialValue: ((key: string) => LOCALIZE_KEYS[key] ?? key) as any,
});
}
@@ -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,3 +0,0 @@
---
title: Box shadow
---
-98
View File
@@ -1,98 +0,0 @@
import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
const SHADOWS = ["s", "m", "l"] as const;
@customElement("demo-misc-box-shadow")
export class DemoMiscBoxShadow extends LitElement {
protected render() {
return html`
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<h2>${mode}</h2>
<div class="grid">
${SHADOWS.map(
(size) => html`
<div
class="box"
style="box-shadow: var(--ha-box-shadow-${size})"
>
${size}
</div>
`
)}
</div>
</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;
flex-direction: row;
gap: 48px;
padding: 48px;
}
.light,
.dark {
flex: 1;
background-color: var(--primary-background-color);
border-radius: 16px;
padding: 32px;
}
h2 {
margin: 0 0 24px;
font-size: 18px;
font-weight: 500;
color: var(--primary-text-color);
text-transform: capitalize;
}
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 32px;
}
.box {
display: flex;
align-items: center;
justify-content: center;
height: 120px;
border-radius: 12px;
background-color: var(--card-background-color);
color: var(--primary-text-color);
font-size: 16px;
font-weight: 500;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-misc-box-shadow": DemoMiscBoxShadow;
}
}
@@ -1,5 +1,7 @@
import { IntersectionController } from "@lit-labs/observers/intersection-controller.js";
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";
import { classMap } from "lit/directives/class-map";
+2 -2
View File
@@ -1,3 +1,4 @@
import "@material/mwc-linear-progress";
import { mdiOpenInNew } from "@mdi/js";
import { css, html, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -7,7 +8,6 @@ import "../../src/components/ha-button";
import "../../src/components/ha-fade-in";
import "../../src/components/ha-spinner";
import "../../src/components/ha-svg-icon";
import "../../src/components/progress/ha-progress-bar";
import { makeDialogManager } from "../../src/dialogs/make-dialog-manager";
import "../../src/onboarding/onboarding-welcome-links";
import { onBoardingStyles } from "../../src/onboarding/styles";
@@ -60,7 +60,7 @@ class HaLandingPage extends LandingPageBaseElement {
${!networkIssue && !this._supervisorError
? html`
<p>${this.localize("subheader")}</p>
<ha-progress-bar indeterminate></ha-progress-bar>
<mwc-linear-progress indeterminate></mwc-linear-progress>
`
: nothing}
${networkIssue || this._networkInfoError
+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",
],
+29 -25
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",
@@ -59,14 +59,22 @@
"@lit-labs/virtualizer": "2.1.1",
"@lit/context": "1.1.6",
"@lit/reactive-element": "2.1.2",
"@material/chips": "=14.0.0-canary.53b3cad2f.0",
"@material/data-table": "=14.0.0-canary.53b3cad2f.0",
"@material/mwc-base": "0.27.0",
"@material/mwc-checkbox": "0.27.0",
"@material/mwc-dialog": "0.27.0",
"@material/mwc-drawer": "0.27.0",
"@material/mwc-fab": "0.27.0",
"@material/mwc-floating-label": "0.27.0",
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
"@material/mwc-linear-progress": "0.27.0",
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"@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",
@@ -74,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",
@@ -94,7 +102,7 @@
"dialog-polyfill": "0.5.6",
"echarts": "6.0.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.3.0",
"fuse.js": "7.1.0",
"google-timezones-json": "1.2.0",
"gulp-zopfli-green": "7.0.0",
"hls.js": "1.6.15",
@@ -108,7 +116,7 @@
"lit": "3.3.2",
"lit-html": "3.3.2",
"luxon": "3.7.2",
"marked": "18.0.0",
"marked": "17.0.5",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -136,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.59.0",
"@lokalise/node-api": "15.7.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",
@@ -163,17 +169,16 @@
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.1.4",
"@vitest/coverage-v8": "4.1.2",
"babel-loader": "10.1.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.4",
"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",
@@ -181,14 +186,13 @@
"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",
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "29.0.2",
"jsdom": "29.0.1",
"jszip": "3.10.1",
"lint-staged": "16.4.0",
"lit-analyzer": "2.0.3",
@@ -196,17 +200,17 @@
"lodash.template": "4.5.0",
"map-stream": "0.0.7",
"pinst": "3.0.0",
"prettier": "3.8.2",
"prettier": "3.8.1",
"rspack-manifest-plugin": "5.2.1",
"serve": "14.2.6",
"sinon": "21.1.0",
"sinon": "21.0.3",
"tar": "7.5.13",
"terser-webpack-plugin": "5.4.0",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.2",
"typescript-eslint": "8.58.1",
"typescript-eslint": "8.57.2",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.4",
"vitest": "4.1.2",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.0#~/.yarn/patches/workbox-build-npm-7.4.0-c84561662c.patch"
@@ -21,9 +21,6 @@ export const filterNavigationPages = (
if (page.path === "#external-app-configuration") {
return hass.auth.external?.config.hasSettingsScreen;
}
if (page.adminOnly && !hass.user?.is_admin) {
return false;
}
// Only show Bluetooth page if there are Bluetooth config entries
if (page.component === "bluetooth") {
return options.hasBluetoothConfigEntries ?? false;
-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.
*
+2 -8
View File
@@ -7,8 +7,7 @@ export type LeafletModuleType = typeof import("leaflet");
export type LeafletDrawModuleType = typeof import("leaflet-draw");
export const setupLeafletMap = async (
mapElement: HTMLElement,
initialView?: { latitude: number; longitude: number; zoom?: number }
mapElement: HTMLElement
): Promise<[Map, LeafletModuleType, TileLayer]> => {
if (!mapElement.parentNode) {
throw new Error("Cannot setup Leaflet map on disconnected element");
@@ -33,12 +32,7 @@ export const setupLeafletMap = async (
markerClusterStyle.setAttribute("rel", "stylesheet");
mapElement.parentNode.appendChild(markerClusterStyle);
if (initialView) {
map.setView(
[initialView.latitude, initialView.longitude],
initialView.zoom ?? 13
);
}
map.setView([52.3731339, 4.8903147], 13);
const tileLayer = createTileLayer(Leaflet).addTo(map);
+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 {
+4 -82
View File
@@ -18,16 +18,15 @@ import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { ensureArray } from "../../common/array/ensure-array";
import { getAllGraphColors } from "../../common/color/colors";
import { transform } from "../../common/decorators/transform";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { listenMediaQuery } from "../../common/dom/media_query";
import { afterNextRender } from "../../common/util/render-status";
import { filterXSS } from "../../common/util/xss";
import { uiContext } from "../../data/context";
import { themesContext } from "../../data/context";
import type { Themes } from "../../data/ws-themes";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant, HomeAssistantUI } from "../../types";
import type { HomeAssistant } from "../../types";
import { isMac } from "../../util/is_mac";
import "../chips/ha-assist-chip";
import "../ha-icon-button";
@@ -75,11 +74,8 @@ export class HaChartBase extends LitElement {
public extraComponents?: any[];
@state()
@consume({ context: uiContext, subscribe: true })
@transform<HomeAssistantUI, Themes>({
transformer: ({ themes }) => themes,
})
private _themes!: Themes;
@consume({ context: themesContext, subscribe: true })
_themes!: Themes;
@state() private _isZoomed = false;
@@ -178,7 +174,6 @@ export class HaChartBase extends LitElement {
if (!this.options?.dataZoom) {
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
}
this._updateSankeyRoam();
// drag to zoom
this.chart?.dispatchAction({
type: "takeGlobalCursor",
@@ -197,7 +192,6 @@ export class HaChartBase extends LitElement {
if (!this.options?.dataZoom) {
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
}
this._updateSankeyRoam();
this.chart?.dispatchAction({
type: "takeGlobalCursor",
key: "dataZoomSelect",
@@ -273,9 +267,6 @@ export class HaChartBase extends LitElement {
}
if (Object.keys(chartOptions).length > 0) {
this._setChartOptions(chartOptions);
if (chartOptions.series) {
this._updateSankeyRoam();
}
}
}
@@ -460,22 +451,6 @@ export class HaChartBase extends LitElement {
this.chart.on("click", (e: ECElementEvent) => {
fireEvent(this, "chart-click", e);
});
this.chart.on("sankeyroam", () => {
const option = this.chart!.getOption();
const series = option.series as any[];
const sankeySeries = series?.find((s: any) => s.type === "sankey");
const zoomed = sankeySeries.zoom !== 1;
this._isZoomed = zoomed;
if (!zoomed) {
// Reset center when fully zoomed out
this.chart!.setOption({
series: [{ id: sankeySeries.id, center: null }],
});
}
fireEvent(this, "chart-sankeyroam", { zoom: sankeySeries.zoom });
// Clear cached emphasis states so labels don't revert to pre-zoom sizes
this.chart!.dispatchAction({ type: "downplay" });
});
if (!this.options?.dataZoom) {
this.chart.getZr().on("dblclick", this._handleClickZoom);
@@ -574,7 +549,6 @@ export class HaChartBase extends LitElement {
...this._createOptions(),
series: this._getSeries(),
});
this._updateSankeyRoam();
} finally {
this._loading = false;
}
@@ -1014,26 +988,6 @@ export class HaChartBase extends LitElement {
if (!this.chart) {
return;
}
// Handle sankey chart double-click zoom
const option = this.chart.getOption();
const allSeries = option.series as any[];
const sankeySeries = allSeries?.filter((s: any) => s.type === "sankey");
if (sankeySeries?.length) {
if (this._isZoomed) {
this._handleZoomReset();
} else {
this.chart.setOption({
series: sankeySeries.map((s: any) => ({
id: s.id,
zoom: 2,
})),
});
this._isZoomed = true;
}
if (sankeySeries.length === allSeries?.length) {
return;
}
}
const range = this._isZoomed
? [0, 100]
: [
@@ -1058,37 +1012,6 @@ export class HaChartBase extends LitElement {
private _handleZoomReset() {
this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 });
// Reset sankey roam zoom
const option = this.chart?.getOption();
const sankeySeries = (option?.series as any[])?.filter(
(s: any) => s.type === "sankey"
);
if (sankeySeries?.length) {
this.chart?.setOption({
series: sankeySeries.map((s: any) => ({
id: s.id,
zoom: 1,
center: null,
})),
});
this._isZoomed = false;
fireEvent(this, "chart-sankeyroam", { zoom: 1 });
}
}
private _updateSankeyRoam() {
const option = this.chart?.getOption();
const sankeySeries = (option?.series as any[])?.filter(
(s: any) => s.type === "sankey"
);
if (sankeySeries?.length) {
this.chart?.setOption({
series: sankeySeries.map((s: any) => ({
id: s.id,
roam: this._modifierPressed || this._isTouchDevice ? true : "move",
})),
});
}
}
private _handleDataZoomEvent(e: any) {
@@ -1459,6 +1382,5 @@ declare global {
start: number;
end: number;
};
"chart-sankeyroam": { zoom: number };
}
}
+2 -11
View File
@@ -64,8 +64,6 @@ export class HaSankeyChart extends LitElement {
public chart?: EChartsType;
private _currentZoom = 1;
@state() private _sizeController = new ResizeController(this, {
callback: (entries) => entries[0]?.contentRect,
});
@@ -86,13 +84,11 @@ export class HaSankeyChart extends LitElement {
} as ECOption;
return html`<ha-chart-base
.hass=${this.hass}
.data=${this._createData(this.data, this._sizeController.value?.width)}
.options=${options}
height="100%"
.extraComponents=${[SankeyChart]}
@chart-click=${this._handleChartClick}
@chart-sankeyroam=${this._handleChartSankeyRoam}
></ha-chart-base>`;
}
@@ -113,10 +109,6 @@ export class HaSankeyChart extends LitElement {
return null;
};
private _handleChartSankeyRoam = (ev: CustomEvent) => {
this._currentZoom = ev.detail.zoom;
};
private _handleChartClick = (ev: CustomEvent<ECElementEvent>) => {
const detail = ev.detail;
// Only handle node clicks (not links)
@@ -188,7 +180,6 @@ export class HaSankeyChart extends LitElement {
})),
links,
draggable: false,
scaleLimit: { min: 1, max: 4 },
orient: this.vertical ? "vertical" : "horizontal",
nodeWidth: 15,
nodeGap: NODE_GAP,
@@ -219,7 +210,7 @@ export class HaSankeyChart extends LitElement {
""
);
const wordWidth = measureTextWidth(longestWord, FONT_SIZE);
const availableWidth = (params.rect.width + 6) * this._currentZoom;
const availableWidth = params.rect.width + 6;
const fontSize = Math.min(
FONT_SIZE,
(availableWidth / wordWidth) * FONT_SIZE
@@ -232,7 +223,7 @@ export class HaSankeyChart extends LitElement {
};
}
const availableHeight = (params.rect.height + 8) * this._currentZoom; // account for the margin
const availableHeight = params.rect.height + 8; // account for the margin
const fontSize = Math.min(
(availableHeight / params.labelRect.height) * FONT_SIZE,
FONT_SIZE
+12 -11
View File
@@ -1,8 +1,8 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiRestart } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, eventOptions, property, state } from "lit/decorators";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiRestart } from "@mdi/js";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import type {
@@ -12,12 +12,12 @@ import type {
} from "../../data/history";
import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types";
import "../ha-button";
import type { StateHistoryChartLine } from "./state-history-chart-line";
import type { StateHistoryChartTimeline } from "./state-history-chart-timeline";
import "../ha-fab";
import "../ha-svg-icon";
import "./state-history-chart-line";
import type { StateHistoryChartLine } from "./state-history-chart-line";
import "./state-history-chart-timeline";
import type { StateHistoryChartTimeline } from "./state-history-chart-timeline";
const CANVAS_TIMELINE_ROWS_CHUNK = 10; // Split up the canvases to avoid hitting the render limit
@@ -150,14 +150,16 @@ export class StateHistoryCharts extends LitElement {
this._renderHistoryItem(item, index)
)}`}
${this.syncCharts && this._hasZoomedCharts
? html`<ha-button
size="large"
? html`<ha-fab
slot="fab"
class="reset-button"
.label=${this.hass.localize(
"ui.components.history_charts.zoom_reset"
)}
@click=${this._handleGlobalZoomReset}
>
<ha-svg-icon slot="start" .path=${mdiRestart}></ha-svg-icon>
${this.hass.localize("ui.components.history_charts.zoom_reset")}
</ha-button>`
<ha-svg-icon slot="icon" .path=${mdiRestart}></ha-svg-icon>
</ha-fab>`
: nothing}
`;
}
@@ -446,7 +448,6 @@ export class StateHistoryCharts extends LitElement {
bottom: calc(24px + var(--safe-area-inset-bottom));
right: calc(24px + var(--safe-area-inset-bottom));
z-index: 1;
--ha-button-box-shadow: var(--ha-box-shadow-l);
}
`;
}
+44 -87
View File
@@ -16,18 +16,13 @@ import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { STRINGS_SEPARATOR_DOT } from "../../common/const";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import { deepActiveElement } from "../../common/dom/deep-active-element";
import type {
HASSDomCurrentTargetEvent,
HASSDomTargetEvent,
} from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { stringCompare } from "../../common/string/compare";
import type { LocalizeFunc } from "../../common/translations/localize";
import { debounce } from "../../common/util/debounce";
import { groupBy } from "../../common/util/group-by";
import { nextRender } from "../../common/util/render-status";
import { internationalizationContext } from "../../data/context";
import { localeContext, localizeContext } from "../../data/context";
import type { FrontendLocaleData } from "../../data/translation";
import { haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer";
@@ -108,13 +103,16 @@ 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 {
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
@consume({ context: localizeContext, subscribe: true })
private _localize?: ContextType<typeof localizeContext>;
@state()
@consume({ context: localeContext, subscribe: true })
private _locale?: ContextType<typeof localeContext>;
@property({ type: Boolean }) public narrow = false;
@@ -168,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;
@@ -248,30 +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");
}
const activeElement = deepActiveElement();
if (
changedProps.has("selectable") ||
(!this.autoHeight &&
activeElement &&
AUTO_FOCUS_ALLOWED_ACTIVE_TAGS.includes(activeElement.tagName))
) {
this._focusScroller();
}
}
public willUpdate(properties: PropertyValues) {
@@ -527,9 +507,7 @@ export class HaDataTable extends LitElement {
<div class="mdc-data-table__row" role="row">
<div class="mdc-data-table__cell grows center" role="cell">
${this.noDataText ||
this._i18n?.localize?.(
"ui.components.data-table.no-data"
) ||
this._localize?.("ui.components.data-table.no-data") ||
"No data"}
</div>
</div>
@@ -539,12 +517,11 @@ 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,
this._i18n?.localize,
this._i18n?.locale,
this._localize,
this._locale,
this.appendRow,
this.groupColumn,
this.groupOrder,
@@ -714,7 +691,7 @@ export class HaDataTable extends LitElement {
this._sortColumns[this.sortColumn],
this.sortDirection,
this.sortColumn,
this._i18n?.locale?.language
this._locale?.language
)
: filteredData;
@@ -852,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;
}
@@ -873,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 = [];
@@ -887,15 +861,14 @@ 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,
this._i18n?.localize,
this._i18n?.locale,
this._localize,
this._locale,
this.appendRow,
this.groupColumn,
this.groupOrder,
@@ -927,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];
}
@@ -965,9 +938,7 @@ export class HaDataTable extends LitElement {
return checkedRows;
}
private _handleRowClick = (
ev: HASSDomCurrentTargetEvent<HTMLElement & { rowId: string }>
) => {
private _handleRowClick = (ev: Event) => {
if (
ev
.composedPath()
@@ -983,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);
}
}
@@ -1011,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;
@@ -1026,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
@@ -1469,11 +1431,6 @@ export class HaDataTable extends LitElement {
contain: size layout !important;
overscroll-behavior: contain;
}
lit-virtualizer:focus,
lit-virtualizer:focus-visible {
outline: none;
}
`,
];
}
+34 -61
View File
@@ -3,7 +3,6 @@ import { consume, type ContextType } from "@lit/context";
import type { ActionDetail } from "@material/mwc-list";
import { mdiCalendarToday } from "@mdi/js";
import "cally";
import type { HassConfig } from "home-assistant-js-websocket/dist/types";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, queryAll, state } from "lit/decorators";
import { firstWeekdayIndex } from "../../common/datetime/first_weekday";
@@ -13,13 +12,16 @@ import {
formatDateYear,
formatISODateOnly,
} from "../../common/datetime/format_date";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import { configContext, internationalizationContext } from "../../data/context";
import {
configContext,
localeContext,
localizeContext,
} from "../../data/context";
import { TimeZone } from "../../data/translation";
import { MobileAwareMixin } from "../../mixins/mobile-aware-mixin";
import { haStyleScrollbar } from "../../resources/styles";
import type { HomeAssistantConfig, ValueChangedEvent } from "../../types";
import type { ValueChangedEvent } from "../../types";
import "../chips/ha-chip-set";
import "../chips/ha-filter-chip";
import type { HaFilterChip } from "../chips/ha-filter-chip";
@@ -46,15 +48,16 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
public timePicker = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
@state()
@consume({ context: localeContext, subscribe: true })
private locale!: ContextType<typeof localeContext>;
@state()
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
private _hassConfig!: HassConfig;
private hassConfig!: ContextType<typeof configContext>;
/** used to show month in calendar-range header */
@state() private _pickerMonth?: string;
@@ -84,20 +87,12 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
? formatCallyDateRange(
this.startDate,
this.endDate,
this._i18n?.locale,
this._hassConfig
this.locale,
this.hassConfig
)
: undefined;
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig);
this._pickerYear = formatDateYear(date, this.locale, this.hassConfig);
if (this.timePicker && this.startDate && this.endDate) {
this._timeValue = {
@@ -149,12 +144,12 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
<div class="range">
<calendar-range
.value=${this._dateValue}
.locale=${this._i18n.locale.language}
.locale=${this.locale.language}
.focusedDate=${this._focusDate}
@focusday=${this._focusChanged}
@change=${this._handleChange}
show-outside-days
.firstDayOfWeek=${firstWeekdayIndex(this._i18n.locale)}
.firstDayOfWeek=${firstWeekdayIndex(this.locale)}
>
<ha-icon-button-prev
tabindex="-1"
@@ -167,7 +162,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
<ha-icon-button
@click=${this._focusToday}
.path=${mdiCalendarToday}
.label=${this._i18n.localize("ui.dialogs.date-picker.today")}
.label=${this.localize("ui.dialogs.date-picker.today")}
></ha-icon-button>
</div>
<ha-icon-button-next
@@ -181,9 +176,9 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
<div class="times">
<ha-time-input
.value=${`${this._timeValue.from.hours}:${this._timeValue.from.minutes}`}
.locale=${this._i18n.locale}
.locale=${this.locale}
@value-changed=${this._handleChangeTime}
.label=${this._i18n.localize(
.label=${this.localize(
"ui.components.date-range-picker.time_from"
)}
id="from"
@@ -192,9 +187,9 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
></ha-time-input>
<ha-time-input
.value=${`${this._timeValue.to.hours}:${this._timeValue.to.minutes}`}
.locale=${this._i18n.locale}
.locale=${this.locale}
@value-changed=${this._handleChangeTime}
.label=${this._i18n.localize(
.label=${this.localize(
"ui.components.date-range-picker.time_to"
)}
id="to"
@@ -208,33 +203,19 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
</div>
<div class="footer">
<ha-button appearance="plain" @click=${this._cancel}
>${this._i18n.localize("ui.common.cancel")}</ha-button
>${this.localize("ui.common.cancel")}</ha-button
>
<ha-button .disabled=${!this._dateValue} @click=${this._save}
>${this._i18n.localize(
"ui.components.date-range-picker.select"
)}</ha-button
>${this.localize("ui.components.date-range-picker.select")}</ha-button
>
</div>`;
}
private _focusToday() {
const date = new Date();
this._focusDate = formatISODateOnly(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._focusDate = formatISODateOnly(date, this.locale, this.hassConfig);
this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig);
this._pickerYear = formatDateYear(date, this.locale, this.hassConfig);
}
private _cancel() {
@@ -274,12 +255,12 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
}
}
if (this._i18n.locale.time_zone === TimeZone.server) {
if (this.locale.time_zone === TimeZone.server) {
startDate = new Date(
new TZDate(startDate, this._hassConfig.time_zone).getTime()
new TZDate(startDate, this.hassConfig.time_zone).getTime()
);
endDate = new Date(
new TZDate(endDate, this._hassConfig.time_zone).getTime()
new TZDate(endDate, this.hassConfig.time_zone).getTime()
);
}
@@ -305,16 +286,8 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
private _focusChanged(ev: CustomEvent<Date>) {
const date = ev.detail;
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig);
this._pickerYear = formatDateYear(date, this.locale, this.hassConfig);
this._focusDate = undefined;
}
@@ -3,7 +3,6 @@ import { consume, type ContextType } from "@lit/context";
import { mdiCalendar } from "@mdi/js";
import "cally";
import { isThisYear } from "date-fns";
import type { HassConfig } from "home-assistant-js-websocket/dist/types";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -15,10 +14,12 @@ import {
formatShortDateTime,
formatShortDateTimeWithYear,
} from "../../common/datetime/format_date_time";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import { configContext, internationalizationContext } from "../../data/context";
import type { HomeAssistantConfig } from "../../types";
import {
configContext,
localeContext,
localizeContext,
} from "../../data/context";
import "../ha-bottom-sheet";
import "../ha-icon-button";
import "../ha-icon-button-next";
@@ -42,15 +43,16 @@ const EXTENDED_RANGE_KEYS: DateRange[] = [
@customElement("ha-date-range-picker")
export class HaDateRangePicker extends LitElement {
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
@state()
@consume({ context: localeContext, subscribe: true })
private locale!: ContextType<typeof localeContext>;
@state()
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
private _hassConfig!: HassConfig;
private hassConfig!: ContextType<typeof configContext>;
@property({ attribute: false }) public startDate!: Date;
@@ -115,8 +117,8 @@ export class HaDateRangePicker extends LitElement {
this._ranges = {};
rangeKeys.forEach((key) => {
this._ranges![
this._i18n.localize(`ui.components.date-range-picker.ranges.${key}`)
] = calcDateRange(this._i18n.locale, this._hassConfig, key);
this.localize(`ui.components.date-range-picker.ranges.${key}`)
] = calcDateRange(this.locale, this.hassConfig, key);
});
}
@@ -131,50 +133,47 @@ 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)
? formatShortDateTime(
this.startDate,
this._i18n.locale,
this._hassConfig
this.locale,
this.hassConfig
)
: formatShortDateTimeWithYear(
this.startDate,
this._i18n.locale,
this._hassConfig
this.locale,
this.hassConfig
)) +
(window.innerWidth >= 459 ? " - " : " - \n") +
(isThisYear(this.endDate)
? formatShortDateTime(
this.endDate,
this._i18n.locale,
this._hassConfig
this.locale,
this.hassConfig
)
: formatShortDateTimeWithYear(
this.endDate,
this._i18n.locale,
this._hassConfig
this.locale,
this.hassConfig
))}
.label=${this._i18n.localize(
.label=${this.localize(
"ui.components.date-range-picker.start_date"
) +
" - " +
this._i18n.localize(
"ui.components.date-range-picker.end_date"
)}
this.localize("ui.components.date-range-picker.end_date")}
.disabled=${this.disabled}
readonly
></ha-textarea>
<ha-icon-button-prev
.label=${this._i18n.localize("ui.common.previous")}
.label=${this.localize("ui.common.previous")}
@click=${this._handlePrev}
>
</ha-icon-button-prev>
<ha-icon-button-next
.label=${this._i18n.localize("ui.common.next")}
.label=${this.localize("ui.common.next")}
@click=${this._handleNext}
>
</ha-icon-button-next>`
@@ -182,7 +181,7 @@ export class HaDateRangePicker extends LitElement {
@click=${this._openPicker}
.disabled=${this.disabled}
id="field"
.label=${this._i18n.localize(
.label=${this.localize(
"ui.components.date-range-picker.select_date_range"
)}
.path=${mdiCalendar}
@@ -290,8 +289,8 @@ export class HaDateRangePicker extends LitElement {
this.startDate,
this.endDate,
forward,
this._i18n.locale,
this._hassConfig
this.locale,
this.hassConfig
);
this.startDate = start;
this.endDate = end;
@@ -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();
}
}
}
}
@@ -2,7 +2,6 @@ import "@home-assistant/webawesome/dist/components/divider/divider";
import { consume, type ContextType } from "@lit/context";
import { mdiBackspace, mdiCalendarToday } from "@mdi/js";
import "cally";
import type { HassConfig } from "home-assistant-js-websocket/dist/types";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import {
@@ -11,10 +10,12 @@ import {
formatDateYear,
formatISODateOnly,
} from "../../common/datetime/format_date";
import { transform } from "../../common/decorators/transform";
import { configContext, internationalizationContext } from "../../data/context";
import {
configContext,
localeContext,
localizeContext,
} from "../../data/context";
import { DialogMixin } from "../../dialogs/dialog-mixin";
import type { HomeAssistantConfig } from "../../types";
import "../ha-button";
import type { DatePickerDialogParams } from "../ha-date-input";
import "../ha-dialog";
@@ -39,15 +40,16 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
LitElement
) {
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
@state()
@consume({ context: localeContext, subscribe: true })
private locale!: ContextType<typeof localeContext>;
@state()
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
private _hassConfig!: HassConfig;
private hassConfig!: ContextType<typeof configContext>;
@state() private _value?: {
year: string;
@@ -72,26 +74,14 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
? new Date(`${this.params.value.split("T")[0]}T00:00:00`)
: new Date();
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(date, this.locale, this.hassConfig);
this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig);
this._value = this.params.value
? {
year: this._pickerYear,
title: formatDateShort(date, this._i18n.locale, this._hassConfig),
dateString: formatISODateOnly(
date,
this._i18n.locale,
this._hassConfig
),
title: formatDateShort(date, this.locale, this.hassConfig),
dateString: formatISODateOnly(date, this.locale, this.hassConfig),
}
: undefined;
}
@@ -105,7 +95,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
open
width="small"
.headerTitle=${this._value?.title ||
this._i18n.localize("ui.dialogs.date-picker.title")}
this.localize("ui.dialogs.date-picker.title")}
.headerSubtitle=${this._value?.year}
header-subtitle-position="above"
>
@@ -113,7 +103,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
? html`
<ha-icon-button
.path=${mdiBackspace}
.label=${this._i18n.localize("ui.dialogs.date-picker.clear")}
.label=${this.localize("ui.dialogs.date-picker.clear")}
slot="headerActionItems"
@click=${this._clear}
></ha-icon-button>
@@ -141,7 +131,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
<ha-icon-button
@click=${this._setToday}
.path=${mdiCalendarToday}
.label=${this._i18n.localize("ui.dialogs.date-picker.today")}
.label=${this.localize("ui.dialogs.date-picker.today")}
></ha-icon-button>
</div>
<ha-icon-button-next tabindex="-1" slot="next"></ha-icon-button-next>
@@ -153,10 +143,10 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
slot="secondaryAction"
@click=${this.closeDialog}
>
${this._i18n.localize("ui.common.cancel")}
${this.localize("ui.common.cancel")}
</ha-button>
<ha-button slot="primaryAction" @click=${this._setValue}>
${this._i18n.localize("ui.common.ok")}
${this.localize("ui.common.ok")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>`;
@@ -174,39 +164,23 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
? new Date(`${value.split("T")[0]}T00:00:00`)
: new Date();
this._value = {
year: formatDateYear(date, this._i18n.locale, this._hassConfig),
title: formatDateShort(date, this._i18n.locale, this._hassConfig),
year: formatDateYear(date, this.locale, this.hassConfig),
title: formatDateShort(date, this.locale, this.hassConfig),
dateString:
value || formatISODateOnly(date, this._i18n.locale, this._hassConfig),
value || formatISODateOnly(date, this.locale, this.hassConfig),
};
if (setFocusDay) {
this._focusDate = this._value.dateString;
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig);
this._pickerYear = formatDateYear(date, this.locale, this.hassConfig);
}
}
private _focusChanged(ev: CustomEvent<Date>) {
const date = ev.detail;
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig);
this._pickerYear = formatDateYear(date, this.locale, this.hassConfig);
this._focusDate = undefined;
}
@@ -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 ??
+1 -10
View File
@@ -27,7 +27,7 @@ export type Appearance = "accent" | "filled" | "outlined" | "plain";
* @cssprop --ha-button-height - The height of the button.
* @cssprop --ha-button-border-radius - The border radius of the button. defaults to `var(--ha-border-radius-pill)`.
*
* @attr {("small"|"medium"|"large")} size - Sets the button size.
* @attr {("small"|"medium")} size - Sets the button size.
* @attr {("brand"|"neutral"|"danger"|"warning"|"success")} variant - Sets the button color variant. "primary" is default.
* @attr {("accent"|"filled"|"plain")} appearance - Sets the button appearance.
* @attr {boolean} loading - shows a loading indicator instead of the buttons label and disable buttons click.
@@ -62,7 +62,6 @@ export class HaButton extends Button {
transition: background-color var(--ha-animation-duration-fast)
ease-out;
text-wrap: wrap;
box-shadow: var(--ha-button-box-shadow);
}
:host([size="small"]) .button {
@@ -74,14 +73,6 @@ export class HaButton extends Button {
--wa-form-control-padding-inline: var(--ha-space-3);
}
:host([size="large"]) .button {
--wa-form-control-height: var(
--ha-button-height,
var(--button-height, 48px)
);
font-size: var(--ha-font-size-l);
}
:host([variant="brand"]) {
--button-color-fill-normal-active: var(
--ha-color-fill-primary-normal-active
+12 -14
View File
@@ -7,7 +7,7 @@ import memoizeOne from "memoize-one";
import { computeCssColor, THEME_COLORS } from "../common/color/compute-color";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeKeys } from "../common/translations/localize";
import { internationalizationContext } from "../data/context";
import { localizeContext } from "../data/context";
import type { UiColorExtraOption } from "../data/selector";
import type { ValueChangedEvent } from "../types";
import "./ha-combo-box-item";
@@ -55,8 +55,8 @@ export class HaColorPicker extends LitElement {
@property({ type: Boolean }) public required = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
render() {
const effectiveValue = this.value ?? this.defaultColor ?? "";
@@ -73,7 +73,7 @@ export class HaColorPicker extends LitElement {
.rowRenderer=${this._rowRenderer}
.valueRenderer=${this._valueRenderer}
@value-changed=${this._valueChanged}
.notFoundLabel=${this._i18n?.localize?.(
.notFoundLabel=${this.localize?.(
"ui.components.color-picker.no_colors_found"
)}
.getAdditionalItems=${this._getAdditionalItems}
@@ -103,7 +103,7 @@ export class HaColorPicker extends LitElement {
{
id: searchString,
primary:
this._i18n?.localize?.("ui.components.color-picker.custom_color") ||
this.localize?.("ui.components.color-picker.custom_color") ||
"Custom color",
secondary: searchString,
},
@@ -130,15 +130,14 @@ export class HaColorPicker extends LitElement {
const items: PickerComboBoxItem[] = [];
const defaultSuffix =
this._i18n?.localize?.("ui.components.color-picker.default") ||
"Default";
this.localize?.("ui.components.color-picker.default") || "Default";
const addDefaultSuffix = (label: string, isDefault: boolean) =>
isDefault && defaultSuffix ? `${label} (${defaultSuffix})` : label;
if (includeNone) {
const noneLabel =
this._i18n?.localize?.("ui.components.color-picker.none") || "None";
this.localize?.("ui.components.color-picker.none") || "None";
items.push({
id: "none",
primary: addDefaultSuffix(noneLabel, defaultColor === "none"),
@@ -148,7 +147,7 @@ export class HaColorPicker extends LitElement {
if (includeState) {
const stateLabel =
this._i18n?.localize?.("ui.components.color-picker.state") || "State";
this.localize?.("ui.components.color-picker.state") || "State";
items.push({
id: "state",
primary: addDefaultSuffix(stateLabel, defaultColor === "state"),
@@ -171,7 +170,7 @@ export class HaColorPicker extends LitElement {
Array.from(THEME_COLORS).forEach((color) => {
const themeLabel =
this._i18n?.localize?.(
this.localize?.(
`ui.components.color-picker.colors.${color}` as LocalizeKeys
) || color;
items.push({
@@ -228,7 +227,7 @@ export class HaColorPicker extends LitElement {
return html`
<ha-svg-icon slot="start" .path=${mdiInvertColorsOff}></ha-svg-icon>
<span slot="headline">
${this._i18n?.localize?.("ui.components.color-picker.none") || "None"}
${this.localize?.("ui.components.color-picker.none") || "None"}
</span>
`;
}
@@ -236,8 +235,7 @@ export class HaColorPicker extends LitElement {
return html`
<ha-svg-icon slot="start" .path=${mdiPalette}></ha-svg-icon>
<span slot="headline">
${this._i18n?.localize?.("ui.components.color-picker.state") ||
"State"}
${this.localize?.("ui.components.color-picker.state") || "State"}
</span>
`;
}
@@ -245,7 +243,7 @@ export class HaColorPicker extends LitElement {
const extraOption = this.extraOptions?.find((o) => o.value === value);
const label =
extraOption?.label ||
this._i18n?.localize?.(
this.localize?.(
`ui.components.color-picker.colors.${value}` as LocalizeKeys
) ||
value;
+8 -8
View File
@@ -14,7 +14,7 @@ import { ifDefined } from "lit/directives/if-defined";
import type { HASSDomEvent } from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event";
import { withViewTransition } from "../common/util/view-transition";
import { internationalizationContext } from "../data/context";
import { localizeContext } from "../data/context";
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-dialog-header";
@@ -123,13 +123,13 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
@query(".body") public bodyContainer!: HTMLDivElement;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
// disabled till iOS app fix the "focus_element" implementation
// @state()
// @consume({ context: configContext, subscribe: true })
// private _hassConfig?: ContextType<typeof configContext>;
// @consume({ context: authContext, subscribe: true })
// private auth?: ContextType<typeof authContext>;
@state()
private _bodyScrolled = false;
@@ -184,7 +184,7 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
<slot name="headerNavigationIcon" slot="navigationIcon">
<ha-icon-button
data-dialog="close"
.label=${this._i18n?.localize("ui.common.close") ?? "Close"}
.label=${this.localize?.("ui.common.close") ?? "Close"}
.path=${mdiClose}
></ha-icon-button>
</slot>
@@ -222,13 +222,13 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
requestAnimationFrame(() => {
// disabled till iOS app fix the "focus_element" implementation
// if (this._hassConfig?.auth.external && isIosApp(this._hassConfig.auth.external)) {
// if (this.auth?.external && isIosApp(this.auth.external)) {
// const element = this.querySelector("[autofocus]");
// if (element !== null) {
// if (!element.id) {
// element.id = "ha-dialog-autofocus";
// }
// this._hassConfig.auth.external.fireMessage({
// this.auth.external.fireMessage({
// type: "focus_element",
// payload: {
// element_id: element.id,
+16 -7
View File
@@ -2,7 +2,12 @@ import { consume, type ContextType } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import { configContext, connectionContext, uiContext } from "../data/context";
import {
authContext,
configContext,
connectionContext,
themesContext,
} from "../data/context";
import {
DEFAULT_DOMAIN_ICON,
domainIcon,
@@ -33,8 +38,12 @@ export class HaDomainIcon extends LitElement {
private _connection?: ContextType<typeof connectionContext>;
@state()
@consume({ context: uiContext, subscribe: true })
private _hassUi?: ContextType<typeof uiContext>;
@consume({ context: themesContext, subscribe: true })
private _themes?: ContextType<typeof themesContext>;
@state()
@consume({ context: authContext, subscribe: true })
private _auth?: ContextType<typeof authContext>;
protected render() {
if (this.icon) {
@@ -50,8 +59,8 @@ export class HaDomainIcon extends LitElement {
}
const icon = domainIcon(
this._connection.connection,
this._hassConfig.config,
this._connection,
this._hassConfig,
this.domain,
this.deviceClass,
this.state
@@ -77,9 +86,9 @@ export class HaDomainIcon extends LitElement {
{
domain: this.domain!,
type: "icon",
darkOptimized: this._hassUi?.themes.darkMode,
darkOptimized: this._themes?.darkMode,
},
this._hassConfig?.auth.data.hassUrl
this._auth?.data.hassUrl
);
return html`
<img
+60
View File
@@ -0,0 +1,60 @@
import { FabBase } from "@material/mwc-fab/mwc-fab-base";
import { styles } from "@material/mwc-fab/mwc-fab.css";
import { customElement } from "lit/decorators";
import { css } from "lit";
import { mainWindow } from "../common/dom/get_main_window";
@customElement("ha-fab")
export class HaFab extends FabBase {
protected firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this.style.setProperty("--mdc-theme-secondary", "var(--primary-color)");
}
static override styles = [
styles,
css`
:host {
--mdc-typography-button-text-transform: none;
--mdc-typography-button-font-size: var(--ha-font-size-l);
--mdc-typography-button-font-family: var(--ha-font-family-body);
--mdc-typography-button-font-weight: var(--ha-font-weight-medium);
}
:host .mdc-fab--extended {
border-radius: var(
--ha-button-border-radius,
var(--ha-border-radius-pill)
);
}
:host .mdc-fab.mdc-fab--extended .ripple {
border-radius: var(
--ha-button-border-radius,
var(--ha-border-radius-pill)
);
}
:host .mdc-fab--extended .mdc-fab__icon {
margin-inline-start: -8px;
margin-inline-end: 12px;
direction: var(--direction);
}
:disabled {
--mdc-theme-secondary: var(--disabled-text-color);
cursor: not-allowed !important;
}
`,
// safari workaround - must be explicit
mainWindow.document.dir === "rtl"
? css`
:host .mdc-fab--extended .mdc-fab__icon {
direction: rtl;
}
`
: css``,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-fab": HaFab;
}
}
+5 -6
View File
@@ -1,3 +1,4 @@
import "@material/mwc-linear-progress/mwc-linear-progress";
import { mdiDelete, mdiFileUpload } from "@mdi/js";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
@@ -11,7 +12,6 @@ import type { HomeAssistant } from "../types";
import { bytesToString } from "../util/bytes-to-string";
import "./ha-button";
import "./ha-icon-button";
import "./progress/ha-progress-bar";
declare global {
interface HASSDomEvents {
@@ -100,11 +100,10 @@ export class HaFileUpload extends LitElement {
</div>`
: nothing}
</div>
<ha-progress-bar
<mwc-linear-progress
.indeterminate=${!this.progress}
.value=${this.progress}
loading
></ha-progress-bar>
.progress=${this.progress ? this.progress / 100 : undefined}
></mwc-linear-progress>
</div>`
: html`<label
for=${this.value ? "" : "input"}
@@ -320,7 +319,7 @@ export class HaFileUpload extends LitElement {
--mdc-button-outline-color: var(--primary-color);
--ha-icon-button-size: 24px;
}
ha-progress-bar {
mwc-linear-progress {
width: 100%;
padding: 8px 32px;
box-sizing: border-box;
+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}
+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";
+18 -3
View File
@@ -1,5 +1,5 @@
import Fuse from "fuse.js";
import { mdiDevices, mdiTextureBox } from "@mdi/js";
import { mdiDevices, mdiPlus, mdiTextureBox } from "@mdi/js";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -8,7 +8,7 @@ import { caseInsensitiveStringCompare } from "../common/string/compare";
import { titleCase } from "../common/string/title-case";
import { getConfigEntries, type ConfigEntry } from "../data/config_entries";
import { fetchConfig } from "../data/lovelace/config/types";
import { getPanelIcon, getPanelTitle, SYSTEM_PANELS } from "../data/panel";
import { getPanelIcon, getPanelTitle } from "../data/panel";
import { findRelated, type RelatedResult } from "../data/search";
import { PANEL_DASHBOARDS } from "../panels/config/lovelace/dashboards/ha-config-lovelace-dashboards";
import { computeAreaPath } from "../panels/lovelace/strategies/areas/helpers/areas-strategy-helper";
@@ -235,6 +235,22 @@ export class HaNavigationPicker extends LitElement {
addGroup("views", views);
addGroup("other_routes", otherRoutes);
if (
searchString &&
!this._navigationItems.some((navItem) => navItem.id === searchString)
) {
items.push({
id: searchString,
primary: this.hass.localize(
"ui.components.navigation-picker.add_custom_path"
),
secondary: `"${searchString}"`,
icon_path: mdiPlus,
sorting_label: searchString,
group: "other_routes",
});
}
return items;
};
@@ -274,7 +290,6 @@ export class HaNavigationPicker extends LitElement {
const otherRoutes: NavigationItem[] = [];
for (const panel of panels) {
if (SYSTEM_PANELS.includes(panel.id)) continue;
const path = `/${panel.url_path}`;
const panelTitle = getPanelTitle(this.hass, panel);
const primary = panelTitle || path;
+14 -10
View File
@@ -15,7 +15,7 @@ import memoizeOne from "memoize-one";
import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import { internationalizationContext } from "../data/context";
import { localeContext, localizeContext } from "../data/context";
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
import {
multiTermSortedSearch,
@@ -162,8 +162,12 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@query("ha-input-search") private _searchFieldElement?: HaInputSearch;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
@state()
@consume({ context: localeContext, subscribe: true })
private locale!: ContextType<typeof localeContext>;
@state() private _items: PickerComboBoxItem[] = [];
@@ -218,9 +222,9 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
const searchLabel =
this.label ??
(this.allowCustomValue
? (this.i18n.localize?.("ui.components.combo-box.search_or_custom") ??
? (this.localize?.("ui.components.combo-box.search_or_custom") ??
"Search | Add custom value")
: (this.i18n.localize?.("ui.common.search") ?? "Search"));
: (this.localize?.("ui.common.search") ?? "Search"));
return html`<ha-input-search
appearance="outlined"
@@ -347,7 +351,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
return caseInsensitiveStringCompare(
sortLabelA,
sortLabelB,
this.i18n.locale?.language ?? navigator.language
this.locale?.language ?? navigator.language
);
});
}
@@ -364,7 +368,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
id: this._search,
primary:
this.customValueLabel ??
this.i18n.localize?.("ui.components.combo-box.add_custom_item") ??
this.localize?.("ui.components.combo-box.add_custom_item") ??
"Add custom item",
secondary: `"${this._search}"`,
icon_path: mdiPlus,
@@ -398,10 +402,10 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
? typeof this.notFoundLabel === "function"
? this.notFoundLabel(this._search)
: this.notFoundLabel ||
this.i18n.localize?.("ui.components.combo-box.no_match") ||
this.localize?.("ui.components.combo-box.no_match") ||
"No matching items found"
: this.emptyLabel ||
this.i18n.localize?.("ui.components.combo-box.no_items") ||
this.localize?.("ui.components.combo-box.no_items") ||
"No items available"}</span
>
</ha-combo-box-item>
@@ -493,7 +497,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
id: searchString,
primary:
this.customValueLabel ??
this.i18n.localize?.("ui.components.combo-box.add_custom_item") ??
this.localize?.("ui.components.combo-box.add_custom_item") ??
"Add custom item",
secondary: `"${searchString}"`,
icon_path: mdiPlus,
+6 -5
View File
@@ -1,4 +1,4 @@
import { consume, type ContextType } from "@lit/context";
import { consume } from "@lit/context";
import { mdiClose, mdiMenuDown } from "@mdi/js";
import {
css,
@@ -11,8 +11,9 @@ import {
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event";
import { internationalizationContext } from "../data/context";
import { localizeContext } from "../data/context";
import { PickerMixin } from "../mixins/picker-mixin";
import type { HomeAssistant } from "../types";
import "./ha-combo-box-item";
import type { HaComboBoxItem } from "./ha-combo-box-item";
import "./ha-icon";
@@ -33,8 +34,8 @@ export class HaPickerField extends PickerMixin(LitElement) {
@query("ha-combo-box-item", true) public item!: HaComboBoxItem;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: localizeContext, subscribe: true })
private localize!: HomeAssistant["localize"];
public async focus() {
await this.updateComplete;
@@ -88,7 +89,7 @@ export class HaPickerField extends PickerMixin(LitElement) {
${this.unknown
? html`<div slot="supporting-text" class="unknown">
${this.unknownItemText ||
this._i18n?.localize("ui.components.combo-box.unknown_item")}
this.localize("ui.components.combo-box.unknown_item")}
</div>`
: nothing}
${showClearIcon
@@ -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
+1 -4
View File
@@ -23,10 +23,7 @@ export class HaSlider extends Slider {
--marker-height: calc(var(--ha-slider-track-size, 4px) / 2);
--marker-width: calc(var(--ha-slider-track-size, 4px) / 2);
--wa-color-surface-default: var(--card-background-color);
--wa-color-neutral-fill-normal: var(
--ha-slider-track-color,
var(--disabled-color)
);
--wa-color-neutral-fill-normal: var(--disabled-color);
--wa-tooltip-background-color: var(
--ha-tooltip-background-color,
var(--secondary-background-color)
-18
View File
@@ -13,24 +13,6 @@ export class HaTabGroup extends TabGroup {
@property({ attribute: "tab-only", type: Boolean }) tabOnly = 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;
+31 -25
View File
@@ -1,10 +1,12 @@
import "@home-assistant/webawesome/dist/components/popover/popover";
import { consume } from "@lit/context";
// @ts-ignore
import chipStyles from "@material/chips/dist/mdc.chips.min.css";
import { mdiPlus, mdiTextureBox } from "@mdi/js";
import Fuse from "fuse.js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing, unsafeCSS } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
@@ -198,7 +200,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
return html`
<div class="items">
<div class="mdc-chip-set items">
${floorIds.length
? floorIds.map(
(floor_id) => html`
@@ -1231,30 +1233,34 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
this.hass?.locale.language ?? navigator.language
);
static styles = css`
.add-target-wrapper {
display: flex;
justify-content: flex-start;
margin-top: var(--ha-space-3);
}
static get styles(): CSSResultGroup {
return css`
.add-target-wrapper {
display: flex;
justify-content: flex-start;
margin-top: var(--ha-space-3);
}
ha-generic-picker {
width: 100%;
}
ha-generic-picker {
width: 100%;
}
.items {
z-index: 2;
display: flex;
flex-wrap: wrap;
padding: var(--ha-space-2) 0;
gap: var(--ha-space-2);
}
.item-groups {
overflow: hidden;
border: 2px solid var(--divider-color);
border-radius: var(--ha-border-radius-lg);
}
`;
${unsafeCSS(chipStyles)}
.items {
z-index: 2;
}
.mdc-chip-set {
padding: var(--ha-space-1) 0;
gap: var(--ha-space-2);
}
.item-groups {
overflow: hidden;
border: 2px solid var(--divider-color);
border-radius: var(--ha-border-radius-lg);
}
`;
}
}
declare global {
+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";
+6 -6
View File
@@ -3,7 +3,7 @@ import { mdiContentCopy, mdiEye, mdiEyeOff } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { copyToClipboard } from "../../common/util/copy-clipboard";
import { internationalizationContext } from "../../data/context";
import { localizeContext } from "../../data/context";
import { showToast } from "../../util/toast";
import "../ha-button";
import "../ha-icon-button";
@@ -59,8 +59,8 @@ export class HaInputCopy extends LitElement {
false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
@state() private _showMasked = true;
@@ -90,7 +90,7 @@ export class HaInputCopy extends LitElement {
? html`<ha-icon-button
slot="end"
class="toggle-unmasked"
.label=${this._i18n.localize(
.label=${this.localize(
`ui.common.${this._showMasked ? "show" : "hide"}`
)}
@click=${this._toggleMasked}
@@ -101,7 +101,7 @@ export class HaInputCopy extends LitElement {
</div>
<ha-button @click=${this._copy} appearance="plain" size="small">
<ha-svg-icon slot="start" .path=${mdiContentCopy}></ha-svg-icon>
${this.label || this._i18n.localize("ui.common.copy")}
${this.label || this.localize("ui.common.copy")}
</ha-button>
</div>
`;
@@ -119,7 +119,7 @@ export class HaInputCopy extends LitElement {
private async _copy(): Promise<void> {
await copyToClipboard(this.value);
showToast(this, {
message: this._i18n.localize("ui.common.copied_clipboard"),
message: this.localize("ui.common.copied_clipboard"),
});
}
+6 -6
View File
@@ -5,7 +5,7 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../../common/dom/fire_event";
import { internationalizationContext } from "../../data/context";
import { localizeContext } from "../../data/context";
import { haStyle } from "../../resources/styles";
import "../ha-button";
import "../ha-icon-button";
@@ -64,8 +64,8 @@ class HaInputMulti extends LitElement {
public updateOnBlur = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
@consume({ context: localizeContext, subscribe: true })
private localize?: ContextType<typeof localizeContext>;
protected render() {
return html`
@@ -109,7 +109,7 @@ class HaInputMulti extends LitElement {
.index=${index}
slot="navigationIcon"
.label=${this.removeLabel ??
this._i18n?.localize("ui.common.remove") ??
this.localize?.("ui.common.remove") ??
"Remove"}
@click=${this._removeItem}
.path=${mdiDeleteOutline}
@@ -137,10 +137,10 @@ class HaInputMulti extends LitElement {
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.addLabel ??
(this.label
? this._i18n?.localize("ui.components.multi-textfield.add_item", {
? this.localize?.("ui.components.multi-textfield.add_item", {
item: this.label,
})
: this._i18n?.localize("ui.common.add")) ??
: this.localize?.("ui.common.add")) ??
"Add"}
</ha-button>
</div>
+5 -5
View File
@@ -2,7 +2,7 @@ import { consume, type ContextType } from "@lit/context";
import { mdiMagnify } from "@mdi/js";
import { html, type PropertyValues } from "lit";
import { customElement, state } from "lit/decorators";
import { internationalizationContext } from "../../data/context";
import { localizeContext } from "../../data/context";
import { HaInput } from "./ha-input";
/**
@@ -18,8 +18,8 @@ import { HaInput } from "./ha-input";
@customElement("ha-input-search")
export class HaInputSearch extends HaInput {
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
constructor() {
super();
@@ -33,9 +33,9 @@ export class HaInputSearch extends HaInput {
if (
!this.label &&
!this.placeholder &&
(!this.hasUpdated || changedProps.has("_i18n"))
(!this.hasUpdated || changedProps.has("localize"))
) {
this.placeholder = this._i18n.localize("ui.common.search");
this.placeholder = this.localize("ui.common.search");
}
}
+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);
}
`;
+1 -5
View File
@@ -254,11 +254,7 @@ export class HaMap extends ReactiveElement {
}
this._loading = true;
try {
[this.leafletMap, this.Leaflet] = await setupLeafletMap(map, {
latitude: this.hass?.config.latitude ?? 52.3731339,
longitude: this.hass?.config.longitude ?? 4.8903147,
zoom: this.zoom,
});
[this.leafletMap, this.Leaflet] = await setupLeafletMap(map);
this._updateMapStyle();
this.leafletMap.on("click", (ev) => {
if (this._clickCount === 0) {
@@ -115,11 +115,11 @@ class DialogMediaManage extends LitElement {
></ha-icon-button
></slot>`
: nothing}
<h1 class="title" slot="title" id="dialog-box-title">
<span class="title" slot="title" id="dialog-box-title">
${this.hass.localize(
"ui.components.media-browser.file_management.title"
)}
</h1>
</span>
${this._selected.size === 0
? html`<ha-media-upload-button
.hass=${this.hass}
@@ -366,11 +366,6 @@ class DialogMediaManage extends LitElement {
color: var(--secondary-text-color);
font-style: italic;
}
.title {
font: inherit;
font-size: inherit;
margin: inherit;
}
`,
];
}
@@ -79,7 +79,6 @@ class DialogMediaPlayerBrowse extends LitElement {
<ha-dialog
.hass=${this.hass}
.open=${this._open}
width="large"
flexcontent
@closed=${this.closeDialog}
@opened=${this._dialogOpened}
@@ -231,8 +230,6 @@ class DialogMediaPlayerBrowse extends LitElement {
--media-browser-max-height: calc(
100vh - 65px - var(--safe-area-inset-y)
);
height: 100vh;
height: 100dvh;
}
:host(.opened) ha-media-player-browse {
@@ -251,6 +248,7 @@ class DialogMediaPlayerBrowse extends LitElement {
--media-browser-max-height: calc(
100vh - 145px - var(--safe-area-inset-y)
);
width: 700px;
}
}
@@ -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();
@@ -49,6 +49,7 @@ import "../entity/ha-entity-picker";
import "../ha-alert";
import "../ha-button";
import "../ha-card";
import "../ha-fab";
import "../ha-icon-button";
import "../ha-list";
import "../ha-list-item";
@@ -445,20 +446,24 @@ export class HaMediaPlayerBrowse extends LitElement {
currentItem.media_content_id
))
? html`
<ha-button
class="fab"
<ha-fab
mini
.item=${currentItem}
@click=${this._actionClicked}
.title=${this.hass.localize(
`ui.components.media-browser.${this.action}`
)}
>
<ha-svg-icon
slot="icon"
.label=${this.hass.localize(
`ui.components.media-browser.${this.action}-media`
)}
.path=${this.action === "play"
? mdiPlay
: mdiPlus}
></ha-svg-icon>
</ha-button>
${this.hass.localize(
`ui.components.media-browser.${this.action}`
)}
</ha-fab>
`
: ""}
</div>
@@ -1358,11 +1363,11 @@ export class HaMediaPlayerBrowse extends LitElement {
height 0.4s,
padding-bottom 0.4s;
}
.fab {
ha-fab {
position: absolute;
bottom: calc(var(--ha-space-5) * -1);
right: var(--ha-space-5);
--ha-button-box-shadow: var(--ha-box-shadow-l);
--mdc-theme-secondary: var(--primary-color);
bottom: -20px;
right: 20px;
}
:host([narrow]) .header-info ha-button {
margin-top: 16px;
@@ -1424,10 +1429,11 @@ export class HaMediaPlayerBrowse extends LitElement {
padding-bottom: initial;
margin-bottom: 0;
}
:host([scrolled]) .fab {
bottom: 0;
right: calc(var(--ha-space-6) * -1);
--ha-button-box-shadow: none;
:host([scrolled]) ha-fab {
bottom: 0px;
right: -24px;
--mdc-fab-box-shadow: none;
--mdc-theme-secondary: rgba(var(--rgb-primary-color), 0.5);
}
lit-virtualizer {
-131
View File
@@ -1,131 +0,0 @@
import ProgressBar from "@home-assistant/webawesome/dist/components/progress-bar/progress-bar";
import type { CSSResultGroup } from "lit";
import { css } from "lit";
import { customElement, property } from "lit/decorators";
/**
* Home Assistant progress bar component
*
* @element ha-progress-bar
* @extends {ProgressBar}
*
* @summary
* A stylable progress bar component based on webawesome progress bar.
* Supports regular, indeterminate, and loading states with Home Assistant theming.
*
* @cssprop --ha-progress-bar-indicator-color - Color of the filled progress indicator.
* @cssprop --ha-progress-bar-indicator-background - Background of the filled progress indicator. Overrides `--ha-progress-bar-indicator-color` when set (accepts any CSS background value, e.g. gradients).
* @cssprop --ha-progress-bar-track-color - Color of the progress track.
* @cssprop --ha-progress-bar-track-height - Height of the progress track. Defaults to `16px`.
* @cssprop --ha-progress-bar-border-radius - Border radius of the progress bar. Defaults to `var(--ha-border-radius-pill)`.
* @cssprop --ha-progress-bar-animation-duration - Animation duration for indeterminate/loading highlight. Defaults to `2.5s`.
* @cssprop --ha-progress-bar-indicator-highlight-image - Image shown at the progress indicator tip (accepts any CSS `background-image` value). Hidden during indeterminate state.
* @cssprop --ha-progress-bar-indicator-highlight-width - Width of the indicator highlight element. Defaults to `calc(var(--track-height) * 2)`.
* @cssprop --ha-progress-bar-indicator-highlight-height - Height of the indicator highlight element. Defaults to `calc(var(--track-height) * 2)`.
*
* @attr {boolean} loading - Shows the loading highlight animation on top of the indicator.
* @attr {boolean} indeterminate - Shows indeterminate progress animation (inherited from ProgressBar).
*/
@customElement("ha-progress-bar")
export class HaProgressBar extends ProgressBar {
@property({ type: Boolean, reflect: true })
loading = false;
static get styles(): CSSResultGroup {
return [
ProgressBar.styles,
css`
:host {
--indicator-color: var(
--ha-progress-bar-indicator-color,
var(--ha-color-on-primary-normal)
);
--track-color: var(
--ha-progress-bar-track-color,
var(--ha-color-fill-neutral-normal-hover)
);
--track-height: var(--ha-progress-bar-track-height, 16px);
--wa-transition-slow: var(--ha-animation-duration-slow);
position: relative;
}
.progress-bar {
border-radius: var(
--ha-progress-bar-border-radius,
var(--ha-border-radius-pill)
);
}
.indicator {
background: var(
--ha-progress-bar-indicator-background,
var(--indicator-color)
);
}
:host([indeterminate]) .indicator {
animation: wa-progress-indeterminate
var(--ha-progress-bar-animation-duration, 2.5s) infinite
cubic-bezier(0.37, 0, 0.63, 1);
}
@keyframes slide-highlight {
0% {
transform: translateX(-200%);
}
100% {
transform: translateX(200%);
}
}
:host([loading]:not([indeterminate])) .progress-bar::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
oklch(from var(--indicator-color) 85% c h) 50%,
transparent 100%
);
opacity: 0.5;
animation: slide-highlight
var(--ha-progress-bar-animation-duration, 2.5s) infinite
cubic-bezier(0.37, 0, 0.63, 1);
width: 50%;
}
:host::after {
--width: var(
--ha-progress-bar-indicator-highlight-width,
calc(var(--track-height) * 2)
);
width: var(--width);
height: var(
--ha-progress-bar-indicator-highlight-height,
calc(var(--track-height) * 2)
);
content: "";
position: absolute;
inset-inline-start: clamp(
var(--width) / 2,
var(--percentage, 0%),
calc(100% - var(--width) / 2)
);
top: 50%;
transform: translate(-50%, -50%);
background-image: var(--ha-progress-bar-indicator-highlight-image);
background-size: contain;
background-repeat: no-repeat;
background-position: center;
pointer-events: none;
transition: inset-inline-start var(--wa-transition-slow, 200ms)
var(--wa-transition-easing, ease);
}
:host([indeterminate])::after {
display: none;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-progress-bar": HaProgressBar;
}
}
@@ -1,14 +1,17 @@
import "@home-assistant/webawesome/dist/components/tag/tag";
import { consume } from "@lit/context";
// @ts-ignore
import chipStyles from "@material/chips/dist/mdc.chips.min.css";
import {
mdiClose,
mdiDevices,
mdiHome,
mdiLabel,
mdiTextureBox,
mdiUnfoldMoreVertical,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement, nothing, unsafeCSS } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../../common/color/compute-color";
import { hex2rgb } from "../../common/color/convert-color";
@@ -24,7 +27,6 @@ import type { LabelRegistryEntry } from "../../data/label/label_registry";
import type { TargetType } from "../../data/target";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import "../ha-domain-icon";
import { floorDefaultIconPath } from "../ha-floor-icon";
import "../ha-icon";
import "../ha-icon-button";
@@ -54,37 +56,50 @@ export class HaTargetPickerValueChip extends LitElement {
this._itemData(this.type, this.itemId);
return html`
<wa-tag
pill
with-remove
class=${this.type}
style=${color ? `--color: rgb(${color});` : ""}
@wa-remove=${this._removeItem}
<div
class="mdc-chip ${classMap({
[this.type]: true,
})}"
style=${color
? `--color: rgb(${color}); --background-color: rgba(${color}, .5)`
: ""}
>
<div class="icon">
${iconPath
? html`<ha-icon .icon=${iconPath}></ha-icon>`
: this._iconImg
? html`<img
alt=${this._domainName || ""}
width="24"
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
: stateObject
? html`<ha-state-icon
.hass=${this.hass}
.stateObj=${stateObject}
></ha-state-icon>`
: nothing}
</div>
<span class="name"> ${name} </span>
${iconPath
? html`<ha-icon
class="mdc-chip__icon mdc-chip__icon--leading"
.icon=${iconPath}
></ha-icon>`
: this._iconImg
? html`<img
class="mdc-chip__icon mdc-chip__icon--leading"
alt=${this._domainName || ""}
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon
class="mdc-chip__icon mdc-chip__icon--leading"
.path=${fallbackIconPath}
></ha-svg-icon>`
: stateObject
? html`<ha-state-icon
class="mdc-chip__icon mdc-chip__icon--leading"
.hass=${this.hass}
.stateObj=${stateObject}
></ha-state-icon>`
: nothing}
<span role="gridcell">
<span role="button" tabindex="0" class="mdc-chip__primary-action">
<span id="title-${this.itemId}" class="mdc-chip__text"
>${name}</span
>
</span>
</span>
${this.type === "entity"
? nothing
: html`<ha-tooltip .for="expand-${slugify(this.itemId)}"
: html`<span role="gridcell">
<ha-tooltip .for="expand-${slugify(this.itemId)}"
>${this.hass.localize(
`ui.components.target-picker.expand_${this.type}_id`
)}
@@ -99,8 +114,25 @@ export class HaTargetPickerValueChip extends LitElement {
.id="expand-${slugify(this.itemId)}"
.type=${this.type}
@click=${this._handleExpand}
></ha-icon-button>`}
</wa-tag>
></ha-icon-button>
</span>`}
<span role="gridcell">
<ha-tooltip .for="remove-${slugify(this.itemId)}">
${this.hass.localize(
`ui.components.target-picker.remove_${this.type}_id`
)}
</ha-tooltip>
<ha-icon-button
class="mdc-chip__icon mdc-chip__icon--trailing"
.label=${this.hass.localize("ui.components.target-picker.remove")}
.path=${mdiClose}
hide-title
.id="remove-${slugify(this.itemId)}"
.type=${this.type}
@click=${this._removeItem}
></ha-icon-button>
</span>
</div>
`;
}
@@ -209,52 +241,101 @@ export class HaTargetPickerValueChip extends LitElement {
}
static styles = css`
:host {
display: inline-block;
max-width: 100%;
}
wa-tag {
background-color: var(--card-background-color);
border-width: var(--ha-border-width-md);
padding-inline-start: 0;
overflow: hidden;
max-width: 100%;
${unsafeCSS(chipStyles)}
.mdc-chip {
color: var(--primary-text-color);
}
wa-tag.entity {
border-color: var(--ha-color-green-80);
--background-color: var(--ha-color-green-80);
.mdc-chip.add {
color: rgba(0, 0, 0, 0.87);
}
wa-tag.device {
border-color: var(--ha-color-primary-80);
--background-color: var(--ha-color-primary-80);
.add-container {
position: relative;
display: inline-flex;
}
wa-tag.area {
border-color: var(--ha-color-orange-80);
--background-color: var(--ha-color-orange-80);
.mdc-chip:not(.add) {
cursor: default;
}
wa-tag.label {
border-color: var(--color);
--background-color: var(--color);
--icon-primary-color: var(--primary-text-color);
}
.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon {
background-color: var(--background-color);
border-radius: var(--ha-border-radius-circle);
padding: var(--ha-space-2) var(--ha-space-1);
.mdc-chip ha-icon-button {
--ha-icon-button-size: 24px;
display: flex;
align-items: center;
outline: none;
}
.mdc-chip ha-icon-button ha-svg-icon {
border-radius: 50%;
background: var(--secondary-text-color);
}
.mdc-chip__icon.mdc-chip__icon--trailing {
width: var(--ha-space-4);
height: var(--ha-space-4);
--mdc-icon-size: 14px;
color: var(--secondary-text-color);
margin-inline-start: var(--ha-space-1) !important;
margin-inline-end: calc(-1 * var(--ha-space-1)) !important;
direction: var(--direction);
}
.mdc-chip__icon--leading {
display: flex;
align-items: center;
justify-content: center;
--mdc-icon-size: 20px;
border-radius: var(--ha-border-radius-circle);
padding: 6px;
margin-left: -13px !important;
margin-inline-start: -13px !important;
margin-inline-end: var(--ha-space-1) !important;
direction: var(--direction);
}
.expand-btn {
--ha-icon-button-size: 16px;
--mdc-icon-size: 14px;
margin-right: 0;
margin-inline-end: 0;
margin-inline-start: initial;
}
.mdc-chip.area:not(.add),
.mdc-chip.floor:not(.add) {
border: 1px solid #fed6a4;
background: var(--card-background-color);
}
.mdc-chip.area:not(.add) .mdc-chip__icon--leading,
.mdc-chip.area.add,
.mdc-chip.floor:not(.add) .mdc-chip__icon--leading,
.mdc-chip.floor.add {
background: #fed6a4;
}
.mdc-chip.device:not(.add) {
border: 1px solid #a8e1fb;
background: var(--card-background-color);
}
.mdc-chip.device:not(.add) .mdc-chip__icon--leading,
.mdc-chip.device.add {
background: #a8e1fb;
}
.mdc-chip.entity:not(.add) {
border: 1px solid #d2e7b9;
background: var(--card-background-color);
}
.mdc-chip.entity:not(.add) .mdc-chip__icon--leading,
.mdc-chip.entity.add {
background: #d2e7b9;
}
.mdc-chip.label:not(.add) {
border: 1px solid var(--color, #e0e0e0);
background: var(--card-background-color);
}
.mdc-chip.label:not(.add) .mdc-chip__icon--leading,
.mdc-chip.label.add {
background: var(--background-color, #e0e0e0);
}
.mdc-chip:hover {
z-index: 5;
}
:host([disabled]) .mdc-chip {
opacity: var(--light-disabled-opacity);
pointer-events: none;
}
.tooltip-icon-img {
width: 24px;
height: 24px;
}
`;
}
@@ -5,8 +5,6 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import type { Trigger } from "../../data/automation";
import { migrateAutomationTrigger } from "../../data/automation";
import { describeCondition, describeTrigger } from "../../data/automation_i18n";
import { fullEntitiesContext, labelsContext } from "../../data/context";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
@@ -26,6 +24,8 @@ import "../ha-icon-button";
import "./hat-logbook-note";
import type { NodeInfo } from "./hat-script-graph";
import { traceTabStyles } from "./trace-tab-styles";
import type { Trigger } from "../../data/automation";
import { migrateAutomationTrigger } from "../../data/automation";
const TRACE_PATH_TABS = [
"step_config",
+25 -57
View File
@@ -1,8 +1,6 @@
import { TZDate } from "@date-fns/tz";
import type { HassConfig, HassEntity } from "home-assistant-js-websocket";
import { ensureArray } from "../common/array/ensure-array";
import {
formatDurationDigital,
formatDurationLong,
formatNumericDuration,
} from "../common/datetime/format_duration";
@@ -70,22 +68,8 @@ const localizeTimeString = (
return time;
}
try {
const hours = Number(chunks[0]);
const minutes = Number(chunks[1]);
const seconds = chunks.length > 2 ? Number(chunks[2]) : 0;
// Create date in the server timezone so formatTime converts correctly
// when the user's browser timezone differs from the HA server timezone.
const now = new Date();
const dt = new TZDate(
now.getFullYear(),
now.getMonth(),
now.getDate(),
hours,
minutes,
seconds,
config.time_zone
);
if (chunks.length === 2 || seconds === 0) {
const dt = new Date("1970-01-01T" + time);
if (chunks.length === 2 || Number(chunks[2]) === 0) {
return formatTime(dt, locale, config);
}
return formatTimeWithSeconds(dt, locale, config);
@@ -873,27 +857,6 @@ const describeLegacyTrigger = (
return undefined;
};
const formatSunOffset = (
hass: HomeAssistant,
offset?: number | string | ForDict
): string => {
if (!offset) {
return "";
}
if (typeof offset === "number") {
return secondsToDuration(offset)!;
}
if (typeof offset === "string") {
return offset;
}
try {
const formatted = formatDurationDigital(hass.locale, offset);
return formatted.startsWith("-") ? formatted : `+${formatted}`;
} catch (_e) {
return JSON.stringify(offset);
}
};
export const describeCondition = (
condition: Condition,
hass: HomeAssistant,
@@ -1234,17 +1197,7 @@ const describeLegacyCondition = (
let hasTime = "";
if (after !== undefined && before !== undefined) {
if (
typeof condition.after === "string" &&
!condition.after.includes(".") &&
typeof condition.before === "string" &&
!condition.before.includes(".") &&
condition.after > condition.before
) {
hasTime = "after_before_or";
} else {
hasTime = "after_before";
}
hasTime = "after_before";
} else if (after !== undefined) {
hasTime = "after";
} else if (before !== undefined) {
@@ -1267,15 +1220,30 @@ const describeLegacyCondition = (
// Sun condition
if (condition.condition === "sun" && (condition.before || condition.after)) {
const afterDuration = condition.after
? formatSunOffset(hass, condition.after_offset)
: "";
const beforeDuration = condition.before
? formatSunOffset(hass, condition.before_offset)
: "";
let afterDuration = "";
if (condition.after && condition.after_offset) {
if (typeof condition.after_offset === "number") {
afterDuration = secondsToDuration(condition.after_offset)!;
} else if (typeof condition.after_offset === "string") {
afterDuration = condition.after_offset;
} else {
afterDuration = JSON.stringify(condition.after_offset);
}
}
let beforeDuration = "";
if (condition.before && condition.before_offset) {
if (typeof condition.before_offset === "number") {
beforeDuration = secondsToDuration(condition.before_offset)!;
} else if (typeof condition.before_offset === "string") {
beforeDuration = condition.before_offset;
} else {
beforeDuration = JSON.stringify(condition.before_offset);
}
}
return hass.localize(
`${conditionsTranslationBaseKey}.sun.description.${condition.before && condition.after ? "between" : condition.before ? "before" : "after"}`,
`${conditionsTranslationBaseKey}.sun.description.full`,
{
afterChoice: condition.after ?? "other",
afterOffsetChoice: afterDuration !== "" ? "offset" : "other",
+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,
};
};
+38
View File
@@ -0,0 +1,38 @@
import { createContext } from "@lit/context";
import type { HassConfig } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../types";
import type { ConfigEntry } from "./config_entries";
import type { EntityRegistryEntry } from "./entity/entity_registry";
import type { LabelRegistryEntry } from "./label/label_registry";
export const connectionContext =
createContext<HomeAssistant["connection"]>("connection");
export const statesContext = createContext<HomeAssistant["states"]>("states");
export const entitiesContext =
createContext<HomeAssistant["entities"]>("entities");
export const devicesContext =
createContext<HomeAssistant["devices"]>("devices");
export const areasContext = createContext<HomeAssistant["areas"]>("areas");
export const localizeContext =
createContext<HomeAssistant["localize"]>("localize");
export const localeContext = createContext<HomeAssistant["locale"]>("locale");
export const configContext = createContext<HassConfig>("config");
export const themesContext = createContext<HomeAssistant["themes"]>("themes");
export const selectedThemeContext =
createContext<HomeAssistant["selectedTheme"]>("selectedTheme");
export const userContext = createContext<HomeAssistant["user"]>("user");
export const userDataContext =
createContext<HomeAssistant["userData"]>("userData");
export const panelsContext = createContext<HomeAssistant["panels"]>("panels");
export const fullEntitiesContext =
createContext<EntityRegistryEntry[]>("extendedEntities");
export const floorsContext = createContext<HomeAssistant["floors"]>("floors");
export const labelsContext = createContext<LabelRegistryEntry[]>("labels");
export const configEntriesContext =
createContext<ConfigEntry[]>("configEntries");
export const authContext = createContext<HomeAssistant["auth"]>("auth");
-148
View File
@@ -1,148 +0,0 @@
import { createContext } from "@lit/context";
import type { HassConfig } from "home-assistant-js-websocket";
import type {
HomeAssistant,
HomeAssistantApi,
HomeAssistantConfig,
HomeAssistantConnection,
HomeAssistantInternationalization,
HomeAssistantRegistries,
HomeAssistantUI,
} from "../../types";
import type { ConfigEntry } from "../config_entries";
import type { EntityRegistryEntry } from "../entity/entity_registry";
import type { LabelRegistryEntry } from "../label/label_registry";
/**
* Entity, device, area, and floor registries
*/
export const registriesContext =
createContext<HomeAssistantRegistries>("hassRegistries");
/**
* Live map of all entity states, keyed by entity ID.
*/
export const statesContext = createContext<HomeAssistant["states"]>("states");
/**
* Provides the map of all available Home Assistant services, keyed by domain.
*/
export const servicesContext =
createContext<HomeAssistant["services"]>("services");
/**
* i18n state: active language, locale settings, the `localize` function, translation metadata, and the
* `loadBackendTranslation` / `loadFragmentTranslation` loaders.
*/
export const internationalizationContext =
createContext<HomeAssistantInternationalization>("hassInternationalization");
/**
* HTTP and WebSocket API surface: `callService`, `callApi`,
* `callApiRaw`, `callWS`, `sendWS`, `fetchWithAuth`, and `hassUrl`.
*/
export const apiContext = createContext<HomeAssistantApi>("hassApi");
/**
* WebSocket connection state: `connection`, `connected`, and `debugConnection`.
*/
export const connectionContext =
createContext<HomeAssistantConnection>("hassConnection");
/**
* UI preferences and global UI state: themes, selected theme,
* panels, sidebar mode, kiosk mode, shortcuts, vibration, and
* `suspendWhenHidden`.
*/
export const uiContext = createContext<HomeAssistantUI>("hassUi");
/**
* HA core configuration together with user session data:
* `auth`, `config` (core HA config), `user`, `userData`, and `systemData`.
*/
export const configContext = createContext<HomeAssistantConfig>("hassConfig");
/**
* Map of all entities in the entity registry, keyed by entity ID.
*/
export const entitiesContext =
createContext<HomeAssistant["entities"]>("entities");
/**
* Map of all devices in the device registry, keyed by device ID.
*/
export const devicesContext =
createContext<HomeAssistant["devices"]>("devices");
/**
* Map of all areas in the area registry, keyed by area ID.
*/
export const areasContext = createContext<HomeAssistant["areas"]>("areas");
/**
* Map of all floors in the floor registry, keyed by floor ID.
*/
export const floorsContext = createContext<HomeAssistant["floors"]>("floors");
// #region lazy-contexts
/**
* Lazy contexts are not subscribed to by default. They are only subscribed to when a provider is consumed with at least one consumer.
*/
/**
* Lazy loaded labels registry, keyed by label ID.
*/
export const labelsContext = createContext<LabelRegistryEntry[]>("labels");
/**
* Lazy loaded entity registry array
*/
export const fullEntitiesContext =
createContext<EntityRegistryEntry[]>("extendedEntities");
/**
* Lazy loaded config entries array
*/
export const configEntriesContext =
createContext<ConfigEntry[]>("configEntries");
// #endregion lazy-contexts
// #region deprecated-contexts
/** @deprecated Use `connectionContext` instead. */
export const connectionSingleContext =
createContext<HomeAssistant["connection"]>("connection");
/** @deprecated Use `internationalizationContext` instead. */
export const localizeContext =
createContext<HomeAssistant["localize"]>("localize");
/** @deprecated Use `internationalizationContext` instead. */
export const localeContext = createContext<HomeAssistant["locale"]>("locale");
/** @deprecated Use `configContext` instead. */
export const configSingleContext = createContext<HassConfig>("config");
/** @deprecated Use `uiContext` instead. */
export const themesContext = createContext<HomeAssistant["themes"]>("themes");
/** @deprecated Use `uiContext` instead. */
export const selectedThemeContext =
createContext<HomeAssistant["selectedTheme"]>("selectedTheme");
/** @deprecated Use `configContext` instead. */
export const userContext = createContext<HomeAssistant["user"]>("user");
/** @deprecated Use `configContext` instead. */
export const userDataContext =
createContext<HomeAssistant["userData"]>("userData");
/** @deprecated Use `uiContext` instead. */
export const panelsContext = createContext<HomeAssistant["panels"]>("panels");
/** @deprecated Use `configContext` instead. */
export const authContext = createContext<HomeAssistant["auth"]>("auth");
// #endregion deprecated-contexts
-166
View File
@@ -1,166 +0,0 @@
import type {
HomeAssistant,
HomeAssistantApi,
HomeAssistantConfig,
HomeAssistantConnection,
HomeAssistantInternationalization,
HomeAssistantRegistries,
HomeAssistantUI,
} from "../../types";
const updateRegistries = (
hass: HomeAssistant,
value?: HomeAssistantRegistries
): HomeAssistantRegistries => {
if (
!value ||
value.entities !== hass.entities ||
value.devices !== hass.devices ||
value.areas !== hass.areas ||
value.floors !== hass.floors
) {
return {
entities: hass.entities,
devices: hass.devices,
areas: hass.areas,
floors: hass.floors,
};
}
return value;
};
const updateInternationalization = (
hass: HomeAssistant,
value?: HomeAssistantInternationalization
): HomeAssistantInternationalization => {
if (
!value ||
value.localize !== hass.localize ||
value.locale !== hass.locale ||
value.loadBackendTranslation !== hass.loadBackendTranslation ||
value.loadFragmentTranslation !== hass.loadFragmentTranslation ||
value.language !== hass.language ||
value.selectedLanguage !== hass.selectedLanguage ||
value.translationMetadata !== hass.translationMetadata
) {
return {
localize: hass.localize,
locale: hass.locale,
loadBackendTranslation: hass.loadBackendTranslation,
loadFragmentTranslation: hass.loadFragmentTranslation,
language: hass.language,
selectedLanguage: hass.selectedLanguage,
translationMetadata: hass.translationMetadata,
};
}
return value;
};
const updateApi = (
hass: HomeAssistant,
value?: HomeAssistantApi
): HomeAssistantApi => {
if (
!value ||
value.callService !== hass.callService ||
value.callApi !== hass.callApi ||
value.callApiRaw !== hass.callApiRaw ||
value.callWS !== hass.callWS ||
value.sendWS !== hass.sendWS ||
value.fetchWithAuth !== hass.fetchWithAuth
) {
return {
callService: hass.callService,
callApi: hass.callApi,
callApiRaw: hass.callApiRaw,
callWS: hass.callWS,
sendWS: hass.sendWS,
fetchWithAuth: hass.fetchWithAuth,
};
}
return value;
};
const updateConnection = (
hass: HomeAssistant,
value?: HomeAssistantConnection
): HomeAssistantConnection => {
if (
!value ||
value.connection !== hass.connection ||
value.connected !== hass.connected ||
value.debugConnection !== hass.debugConnection ||
value.hassUrl !== hass.hassUrl
) {
return {
connection: hass.connection,
connected: hass.connected,
debugConnection: hass.debugConnection,
hassUrl: hass.hassUrl,
};
}
return value;
};
const updateUi = (
hass: HomeAssistant,
value?: HomeAssistantUI
): HomeAssistantUI => {
if (
!value ||
value.themes !== hass.themes ||
value.selectedTheme !== hass.selectedTheme ||
value.panels !== hass.panels ||
value.panelUrl !== hass.panelUrl ||
value.dockedSidebar !== hass.dockedSidebar ||
value.kioskMode !== hass.kioskMode ||
value.enableShortcuts !== hass.enableShortcuts ||
value.vibrate !== hass.vibrate ||
value.suspendWhenHidden !== hass.suspendWhenHidden
) {
return {
themes: hass.themes,
selectedTheme: hass.selectedTheme,
panels: hass.panels,
panelUrl: hass.panelUrl,
dockedSidebar: hass.dockedSidebar,
kioskMode: hass.kioskMode,
enableShortcuts: hass.enableShortcuts,
vibrate: hass.vibrate,
suspendWhenHidden: hass.suspendWhenHidden,
};
}
return value;
};
const updateConfig = (
hass: HomeAssistant,
value?: HomeAssistantConfig
): HomeAssistantConfig => {
if (
!value ||
value.auth !== hass.auth ||
value.config !== hass.config ||
value.user !== hass.user ||
value.userData !== hass.userData ||
value.systemData !== hass.systemData
) {
return {
auth: hass.auth,
config: hass.config,
user: hass.user,
userData: hass.userData,
systemData: hass.systemData,
};
}
return value;
};
export const updateHassGroups = {
registries: updateRegistries,
internationalization: updateInternationalization,
api: updateApi,
connection: updateConnection,
ui: updateUi,
config: updateConfig,
};
-2
View File
@@ -1,6 +1,4 @@
export const isExternal =
window.externalAppV2 ||
window.externalApp ||
window.webkit?.messageHandlers?.getExternalAuth ||
location.search.includes("external_auth=1");
export const isExternalAndroid = window.externalApp || window.externalAppV2;
-2
View File
@@ -4,7 +4,6 @@ export interface CoreFrontendUserData {
showAdvanced?: boolean;
showEntityIdPicker?: boolean;
default_panel?: string;
apps_info_dismissed?: boolean;
}
export interface SidebarFrontendUserData {
@@ -22,7 +21,6 @@ export interface HomeFrontendSystemData {
favorite_entities?: string[];
welcome_banner_dismissed?: boolean;
hidden_summaries?: string[];
hide_welcome_message?: boolean;
}
declare global {
-1
View File
@@ -35,7 +35,6 @@ export interface LovelaceViewHeaderConfig {
layout?: "start" | "center" | "responsive";
badges_position?: "bottom" | "top";
badges_wrap?: "wrap" | "scroll";
badges_floating?: boolean;
}
export const DEFAULT_FOOTER_MAX_WIDTH_PX = 600;
-33
View File
@@ -1,33 +0,0 @@
import type { LovelaceStrategyConfigType } from "../panels/lovelace/strategies/get-strategy";
export interface CustomStrategyEntry {
type: string;
name?: string;
description?: string;
documentationURL?: string;
strategyType: LovelaceStrategyConfigType;
}
export interface CustomStrategiesWindow {
customStrategies?: CustomStrategyEntry[];
}
const customStrategiesWindow = window as CustomStrategiesWindow;
if (!("customStrategies" in customStrategiesWindow)) {
customStrategiesWindow.customStrategies = [];
}
export const customStrategies = customStrategiesWindow.customStrategies!;
export const getCustomStrategiesForType = (
strategyType: LovelaceStrategyConfigType
) => customStrategies.filter((s) => s.strategyType === strategyType);
export const getCustomStrategyEntry = (
type: string,
strategyType: LovelaceStrategyConfigType
) =>
customStrategies.find(
(s) => s.type === type && s.strategyType === strategyType
);
-5
View File
@@ -12,16 +12,11 @@ import type { LocalizeKeys } from "../common/translations/localize";
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
import type { HomeAssistant, PanelInfo } from "../types";
export const APP_PANEL = "app";
export const HOME_PANEL = "home";
export const MY_REDIRECT_PANEL = "_my_redirect";
export const NOT_FOUND_PANEL = "notfound";
export const PROFILE_PANEL = "profile";
export const LOVELACE_PANEL = "lovelace";
/** Panels that are internal/system-level and should not appear in user-facing navigation UIs. */
export const SYSTEM_PANELS = [MY_REDIRECT_PANEL, NOT_FOUND_PANEL, APP_PANEL];
/** Panel to show when no panel is picked. */
export const DEFAULT_PANEL = HOME_PANEL;
+11 -6
View File
@@ -18,11 +18,7 @@ import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type { HomeAssistant } from "../types";
import type { HassioAddonInfo } from "./hassio/addon";
import { domainToName } from "./integration";
import {
getPanelIcon,
getPanelNameTranslationKey,
SYSTEM_PANELS,
} from "./panel";
import { getPanelIcon, getPanelNameTranslationKey } from "./panel";
export interface NavigationComboBoxItem extends PickerComboBoxItem {
path: string;
@@ -55,7 +51,12 @@ const generateNavigationPanelCommands = (
apps?: HassioAddonInfo[]
): BaseNavigationCommand[] =>
Object.entries(panels)
.filter(([panelKey]) => !SYSTEM_PANELS.includes(panelKey))
.filter(
([panelKey]) =>
panelKey !== "_my_redirect" &&
panelKey !== "hassio" &&
panelKey !== "app"
)
.map(([_panelKey, panel]) => {
const translationKey = getPanelNameTranslationKey(panel);
const icon = getPanelIcon(panel) || "mdi:view-dashboard";
@@ -104,6 +105,10 @@ const generateNavigationConfigSectionCommands = (
hass: HomeAssistant,
filterOptions: NavigationFilterOptions = {}
): BaseNavigationCommand[] => {
if (!hass.user?.is_admin) {
return [];
}
const items: NavigationInfo[] = [];
const allPages = Object.values(configSections).flat();
const visiblePages = filterNavigationPages(hass, allPages, filterOptions);
-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,
@@ -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",
@@ -2,8 +2,8 @@ import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { blankBeforePercent } from "../../common/translations/blank_before_percent";
import "../../components/ha-progress-ring";
import "../../components/ha-spinner";
import "../../components/progress/ha-progress-ring";
import type { DataEntryFlowStepProgress } from "../../data/data_entry_flow";
import type { HomeAssistant } from "../../types";
import type { FlowConfig } from "./show-dialog-data-entry-flow";
+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;
};
@@ -578,9 +578,8 @@ class MoreInfoMediaPlayer extends LitElement {
.volume {
display: flex;
align-items: center;
justify-content: center;
gap: var(--ha-space-3);
padding-inline: var(--ha-space-2);
margin-left: var(--ha-space-2);
}
.volume-slider-container {
@@ -1,3 +1,4 @@
import "@material/mwc-linear-progress/mwc-linear-progress";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
@@ -14,7 +15,6 @@ import "../../../components/ha-md-list";
import "../../../components/ha-md-list-item";
import "../../../components/ha-spinner";
import "../../../components/ha-switch";
import "../../../components/progress/ha-progress-bar";
import type { BackupConfig } from "../../../data/backup";
import { fetchBackupConfig } from "../../../data/backup";
import { isUnavailableState } from "../../../data/entity/entity";
@@ -191,11 +191,11 @@ class MoreInfoUpdate extends LitElement {
${this.stateObj.attributes.in_progress
? supportsFeature(this.stateObj, UpdateEntityFeature.PROGRESS) &&
this.stateObj.attributes.update_percentage !== null
? html`<ha-progress-bar
loading
.value=${this.stateObj.attributes.update_percentage}
></ha-progress-bar>`
: html`<ha-progress-bar indeterminate></ha-progress-bar>`
? html`<mwc-linear-progress
.progress=${this.stateObj.attributes.update_percentage / 100}
buffer=""
></mwc-linear-progress>`
: html`<mwc-linear-progress indeterminate></mwc-linear-progress>`
: nothing}
<h3>${this.stateObj.attributes.title}</h3>
${this._error
@@ -521,6 +521,10 @@ class MoreInfoUpdate extends LitElement {
justify-content: center;
align-items: center;
}
mwc-linear-progress {
margin-bottom: calc(var(--ha-space-2) * -1);
margin-top: var(--ha-space-1);
}
ha-markdown {
direction: ltr;
padding-bottom: var(--ha-space-4);
+4 -10
View File
@@ -57,11 +57,7 @@ import type { HomeAssistant } from "../../types";
import { isMac } from "../../util/is_mac";
import { showConfirmationDialog } from "../generic/show-dialog-box";
import { showShortcutsDialog } from "../shortcuts/show-shortcuts-dialog";
import {
effectiveQuickBarMode,
type QuickBarParams,
type QuickBarSection,
} from "./show-dialog-quick-bar";
import type { QuickBarParams, QuickBarSection } from "./show-dialog-quick-bar";
const SEPARATOR = "________";
@@ -104,7 +100,7 @@ export class QuickBar extends LitElement {
this._translationsLoaded = true;
}
this._initialize();
this._selectedSection = effectiveQuickBarMode(this.hass.user, params.mode);
this._selectedSection = params.mode;
this._showHint = params.showHint ?? false;
this._relatedResult = params.contextItem ? params.related : undefined;
@@ -660,10 +656,8 @@ export class QuickBar extends LitElement {
private _generateActionCommandsMemoized = memoizeOne(generateActionCommands);
private _createFuseIndex = (
states: PickerComboBoxItem[],
keys: FuseWeightedKey[]
) => Fuse.createIndex(keys, states);
private _createFuseIndex = (states, keys: FuseWeightedKey[]) =>
Fuse.createIndex(keys, states);
private _fuseIndexes = {
entity: memoizeOne((states: PickerComboBoxItem[]) =>
@@ -1,6 +1,5 @@
import { fireEvent } from "../../common/dom/fire_event";
import type { ItemType, RelatedResult } from "../../data/search";
import type { HomeAssistant } from "../../types";
import { closeDialog } from "../make-dialog-manager";
export type QuickBarSection =
@@ -23,20 +22,6 @@ export interface QuickBarParams {
related?: RelatedResult;
}
/** Non-admin users cannot scope the bar to command, device, or area (those sections are admin-only). */
export const effectiveQuickBarMode = (
user: HomeAssistant["user"],
mode?: QuickBarSection
): QuickBarSection | undefined => {
if (mode && user?.is_admin) {
return mode;
}
if (mode === "command" || mode === "device" || mode === "area") {
return undefined;
}
return mode;
};
export const loadQuickBar = () => import("./ha-quick-bar");
export const showQuickBar = (
+5 -4
View File
@@ -1,3 +1,4 @@
import "@material/mwc-linear-progress/mwc-linear-progress";
import {
mdiAutoFix,
mdiLifebuoy,
@@ -18,7 +19,6 @@ import "../../components/ha-icon-next";
import "../../components/ha-md-list";
import "../../components/ha-md-list-item";
import "../../components/ha-spinner";
import "../../components/progress/ha-progress-bar";
import { fetchBackupInfo } from "../../data/backup";
import type { BackupManagerState } from "../../data/backup_manager";
import {
@@ -120,7 +120,9 @@ class DialogRestart extends LitElement {
<div class="action-loader">
${this._loadingBackupInfo
? html`<ha-fade-in .delay=${250}>
<ha-progress-bar indeterminate></ha-progress-bar>
<mwc-linear-progress
.indeterminate=${true}
></mwc-linear-progress>
</ha-fade-in>`
: nothing}
</div>
@@ -462,8 +464,7 @@ class DialogRestart extends LitElement {
padding: 24px;
}
.action-loader {
--ha-progress-bar-track-height: 4px;
--ha-progress-bar-border-radius: 0;
height: 4px;
}
`,
];
+11 -13
View File
@@ -5,7 +5,7 @@ import type { LocalizeKeys } from "../../common/translations/localize";
import "../../components/ha-alert";
import "../../components/ha-dialog";
import "../../components/ha-svg-icon";
import { internationalizationContext } from "../../data/context";
import { localizeContext } from "../../data/context";
import { isMac } from "../../util/is_mac";
import { DialogMixin } from "../dialog-mixin";
@@ -168,8 +168,8 @@ const _SHORTCUTS: Section[] = [
@customElement("dialog-shortcuts")
class DialogShortcuts extends DialogMixin(LitElement) {
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
private _renderShortcut(
shortcutKeys: ShortcutString[],
@@ -183,15 +183,13 @@ class DialogShortcuts extends DialogMixin(LitElement) {
>${shortcutKey === CTRL_CMD
? isMac
? "⌘"
: this._i18n.localize("ui.dialogs.shortcuts.keys.ctrl")
: this.localize("ui.dialogs.shortcuts.keys.ctrl")
: typeof shortcutKey === "string"
? shortcutKey
: this._i18n.localize(
shortcutKey.shortcutTranslationKey
)}</span
: this.localize(shortcutKey.shortcutTranslationKey)}</span
>`
)}
${this._i18n.localize(descriptionKey)}
${this.localize(descriptionKey)}
</div>
`;
}
@@ -200,12 +198,12 @@ class DialogShortcuts extends DialogMixin(LitElement) {
return html`
<ha-dialog
open
.headerTitle=${this._i18n.localize("ui.dialogs.shortcuts.title")}
.headerTitle=${this.localize("ui.dialogs.shortcuts.title")}
>
<div class="content">
${_SHORTCUTS.map(
(section) => html`
<h3>${this._i18n.localize(section.titleTranslationKey)}</h3>
<h3>${this.localize(section.titleTranslationKey)}</h3>
<div class="items">
${section.items.map((item) => {
if ("shortcut" in item) {
@@ -215,7 +213,7 @@ class DialogShortcuts extends DialogMixin(LitElement) {
);
}
return html`<p>
${this._i18n.localize((item as Text).textTranslationKey)}
${this.localize((item as Text).textTranslationKey)}
</p>`;
})}
</div>
@@ -224,9 +222,9 @@ class DialogShortcuts extends DialogMixin(LitElement) {
</div>
<ha-alert slot="footer">
${this._i18n.localize("ui.dialogs.shortcuts.enable_shortcuts_hint", {
${this.localize("ui.dialogs.shortcuts.enable_shortcuts_hint", {
user_profile: html`<a href="/profile/general#shortcuts"
>${this._i18n.localize(
>${this.localize(
"ui.dialogs.shortcuts.enable_shortcuts_hint_user_profile"
)}</a
>`,
@@ -1,3 +1,4 @@
import "@material/mwc-linear-progress/mwc-linear-progress";
import { mdiDotsVertical, mdiRestart } from "@mdi/js";
import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
+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(
@@ -2,8 +2,8 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-progress-ring";
import "../../components/ha-spinner";
import "../../components/progress/ha-progress-ring";
import { ON, UNAVAILABLE } from "../../data/entity/entity";
import {
updateCanInstall,
+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";
+4 -21
View File
@@ -5,11 +5,6 @@ import { Auth } from "home-assistant-js-websocket";
import type { EMMessage } from "./external_messaging";
import { ExternalMessaging } from "./external_messaging";
/**
* WARNING: These constants should not be changed, as the native app relies on
* these exact string values to know which callback to call.
* This happens after getting a response from the native app.
*/
const CALLBACK_SET_TOKEN = "externalAuthSetToken";
const CALLBACK_REVOKE_TOKEN = "externalAuthRevokeToken";
@@ -33,9 +28,6 @@ declare global {
revokeExternalAuth(payload: string);
externalBus(payload: string);
};
externalAppV2?: {
postMessage(payload: string): void;
};
webkit?: {
messageHandlers: {
getExternalAuth: {
@@ -52,9 +44,9 @@ declare global {
}
}
if (!window.externalApp && !window.webkit && !window.externalAppV2) {
if (!window.externalApp && !window.webkit) {
throw new Error(
"External auth requires either externalApp, externalAppV2, or webkit defined on Window object."
"External auth requires either externalApp or webkit defined on Window object."
);
}
@@ -103,11 +95,7 @@ export class ExternalAuth extends Auth {
// we sleep 1 microtask to get the promise to actually set it on the window object.
await Promise.resolve();
if (window.externalAppV2) {
window.externalAppV2.postMessage(
JSON.stringify({ type: "getExternalAuth", payload })
);
} else if (window.externalApp) {
if (window.externalApp) {
window.externalApp.getExternalAuth(JSON.stringify(payload));
} else {
window.webkit!.messageHandlers.getExternalAuth.postMessage(payload);
@@ -131,11 +119,7 @@ export class ExternalAuth extends Auth {
// we sleep 1 microtask to get the promise to actually set it on the window object.
await Promise.resolve();
if (window.externalAppV2) {
window.externalAppV2.postMessage(
JSON.stringify({ type: "revokeExternalAuth", payload })
);
} else if (window.externalApp) {
if (window.externalApp) {
window.externalApp.revokeExternalAuth(JSON.stringify(payload));
} else {
window.webkit!.messageHandlers.revokeExternalAuth.postMessage(payload);
@@ -148,7 +132,6 @@ export class ExternalAuth extends Auth {
export const createExternalAuth = async (hassUrl: string) => {
const auth = new ExternalAuth(hassUrl);
if (
window.externalAppV2 ||
window.externalApp?.externalBus ||
(window.webkit && window.webkit.messageHandlers.externalBus)
) {
+4 -28
View File
@@ -187,11 +187,6 @@ interface EMOutgoingMessageFocusElement extends EMMessage {
};
}
// These types are handled internally by the Android app via postMessage.
// They are not sent by the frontend and should not be used directly.
// They are intentionally listed here to prevent anyone from using them unintentionally.
type RejectedEMMessageType = "onHomeAssistantSetTheme" | "handleBlob";
type EMOutgoingMessageWithoutAnswer =
| EMMessageResultError
| EMMessageResultSuccess
@@ -398,16 +393,8 @@ export class ExternalMessaging {
* Send message to external app that expects a response.
* @param msg message to send
*/
public sendMessage<
T extends keyof EMOutgoingMessageWithAnswer,
TType extends string = EMOutgoingMessageWithAnswer[T]["request"]["type"],
>(
msg: EMOutgoingMessageWithAnswer[T]["request"] & {
type: TType &
(TType extends RejectedEMMessageType
? "ERROR: message type is rejected"
: {});
}
public sendMessage<T extends keyof EMOutgoingMessageWithAnswer>(
msg: EMOutgoingMessageWithAnswer[T]["request"]
): Promise<EMOutgoingMessageWithAnswer[T]["response"]> {
const msgId = ++this.msgId;
msg.id = msgId;
@@ -425,14 +412,7 @@ export class ExternalMessaging {
* Send message to external app without expecting a response.
* @param msg message to send
*/
public fireMessage<T extends string>(
msg: EMOutgoingMessageWithoutAnswer & {
type: T &
(T extends RejectedEMMessageType
? "ERROR: message type is rejected"
: {});
}
) {
public fireMessage(msg: EMOutgoingMessageWithoutAnswer) {
if (!msg.id) {
msg.id = ++this.msgId;
}
@@ -493,11 +473,7 @@ export class ExternalMessaging {
// eslint-disable-next-line no-console
console.log("Sending message to external app", msg);
}
if (window.externalAppV2) {
window.externalAppV2.postMessage(
JSON.stringify({ type: "externalBus", payload: msg })
);
} else if (window.externalApp) {
if (window.externalApp) {
window.externalApp.externalBus(JSON.stringify(msg));
} else {
window.webkit!.messageHandlers.externalBus.postMessage(msg);
+1
View File
@@ -303,6 +303,7 @@ export const provideHass = (
debugConnection: false,
kioskMode: false,
suspendWhenHidden: false,
moreInfoEntityId: null as any,
// @ts-ignore
async callService(domain, service, data) {
if (data && "entity_id" in data) {
-1
View File
@@ -199,7 +199,6 @@ class HassSubpage extends LitElement {
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--ha-space-2);
--ha-button-box-shadow: var(--ha-box-shadow-l);
}
:host([narrow]) #fab.tabs {
bottom: calc(84px + var(--safe-area-inset-bottom, 0px));
+1 -21
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";
@@ -34,8 +28,6 @@ export interface PageNavigation {
not_component?: string | string[];
core?: boolean;
advancedOnly?: boolean;
/** Hide from non-admin users in filtered navigation and quick bar. */
adminOnly?: boolean;
iconPath?: string;
iconSecondaryPath?: string;
iconViewBox?: string;
@@ -83,8 +75,6 @@ export class HassTabsSubpage extends LitElement {
@state() private _activeTab?: PageNavigation;
@query(".content") private _content?: HTMLDivElement;
// @ts-ignore
@restoreScroll(".content") private _savedScrollPos?: number;
@@ -221,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();
@@ -422,7 +403,6 @@ export class HassTabsSubpage extends LitElement {
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--ha-space-2);
--ha-button-box-shadow: var(--ha-box-shadow-l);
}
:host([narrow][show-tabs]) #fab {
bottom: calc(84px + var(--safe-area-inset-bottom, 0px));
-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"),
};

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