Compare commits

..

11 Commits

Author SHA1 Message Date
Paul Bottein d6b7a88f3e Simplify ll-rebuild for section 2026-03-11 17:19:07 +01:00
Paul Bottein 26210c84b9 Remove force refresh in hui-root 2026-03-11 10:47:10 +01:00
Paul Bottein b7d1ee874f Add deep equal to avoid unecessary re-rendering 2026-03-11 10:47:10 +01:00
Paul Bottein a390b1aa6c Improve panel home logic 2026-03-11 10:47:10 +01:00
Paul Bottein 6e69811941 Simplify usage with registryDependencies 2026-03-11 10:47:10 +01:00
Paul Bottein 1e23fda276 Fix some conditions 2026-03-11 10:47:10 +01:00
Paul Bottein 4729073b8c Add should regenerate for all strategies 2026-03-11 10:47:10 +01:00
Paul Bottein 4d54b732cf Add should regenerate for view and section 2026-03-11 10:47:10 +01:00
Paul Bottein 09b1f7dd93 Rename method 2026-03-11 10:47:09 +01:00
Paul Bottein 2c800de025 Check for floors 2026-03-11 10:47:09 +01:00
Paul Bottein 2e70d70e38 Add should update method to strategies 2026-03-11 10:47:09 +01:00
711 changed files with 17063 additions and 25615 deletions
-3
View File
@@ -5,9 +5,6 @@ updates:
schedule:
interval: weekly
time: "06:00"
cooldown:
default-days-before-reopen: 30
default-days: 7
open-pull-requests-limit: 10
labels:
- Dependencies
-5
View File
@@ -8,9 +8,6 @@ on:
branches:
- master
permissions:
contents: read
env:
NODE_OPTIONS: --max_old_space_size=6144
@@ -27,7 +24,6 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: dev
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -63,7 +59,6 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: master
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+1 -10
View File
@@ -18,9 +18,6 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
lint:
name: Lint and check format
@@ -28,8 +25,6 @@ jobs:
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -42,7 +37,7 @@ jobs:
- name: Build resources
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages
- name: Setup lint cache
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: |
node_modules/.cache/prettier
@@ -64,8 +59,6 @@ jobs:
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -84,8 +77,6 @@ jobs:
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
+3 -8
View File
@@ -7,10 +7,6 @@ on:
# The branches below must be a subset of the branches above
branches: [dev]
permissions:
contents: read
security-events: write
jobs:
analyze:
name: Analyze
@@ -32,7 +28,6 @@ jobs:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
fetch-depth: 2
persist-credentials: false
# If this run was triggered by a pull request event, then checkout
# the head of the pull request instead of the merge commit.
@@ -41,14 +36,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
with:
languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
uses: github/codeql-action/autobuild@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -62,4 +57,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
-5
View File
@@ -9,9 +9,6 @@ on:
- dev
- master
permissions:
contents: read
env:
NODE_OPTIONS: --max_old_space_size=6144
@@ -28,7 +25,6 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: dev
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -64,7 +60,6 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: master
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
-5
View File
@@ -5,9 +5,6 @@ on:
schedule:
- cron: "0 0 * * *"
permissions:
contents: read
env:
NODE_OPTIONS: --max_old_space_size=6144
@@ -20,8 +17,6 @@ jobs:
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
-5
View File
@@ -10,9 +10,6 @@ on:
branches:
- dev
permissions:
contents: read
env:
NODE_OPTIONS: --max_old_space_size=6144
@@ -25,8 +22,6 @@ jobs:
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+1 -1
View File
@@ -1,6 +1,6 @@
name: "Pull Request Labeler"
on: pull_request_target # zizmor: ignore[dangerous-triggers] -- safe: only runs actions/labeler, no PR code checkout
on: pull_request_target
jobs:
triage:
-4
View File
@@ -5,10 +5,6 @@ on:
schedule:
- cron: "0 * * * *"
permissions:
issues: write
pull-requests: write
jobs:
lock:
runs-on: ubuntu-latest
-2
View File
@@ -21,8 +21,6 @@ jobs:
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
+9 -23
View File
@@ -1,39 +1,25 @@
name: RelativeCI
on:
# zizmor: ignore[dangerous-triggers] -- safe: only downloads artifacts, no PR code checkout
workflow_run:
workflows: [CI]
types:
- completed
permissions:
contents: read
actions: read
jobs:
upload-frontend-modern:
name: Upload stats (frontend/modern)
upload:
name: Upload stats
if: ${{ github.event.workflow_run.conclusion == 'success' }}
strategy:
matrix:
bundle: [frontend]
build: [modern, legacy]
runs-on: ubuntu-latest
steps:
- name: Send bundle stats and build information to RelativeCI
uses: relative-ci/agent-action@3c681926017930047fc03acaa35cd6a44efcbfc3 # v3.2.2
with:
key: ${{ secrets.RELATIVE_CI_KEY_frontend_modern }}
key: ${{ secrets[format('RELATIVE_CI_KEY_{0}_{1}', matrix.bundle, matrix.build)] }}
token: ${{ github.token }}
artifactName: frontend-bundle-stats
webpackStatsFile: frontend-modern.json
upload-frontend-legacy:
name: Upload stats (frontend/legacy)
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
steps:
- name: Send bundle stats and build information to RelativeCI
uses: relative-ci/agent-action@3c681926017930047fc03acaa35cd6a44efcbfc3 # v3.2.2
with:
key: ${{ secrets.RELATIVE_CI_KEY_frontend_legacy }}
token: ${{ github.token }}
artifactName: frontend-bundle-stats
webpackStatsFile: frontend-legacy.json
artifactName: ${{ format('{0}-bundle-stats', matrix.bundle) }}
webpackStatsFile: ${{ format('{0}-{1}.json', matrix.bundle, matrix.build) }}
+1 -1
View File
@@ -18,6 +18,6 @@ jobs:
pull-requests: read
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@139054aeaa9adc52ab36ddf67437541f039b88e2 # v7.1.1
- uses: release-drafter/release-drafter@6a93d829887aa2e0748befe2e808c66c0ec6e4c7 # v6.4.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+14 -20
View File
@@ -27,8 +27,6 @@ jobs:
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
@@ -36,12 +34,13 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@d56d093b9ab8d2105bc0cb6ee9bcc0ef4ec8b96d # master
uses: home-assistant/actions/helpers/verify-version@master
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install
@@ -63,10 +62,11 @@ jobs:
skip-existing: true
- name: Upload release assets
env:
GH_TOKEN: ${{ github.token }}
TAG_NAME: ${{ github.event.release.tag_name }}
run: gh release upload "$TAG_NAME" dist/*.whl dist/*.tar.gz --clobber
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
files: |
dist/*.whl
dist/*.tar.gz
wheels-init:
name: Init wheels build
@@ -74,17 +74,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Generate requirements.txt
env:
GITHUB_REF: ${{ github.ref }}
run: |
# Sleep to give pypi time to populate the new version across mirrors
sleep 240
version=$(echo "$GITHUB_REF" | awk -F"/" '{print $NF}' )
version=$(echo "${{ github.ref }}" | awk -F"/" '{print $NF}' )
echo "home-assistant-frontend==$version" > ./requirements.txt
# home-assistant/wheels doesn't support SHA pinning
- name: Build wheels
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
uses: home-assistant/wheels@2025.12.0
with:
abi: cp314
tag: musllinux_1_2
@@ -101,12 +99,11 @@ jobs:
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install
- name: Download Translations
@@ -116,11 +113,8 @@ jobs:
- name: Build landing-page
run: landing-page/script/build_landing_page
- name: Tar folder
env:
TAG_NAME: ${{ github.event.release.tag_name }}
run: tar -czf "landing-page/home_assistant_frontend_landingpage-${TAG_NAME}.tar.gz" -C landing-page/dist .
run: tar -czf landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz -C landing-page/dist .
- name: Upload release asset
env:
GH_TOKEN: ${{ github.token }}
TAG_NAME: ${{ github.event.release.tag_name }}
run: gh release upload "$TAG_NAME" "landing-page/home_assistant_frontend_landingpage-${TAG_NAME}.tar.gz" --clobber
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
files: landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz
-4
View File
@@ -5,10 +5,6 @@ on:
schedule:
- cron: "0 * * * *"
permissions:
issues: write
pull-requests: write
jobs:
stale:
runs-on: ubuntu-latest
-5
View File
@@ -8,9 +8,6 @@ on:
paths:
- src/translations/en.json
permissions:
contents: read
jobs:
upload:
name: Upload
@@ -18,8 +15,6 @@ jobs:
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Upload Translations
run: |
+1 -1
View File
@@ -1 +1 @@
24.14.1
24.14.0
@@ -31,7 +31,7 @@ index 8795ddcaa77aea7b0356417e4bc4b19e2b3f860c..fcdc68342d9ac53936c9ed40a9ccfc2f
@@ -129,7 +129,10 @@ export async function injectManifest(
searchString: options.injectionPoint!,
});
- filesToWrite[options.swDest] = source;
+ filesToWrite[options.swDest] = source.replace(
+ url!,
+942
View File
File diff suppressed because one or more lines are too long
-940
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -8,4 +8,4 @@ enableGlobalCache: false
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.13.0.cjs
yarnPath: .yarn/releases/yarn-4.12.0.cjs
+48 -91
View File
@@ -99,44 +99,6 @@ const lokaliseProjects = {
frontend: "3420425759f6d6d241f598.13594006",
};
const POLL_INTERVAL_MS = 1000;
/* eslint-disable no-await-in-loop */
async function pollProcess(lokaliseApi, projectId, processId) {
while (true) {
const process = await lokaliseApi
.queuedProcesses()
.get(processId, { project_id: projectId });
const project =
projectId === lokaliseProjects.backend ? "backend" : "frontend";
if (process.status === "finished") {
console.log(`Lokalise export process for ${project} finished`);
return process;
}
if (process.status === "failed" || process.status === "cancelled") {
throw new Error(
`Lokalise export process for ${project} ${process.status}: ${process.message}`
);
}
console.log(
`Lokalise export process for ${project} in progress...`,
process.status,
process.details?.items_to_process
? `${Math.round(((process.details.items_processed || 0) / process.details.items_to_process) * 100)}% (${process.details.items_processed}/${process.details.items_to_process})`
: ""
);
await new Promise((resolve) => {
setTimeout(resolve, POLL_INTERVAL_MS);
});
}
}
/* eslint-enable no-await-in-loop */
gulp.task("fetch-lokalise", async function () {
let apiKey;
try {
@@ -156,60 +118,55 @@ gulp.task("fetch-lokalise", async function () {
]);
await Promise.all(
Object.entries(lokaliseProjects).map(async ([project, projectId]) => {
try {
const exportProcess = await lokaliseApi
.files()
.async_download(projectId, {
format: "json",
original_filenames: false,
replace_breaks: false,
json_unescaped_slashes: true,
export_empty_as: "skip",
filter_data: ["verified"],
});
const finishedProcess = await pollProcess(
lokaliseApi,
projectId,
exportProcess.process_id
);
const bundleUrl = finishedProcess.details.download_url;
console.log(`Downloading translations from: ${bundleUrl}`);
const response = await fetch(bundleUrl);
if (response.status !== 200 && response.status !== 0) {
Object.entries(lokaliseProjects).map(([project, projectId]) =>
lokaliseApi
.files()
.download(projectId, {
format: "json",
original_filenames: false,
replace_breaks: false,
json_unescaped_slashes: true,
export_empty_as: "skip",
filter_data: ["verified"],
})
.then((download) => fetch(download.bundle_url))
.then((response) => {
if (response.status === 200 || response.status === 0) {
return response.arrayBuffer();
}
throw new Error(response.statusText);
}
console.log(`Extracting translations...`);
const contents = await JSZip.loadAsync(await response.arrayBuffer());
await mkdirPromise;
await Promise.all(
Object.keys(contents.files).map(async (filename) => {
const file = contents.file(filename);
if (!file) {
// no file, probably a directory
return;
}
const content = await file.async("nodebuffer");
await fs.writeFile(
path.join(inDir, project, filename.split("/").splice(-1)[0]),
content,
{ flag: "w", encoding }
);
})
);
} catch (err) {
console.error(err);
throw err;
}
})
})
.then(JSZip.loadAsync)
.then(async (contents) => {
await mkdirPromise;
return Promise.all(
Object.keys(contents.files).map(async (filename) => {
const file = contents.file(filename);
if (!file) {
// no file, probably a directory
return Promise.resolve();
}
return file
.async("nodebuffer")
.then((content) =>
fs.writeFile(
path.join(
inDir,
project,
filename.split("/").splice(-1)[0]
),
content,
{ flag: "w", encoding }
)
);
})
);
})
.catch((err) => {
console.error(err);
throw err;
})
)
);
});
+3 -9
View File
@@ -40,24 +40,18 @@ const convertToJSON = async (
throw e;
}
// Convert to JSON
const parts = localeData.split("} else {");
const firstBlock = parts[0];
const obj = INTL_POLYFILLS[pkg];
const dataRegex = new RegExp(
`Intl\\.${obj}\\.${addFunc}\\((?<data>.*)\\)`,
"s"
);
localeData = firstBlock.match(dataRegex)?.groups?.data;
localeData = localeData.match(dataRegex)?.groups?.data;
if (!localeData) {
throw Error(`Failed to extract data for language ${lang} from ${pkg}`);
}
// Parse to validate JSON, then stringify to minify
try {
localeData = JSON.stringify(JSON.parse(localeData));
await writeFile(join(outDir, `${pkg}/${lang}.json`), localeData);
} catch (e) {
throw Error(`Failed to parse JSON for language ${lang} from ${pkg}: ${e}`);
}
localeData = JSON.stringify(JSON.parse(localeData));
await writeFile(join(outDir, `${pkg}/${lang}.json`), localeData);
};
gulp.task("clean-locale-data", async () => deleteSync([outDir]));
-6
View File
@@ -57,12 +57,6 @@ const runDevServer = async ({
directory: contentBase,
watch: true,
},
client: {
overlay: {
runtimeErrors: (error) =>
!error?.message?.includes("ResizeObserver loop"),
},
},
proxy,
},
compiler
+5 -5
View File
@@ -26,7 +26,7 @@ import "../../../../src/components/ha-svg-icon";
import "../../../../src/layouts/hass-loading-screen";
import { registerServiceWorker } from "../../../../src/util/register-service-worker";
import "./hc-layout";
import "../../../../src/components/input/ha-input";
import "../../../../src/components/ha-textfield";
import "../../../../src/components/ha-button";
const seeFAQ = (qid) => html`
@@ -123,11 +123,11 @@ export class HcConnect extends LitElement {
To get started, enter your Home Assistant URL and click authorize.
If you want a preview instead, click the show demo button.
</p>
<ha-input
<ha-textfield
label="Home Assistant URL"
placeholder="https://abcdefghijklmnop.ui.nabu.casa"
@keydown=${this._handleInputKeyDown}
></ha-input>
></ha-textfield>
${this.error ? html` <p class="error">${this.error}</p> ` : ""}
</div>
<div class="card-actions">
@@ -204,7 +204,7 @@ export class HcConnect extends LitElement {
}
private async _handleConnect() {
const inputEl = this.shadowRoot!.querySelector("ha-input")!;
const inputEl = this.shadowRoot!.querySelector("ha-textfield")!;
const value = inputEl.value || "";
this.error = undefined;
@@ -319,7 +319,7 @@ export class HcConnect extends LitElement {
flex: 1;
}
ha-input {
ha-textfield {
width: 100%;
}
`;
+4 -4
View File
@@ -141,7 +141,7 @@ export const demoEntitiesSections: DemoConfig["entities"] = (localize) =>
},
},
"device_tracker.car": {
entity_id: "device_tracker.car",
entity_id: "sensor.outdoor_humidity",
state: "not_home",
attributes: {
friendly_name: "Car",
@@ -199,7 +199,7 @@ export const demoEntitiesSections: DemoConfig["entities"] = (localize) =>
},
},
"binary_sensor.kitchen_motion": {
entity_id: "binary_sensor.kitchen_motion",
entity_id: "light.kitchen_motion",
state: "on",
attributes: {
device_class: "motion",
@@ -335,7 +335,7 @@ export const demoEntitiesSections: DemoConfig["entities"] = (localize) =>
},
},
"sensor.rain": {
entity_id: "sensor.rain",
entity_id: "sensor.moon_phase",
state: "7.2",
attributes: {
state_class: "total_increasing",
@@ -565,7 +565,7 @@ export const demoEntitiesSections: DemoConfig["entities"] = (localize) =>
},
},
"update.home_assistant_core_update": {
entity_id: "update.home_assistant_core_update",
entity_id: "update.home_assistant_supervisor_update",
state: "off",
attributes: {
auto_update: false,
-1
View File
@@ -1,4 +1,3 @@
/// <reference types="chromecast-caf-sender" />
import { mdiTelevision } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
-6
View File
@@ -480,12 +480,6 @@ const SCHEMAS: {
},
{ type: "string", name: "path", default: "/" },
{ type: "boolean", name: "ssl", default: false },
{
type: "string",
name: "comments",
default: "disabled field",
disabled: true,
},
],
},
];
@@ -1,82 +0,0 @@
---
title: Input
---
# Input `<ha-input>`
A text input component supporting Home Assistant theming and validation, based on webawesome input.
Supports multiple input types including text, number, password, email, search, and more.
## Implementation
### Example usage
```html
<ha-input label="Name" value="Hello"></ha-input>
<ha-input label="Email" type="email" placeholder="you@example.com"></ha-input>
<ha-input label="Password" type="password" password-toggle></ha-input>
<ha-input label="Required" required></ha-input>
<ha-input label="Disabled" disabled value="Can't touch this"></ha-input>
```
### API
This component is based on the webawesome input component.
**Slots**
- `start`: Content placed before the input (usually for icons or prefixes).
- `end`: Content placed after the input (usually for icons or suffixes).
- `label`: Custom label content. Overrides the `label` property.
- `hint`: Custom hint content. Overrides the `hint` property.
- `clear-icon`: Custom clear icon.
- `show-password-icon`: Custom show password icon.
- `hide-password-icon`: Custom hide password icon.
**Properties/Attributes**
| Name | Type | Default | Description |
| -------------------- | ---------------------------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------- |
| appearance | "material"/"outlined" | "material" | Sets the input appearance style. "material" is the default filled style, "outlined" uses a bordered style. |
| type | "text"/"number"/"password"/"email"/"search"/"tel"/"url"/"date"/"datetime-local"/"time"/"color" | "text" | Sets the input type. |
| value | String | - | The current value of the input. |
| label | String | "" | The input's label text. |
| hint | String | "" | The input's hint/helper text. |
| placeholder | String | "" | Placeholder text shown when the input is empty. |
| with-clear | Boolean | false | Adds a clear button when the input is not empty. |
| readonly | Boolean | false | Makes the input readonly. |
| disabled | Boolean | false | Disables the input and prevents user interaction. |
| required | Boolean | false | Makes the input a required field. |
| password-toggle | Boolean | false | Adds a button to toggle the password visibility. |
| without-spin-buttons | Boolean | false | Hides the browser's built-in spin buttons for number inputs. |
| auto-validate | Boolean | false | Validates the input on blur instead of on form submit. |
| invalid | Boolean | false | Marks the input as invalid. |
| inset-label | Boolean | false | Uses an inset label style where the label stays inside the input. |
| validation-message | String | "" | Custom validation message shown when the input is invalid. |
| pattern | String | - | A regular expression pattern to validate input against. |
| minlength | Number | - | The minimum length of input that will be considered valid. |
| maxlength | Number | - | The maximum length of input that will be considered valid. |
| min | Number/String | - | The input's minimum value. Only applies to date and number input types. |
| max | Number/String | - | The input's maximum value. Only applies to date and number input types. |
| step | Number/"any" | - | Specifies the granularity that the value must adhere to. |
**CSS Custom Properties**
- `--ha-input-padding-top` - Padding above the input.
- `--ha-input-padding-bottom` - Padding below the input. Defaults to `var(--ha-space-2)`.
- `--ha-input-text-align` - Text alignment of the input. Defaults to `start`.
- `--ha-input-required-marker` - The marker shown after the label for required fields. Defaults to `"*"`.
---
## Derivatives
The following components extend or wrap `ha-input` for specific use cases:
- **`<ha-input-search>`** — A pre-configured search input with a magnify icon, clear button, and localized "Search" placeholder. Extends `ha-input`.
- **`<ha-input-copy>`** — A read-only input with a copy-to-clipboard button. Supports optional value masking with a reveal toggle.
- **`<ha-input-multi>`** — A dynamic list of text inputs for managing arrays of strings. Supports adding, removing, and drag-and-drop reordering.
-232
View File
@@ -1,232 +0,0 @@
import { ContextProvider } from "@lit/context";
import { mdiMagnify } from "@mdi/js";
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-svg-icon";
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 { localizeContext } from "../../../../src/data/context";
const LOCALIZE_KEYS: Record<string, string> = {
"ui.common.copy": "Copy",
"ui.common.show": "Show",
"ui.common.hide": "Hide",
"ui.common.add": "Add",
"ui.common.remove": "Remove",
"ui.common.search": "Search",
"ui.common.copied_clipboard": "Copied to clipboard",
};
@customElement("demo-components-ha-input")
export class DemoHaInput extends LitElement {
constructor() {
super();
// Provides localizeContext for ha-input-copy, ha-input-multi and ha-input-search
// eslint-disable-next-line no-new
new ContextProvider(this, {
context: localizeContext,
initialValue: ((key: string) => LOCALIZE_KEYS[key] ?? key) as any,
});
}
protected render(): TemplateResult {
return html`
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-input in ${mode}">
<div class="card-content">
<h3>Basic</h3>
<div class="row">
<ha-input label="Default"></ha-input>
<ha-input label="With value" value="Hello"></ha-input>
<ha-input
label="With placeholder"
placeholder="Type here..."
></ha-input>
</div>
<h3>Input types</h3>
<div class="row">
<ha-input label="Text" type="text" value="Text"></ha-input>
<ha-input label="Number" type="number" value="42"></ha-input>
<ha-input
label="Email"
type="email"
placeholder="you@example.com"
></ha-input>
</div>
<div class="row">
<ha-input
label="Password"
type="password"
value="secret"
password-toggle
></ha-input>
<ha-input label="URL" type="url" placeholder="https://...">
</ha-input>
<ha-input label="Date" type="date"></ha-input>
</div>
<h3>States</h3>
<div class="row">
<ha-input
label="Disabled"
disabled
value="Disabled"
></ha-input>
<ha-input
label="Readonly"
readonly
value="Readonly"
></ha-input>
<ha-input label="Required" required></ha-input>
</div>
<div class="row">
<ha-input
label="Invalid"
invalid
validation-message="This field is required"
value=""
></ha-input>
<ha-input label="With hint" hint="This is a hint"></ha-input>
<ha-input
label="With clear"
with-clear
value="Clear me"
></ha-input>
</div>
<h3>With slots</h3>
<div class="row">
<ha-input label="With prefix">
<span slot="start">$</span>
</ha-input>
<ha-input label="With suffix">
<span slot="end">kg</span>
</ha-input>
<ha-input label="With icon">
<ha-svg-icon .path=${mdiMagnify} slot="start"></ha-svg-icon>
</ha-input>
</div>
<h3>Appearance: outlined</h3>
<div class="row">
<ha-input
appearance="outlined"
label="Outlined"
value="Hello"
></ha-input>
<ha-input
appearance="outlined"
label="Outlined disabled"
disabled
value="Disabled"
></ha-input>
<ha-input
appearance="outlined"
label="Outlined invalid"
invalid
validation-message="Required"
></ha-input>
</div>
<div class="row">
<ha-input
appearance="outlined"
placeholder="Placeholder only"
></ha-input>
</div>
</div>
</ha-card>
<ha-card header="Derivatives in ${mode}">
<div class="card-content">
<h3>ha-input-search</h3>
<ha-input-search label="Search label"></ha-input-search>
<ha-input-search appearance="outlined"></ha-input-search>
<h3>ha-input-copy</h3>
<ha-input-copy
value="my-api-token-123"
masked-value="••••••••••••••••••"
masked-toggle
></ha-input-copy>
<h3>ha-input-multi</h3>
<ha-input-multi
label="URL"
add-label="Add URL"
.value=${["https://example.com"]}
></ha-input-multi>
</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-input": DemoHaInput;
}
}
+2 -6
View File
@@ -1,5 +1,5 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
@@ -692,11 +692,7 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
([key, value]) => html`
<ha-settings-row narrow slot=${slot}>
<span slot="heading">${value?.name || key}</span>
${value?.description
? html`<span slot="description"
>${value?.description}</span
>`
: nothing}
<span slot="description">${value?.description}</span>
<ha-selector
.hass=${this.hass}
.selector=${value!.selector}
+1 -1
View File
@@ -19,7 +19,7 @@ The Home Assistant interface is based on Material Design. It's a design system c
We want to make it as easy for designers to contribute as it is for developers. Theres a lot a designer can contribute to:
- Meet us at <a href="https://www.home-assistant.io/join-chat-design" rel="noopener noreferrer" target="_blank">Discord #designers channel</a>. If you can't see the channel, make sure you set the correct role in Channels & Roles.
- Start designing with our <a href="https://www.figma.com/design/2WGI8IDGyxINjSV6NRvPur/Home-Assistant-Design-Kit" rel="noopener noreferrer" target="_blank">Figma DesignKit</a>.
- Start designing with our <a href="https://www.figma.com/community/file/967153512097289521/Home-Assistant-DesignKit" rel="noopener noreferrer" target="_blank">Figma DesignKit</a>.
- Find the latest UX <a href="https://github.com/home-assistant/frontend/discussions?discussions_q=label%3Aux" rel="noopener noreferrer" target="_blank">discussions</a> and <a href="https://github.com/home-assistant/frontend/labels/ux" rel="noopener noreferrer" target="_blank">issues</a> on GitHub. Everyone can start a new issue or discussion!
## Developers
-15
View File
@@ -134,21 +134,6 @@ const CONFIGS = [
entity: sensor.not_working
`,
},
{
heading: "Lower minimum",
config: `
- type: gauge
entity: sensor.brightness_high
needle: true
severity:
green: 0
yellow: 0.45
red: 0.9
min: -0.05
name: " "
max: 1.9
unit: GBP/h`,
},
];
@customElement("demo-lovelace-gauge-card")
+1
View File
@@ -422,6 +422,7 @@ export class DemoEntityState extends LitElement {
return html`
<ha-data-table
.hass=${this.hass}
.columns=${this._columns(this.hass)}
.data=${this._rows()}
auto-height
+45 -39
View File
@@ -26,33 +26,33 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "7.29.2",
"@babel/runtime": "7.28.6",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.1",
"@codemirror/commands": "6.10.3",
"@codemirror/language": "6.12.3",
"@codemirror/commands": "6.10.2",
"@codemirror/language": "6.12.2",
"@codemirror/legacy-modes": "6.5.2",
"@codemirror/search": "6.6.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.40.0",
"@codemirror/state": "6.5.4",
"@codemirror/view": "6.39.16",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.3.1",
"@formatjs/intl-displaynames": "7.3.1",
"@formatjs/intl-durationformat": "0.10.3",
"@formatjs/intl-getcanonicallocales": "3.2.2",
"@formatjs/intl-listformat": "8.3.1",
"@formatjs/intl-locale": "5.3.1",
"@formatjs/intl-numberformat": "9.3.1",
"@formatjs/intl-pluralrules": "6.3.1",
"@formatjs/intl-relativetimeformat": "12.3.1",
"@formatjs/intl-datetimeformat": "7.2.4",
"@formatjs/intl-displaynames": "7.2.1",
"@formatjs/intl-durationformat": "0.10.1",
"@formatjs/intl-getcanonicallocales": "3.2.1",
"@formatjs/intl-listformat": "8.2.1",
"@formatjs/intl-locale": "5.2.1",
"@formatjs/intl-numberformat": "9.2.2",
"@formatjs/intl-pluralrules": "6.2.2",
"@formatjs/intl-relativetimeformat": "12.2.2",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
"@fullcalendar/list": "6.1.20",
"@fullcalendar/luxon3": "6.1.20",
"@fullcalendar/timegrid": "6.1.20",
"@home-assistant/webawesome": "3.3.1-ha.0",
"@home-assistant/webawesome": "3.3.1",
"@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.1.0",
"@lit-labs/observers": "2.1.0",
@@ -82,18 +82,19 @@
"@mdi/js": "7.4.47",
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.20",
"@swc/helpers": "0.5.19",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "3.9.1",
"@tsparticles/preset-links": "3.2.0",
"@vibrant/color": "4.0.4",
"@vue/web-component-wrapper": "1.3.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.1.1",
"cally": "0.9.2",
"app-datepicker": "5.1.1",
"barcode-detector": "3.1.0",
"color-name": "2.1.0",
"comlink": "4.4.2",
"core-js": "3.49.0",
"core-js": "3.48.0",
"cropperjs": "1.6.2",
"culori": "4.0.2",
"date-fns": "4.1.0",
@@ -108,7 +109,7 @@
"hls.js": "1.6.15",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "11.2.0",
"intl-messageformat": "11.1.2",
"js-yaml": "4.1.1",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
@@ -116,7 +117,7 @@
"lit": "3.3.2",
"lit-html": "3.3.2",
"luxon": "3.7.2",
"marked": "17.0.5",
"marked": "17.0.4",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -129,6 +130,9 @@
"stacktrace-js": "2.0.2",
"superstruct": "2.0.2",
"tinykeys": "3.0.0",
"ua-parser-js": "2.0.9",
"vue": "2.7.16",
"vue2-daterange-picker": "0.6.8",
"weekstart": "2.0.0",
"workbox-cacheable-response": "7.4.0",
"workbox-core": "7.4.0",
@@ -140,17 +144,17 @@
},
"devDependencies": {
"@babel/core": "7.29.0",
"@babel/helper-define-polyfill-provider": "0.6.8",
"@babel/helper-define-polyfill-provider": "0.6.7",
"@babel/plugin-transform-runtime": "7.29.0",
"@babel/preset-env": "7.29.2",
"@bundle-stats/plugin-webpack-filter": "4.22.0",
"@babel/preset-env": "7.29.0",
"@bundle-stats/plugin-webpack-filter": "4.21.10",
"@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.6",
"@rspack/core": "1.7.10",
"@rsdoctor/rspack-plugin": "1.5.2",
"@rspack/core": "1.7.7",
"@rspack/dev-server": "1.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.25",
@@ -168,9 +172,10 @@
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.1.2",
"babel-loader": "10.1.1",
"@vitest/coverage-v8": "4.0.18",
"babel-loader": "10.1.0",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
"del": "8.0.1",
@@ -192,9 +197,9 @@
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "29.0.1",
"jsdom": "28.1.0",
"jszip": "3.10.1",
"lint-staged": "16.4.0",
"lint-staged": "16.3.2",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.5.0",
@@ -203,19 +208,20 @@
"prettier": "3.8.1",
"rspack-manifest-plugin": "5.2.1",
"serve": "14.2.6",
"sinon": "21.0.3",
"tar": "7.5.13",
"terser-webpack-plugin": "5.4.0",
"sinon": "21.0.2",
"tar": "7.5.10",
"terser-webpack-plugin": "5.3.17",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.2",
"typescript-eslint": "8.57.2",
"typescript": "5.9.3",
"typescript-eslint": "8.56.1",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.2",
"vitest": "4.0.18",
"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"
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
},
"resolutions": {
"@material/mwc-button@^0.25.3": "^0.27.0",
"lit": "3.3.2",
"lit-html": "3.3.2",
"clean-css": "5.3.3",
@@ -226,8 +232,8 @@
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"glob@^10.2.2": "^10.5.0"
},
"packageManager": "yarn@4.13.0",
"packageManager": "yarn@4.12.0",
"volta": {
"node": "24.14.1"
"node": "24.14.0"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260325.0"
version = "20260128.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+5
View File
@@ -24,6 +24,11 @@
"extends": ["monorepo:material-components-web"],
"enabled": false
},
{
"description": "Vue is only used by date range which is only v2",
"matchPackageNames": ["vue"],
"allowedVersions": "< 3"
},
{
"description": "Group MDI packages",
"groupName": "Material Design Icons",
+61 -4
View File
@@ -1,7 +1,10 @@
/* eslint-disable lit/prefer-static-styles */
import type { TemplateResult } from "lit";
import { html } from "lit";
import { customElement } from "lit/decorators";
import { HaFormString } from "../components/ha-form/ha-form-string";
import "../components/ha-icon-button";
import "../components/input/ha-input";
import "./ha-auth-textfield";
@customElement("ha-auth-form-string")
export class HaAuthFormString extends HaFormString {
@@ -9,9 +12,63 @@ export class HaAuthFormString extends HaFormString {
return this;
}
public connectedCallback(): void {
super.connectedCallback();
this.style.position = "relative";
public reportValidity(): boolean {
return this.querySelector("ha-auth-textfield")?.reportValidity() ?? true;
}
protected render(): TemplateResult {
return html`
<style>
ha-auth-form-string {
display: block;
position: relative;
}
ha-auth-form-string[own-margin] {
margin-bottom: 5px;
}
ha-auth-form-string ha-auth-textfield {
display: block !important;
}
ha-auth-form-string ha-icon-button {
position: absolute;
top: 8px;
right: 8px;
inset-inline-start: initial;
inset-inline-end: 8px;
--ha-icon-button-size: 40px;
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
direction: var(--direction);
}
</style>
<ha-auth-textfield
.type=${!this.isPassword
? this.stringType
: this.unmaskedPassword
? "text"
: "password"}
.label=${this.label}
.value=${this.data || ""}
.helper=${this.helper}
helperPersistent
.disabled=${this.disabled}
.required=${this.schema.required}
.autoValidate=${this.schema.required}
.name=${this.schema.name}
.autocomplete=${this.schema.autocomplete}
?autofocus=${this.schema.autofocus}
.suffix=${this.isPassword
? // reserve some space for the icon.
html`<div style="width: 24px"></div>`
: this.schema.description?.suffix}
.validationMessage=${this.schema.required
? this.localize?.("ui.panel.page-authorize.form.error_required")
: undefined}
@input=${this._valueChanged}
@change=${this._valueChanged}
></ha-auth-textfield>
${this.renderIcon()}
`;
}
}
+1 -4
View File
@@ -1,9 +1,9 @@
/* eslint-disable lit/prefer-static-styles */
import { html } from "lit";
import { customElement, property } from "lit/decorators";
import type { LocalizeFunc } from "../common/translations/localize";
import { HaForm } from "../components/ha-form/ha-form";
import "./ha-auth-form-string";
import type { LocalizeFunc } from "../common/translations/localize";
const localizeBaseKey = "ui.panel.page-authorize.form";
@@ -34,9 +34,6 @@ export class HaAuthForm extends HaForm {
protected render() {
return html`
<style>
ha-auth-form {
--ha-input-required-marker: "";
}
ha-auth-form .root > * {
display: block;
}
+264
View File
@@ -0,0 +1,264 @@
/* eslint-disable lit/value-after-constraints */
/* eslint-disable lit/prefer-static-styles */
import { floatingLabel } from "@material/mwc-floating-label/mwc-floating-label-directive";
import type { TemplateResult } from "lit";
import { html } from "lit";
import { customElement } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { live } from "lit/directives/live";
import { HaTextField } from "../components/ha-textfield";
@customElement("ha-auth-textfield")
export class HaAuthTextField extends HaTextField {
protected renderLabel(): TemplateResult | string {
return !this.label
? ""
: html`
<span
.floatingLabelFoundation=${floatingLabel(
this.label
) as unknown as any}
.id=${this.name}
>${this.label}</span
>
`;
}
protected renderInput(shouldRenderHelperText: boolean): TemplateResult {
const minOrUndef = this.minLength === -1 ? undefined : this.minLength;
const maxOrUndef = this.maxLength === -1 ? undefined : this.maxLength;
const autocapitalizeOrUndef = this.autocapitalize
? (this.autocapitalize as
| "off"
| "none"
| "on"
| "sentences"
| "words"
| "characters")
: undefined;
const showValidationMessage = this.validationMessage && !this.isUiValid;
const ariaLabelledbyOrUndef = this.label ? this.name : undefined;
const ariaControlsOrUndef = shouldRenderHelperText
? "helper-text"
: undefined;
const ariaDescribedbyOrUndef =
this.focused || this.helperPersistent || showValidationMessage
? "helper-text"
: undefined;
// TODO: live() directive needs casting for lit-analyzer
// https://github.com/runem/lit-analyzer/pull/91/files
// TODO: lit-analyzer labels min/max as (number|string) instead of string
return html`<input
aria-labelledby=${ifDefined(ariaLabelledbyOrUndef)}
aria-controls=${ifDefined(ariaControlsOrUndef)}
aria-describedby=${ifDefined(ariaDescribedbyOrUndef)}
class="mdc-text-field__input"
type=${this.type}
.value=${live(this.value) as unknown as string}
?disabled=${this.disabled}
placeholder=${this.placeholder}
?required=${this.required}
?readonly=${this.readOnly}
minlength=${ifDefined(minOrUndef)}
maxlength=${ifDefined(maxOrUndef)}
pattern=${ifDefined(this.pattern ? this.pattern : undefined)}
min=${ifDefined(this.min === "" ? undefined : (this.min as number))}
max=${ifDefined(this.max === "" ? undefined : (this.max as number))}
step=${ifDefined(this.step === null ? undefined : (this.step as number))}
size=${ifDefined(this.size === null ? undefined : this.size)}
name=${ifDefined(this.name === "" ? undefined : this.name)}
inputmode=${ifDefined(this.inputMode)}
autocapitalize=${ifDefined(autocapitalizeOrUndef)}
?autofocus=${this.autofocus}
@input=${this.handleInputChange}
@focus=${this.onInputFocus}
@blur=${this.onInputBlur}
/>`;
}
public render() {
return html`
<style>
ha-auth-textfield {
display: inline-flex;
flex-direction: column;
outline: none;
}
ha-auth-textfield:not([disabled]):hover
:not(.mdc-text-field--invalid):not(.mdc-text-field--focused)
mwc-notched-outline {
--mdc-notched-outline-border-color: var(
--mdc-text-field-outlined-hover-border-color,
rgba(0, 0, 0, 0.87)
);
}
ha-auth-textfield:not([disabled])
.mdc-text-field:not(.mdc-text-field--outlined) {
background-color: var(--mdc-text-field-fill-color, whitesmoke);
}
ha-auth-textfield:not([disabled])
.mdc-text-field.mdc-text-field--invalid
mwc-notched-outline {
--mdc-notched-outline-border-color: var(
--mdc-text-field-error-color,
var(--mdc-theme-error, #b00020)
);
}
ha-auth-textfield:not([disabled])
.mdc-text-field.mdc-text-field--invalid
+ .mdc-text-field-helper-line
.mdc-text-field-character-counter,
ha-auth-textfield:not([disabled])
.mdc-text-field.mdc-text-field--invalid
.mdc-text-field__icon {
color: var(
--mdc-text-field-error-color,
var(--mdc-theme-error, #b00020)
);
}
ha-auth-textfield:not([disabled])
.mdc-text-field:not(.mdc-text-field--invalid):not(
.mdc-text-field--focused
)
.mdc-floating-label,
ha-auth-textfield:not([disabled])
.mdc-text-field:not(.mdc-text-field--invalid):not(
.mdc-text-field--focused
)
.mdc-floating-label::after {
color: var(--mdc-text-field-label-ink-color, rgba(0, 0, 0, 0.6));
}
ha-auth-textfield:not([disabled])
.mdc-text-field.mdc-text-field--focused
mwc-notched-outline {
--mdc-notched-outline-stroke-width: 2px;
}
ha-auth-textfield:not([disabled])
.mdc-text-field.mdc-text-field--focused:not(.mdc-text-field--invalid)
mwc-notched-outline {
--mdc-notched-outline-border-color: var(
--mdc-text-field-focused-label-color,
var(--mdc-theme-primary, rgba(98, 0, 238, 0.87))
);
}
ha-auth-textfield:not([disabled])
.mdc-text-field.mdc-text-field--focused:not(.mdc-text-field--invalid)
.mdc-floating-label {
color: #6200ee;
color: var(--mdc-theme-primary, #6200ee);
}
ha-auth-textfield:not([disabled])
.mdc-text-field
.mdc-text-field__input {
color: var(--mdc-text-field-ink-color, rgba(0, 0, 0, 0.87));
}
ha-auth-textfield:not([disabled])
.mdc-text-field
.mdc-text-field__input::placeholder {
color: var(--mdc-text-field-label-ink-color, rgba(0, 0, 0, 0.6));
}
ha-auth-textfield:not([disabled])
.mdc-text-field-helper-line
.mdc-text-field-helper-text:not(
.mdc-text-field-helper-text--validation-msg
),
ha-auth-textfield:not([disabled])
.mdc-text-field-helper-line:not(.mdc-text-field--invalid)
.mdc-text-field-character-counter {
color: var(--mdc-text-field-label-ink-color, rgba(0, 0, 0, 0.6));
}
ha-auth-textfield[disabled]
.mdc-text-field:not(.mdc-text-field--outlined) {
background-color: var(--mdc-text-field-disabled-fill-color, #fafafa);
}
ha-auth-textfield[disabled]
.mdc-text-field.mdc-text-field--outlined
mwc-notched-outline {
--mdc-notched-outline-border-color: var(
--mdc-text-field-outlined-disabled-border-color,
rgba(0, 0, 0, 0.06)
);
}
ha-auth-textfield[disabled]
.mdc-text-field:not(.mdc-text-field--invalid):not(
.mdc-text-field--focused
)
.mdc-floating-label,
ha-auth-textfield[disabled]
.mdc-text-field:not(.mdc-text-field--invalid):not(
.mdc-text-field--focused
)
.mdc-floating-label::after {
color: var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.38));
}
ha-auth-textfield[disabled] .mdc-text-field .mdc-text-field__input,
ha-auth-textfield[disabled]
.mdc-text-field
.mdc-text-field__input::placeholder {
color: var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.38));
}
ha-auth-textfield[disabled]
.mdc-text-field-helper-line
.mdc-text-field-helper-text,
ha-auth-textfield[disabled]
.mdc-text-field-helper-line
.mdc-text-field-character-counter {
color: var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.38));
}
ha-auth-textfield:not([disabled])
.mdc-text-field.mdc-text-field--focused:not(.mdc-text-field--invalid)
.mdc-floating-label {
color: var(--mdc-theme-primary, #6200ee);
}
ha-auth-textfield[no-spinner] input::-webkit-outer-spin-button,
ha-auth-textfield[no-spinner] input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
ha-auth-textfield[no-spinner] input[type="number"] {
-moz-appearance: textfield;
}
</style>
${super.render()}
`;
}
protected createRenderRoot() {
// add parent style to light dom
const style = document.createElement("style");
style.textContent = HaTextField.elementStyles as unknown as string;
this.append(style);
return this;
}
public firstUpdated() {
super.firstUpdated();
if (this.autofocus) {
this.focus();
}
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-auth-textfield": HaAuthTextField;
}
}
+3 -6
View File
@@ -1,5 +1,4 @@
/* eslint-disable no-console */
/// <reference types="chromecast-caf-sender" />
import type { Auth } from "home-assistant-js-websocket";
import { castApiAvailable } from "./cast_framework";
@@ -59,11 +58,9 @@ export class CastManager {
this._eventListeners[event].push(listener);
return () => {
const listeners = this._eventListeners[event];
const index = listeners.indexOf(listener);
if (index !== -1) {
listeners.splice(index, 1);
}
this._eventListeners[event].splice(
this._eventListeners[event].indexOf(listener)
);
};
}
-28
View File
@@ -32,12 +32,6 @@ const YAML_ONLY_THEMES_COLORS = new Set([
"disabled",
]);
/**
* Compose a CSS variable out of a theme color
* @param color - Theme color (examples: `red`, `primary-text`)
* @returns CSS variable in `--xxx-color` format;
* initial color if not found in theme colors
*/
export function computeCssVariableName(color: string): string {
if (THEME_COLORS.has(color) || YAML_ONLY_THEMES_COLORS.has(color)) {
return `--${color}-color`;
@@ -45,12 +39,6 @@ export function computeCssVariableName(color: string): string {
return color;
}
/**
* Compose a CSS variable out of a theme color & then resolve it
* @param color - Theme color (examples: `red`, `primary-text`)
* @returns Resolved CSS variable in `var(--xxx-color)` format;
* initial color if not found in theme colors
*/
export function computeCssColor(color: string): string {
const cssVarName = computeCssVariableName(color);
if (cssVarName !== color) {
@@ -59,22 +47,6 @@ export function computeCssColor(color: string): string {
return color;
}
/**
* Get a color from document's styles
* @param color - Named theme color (examples: `red`, `primary-text`)
* @returns Resolved color; initial color if not found in document's styles
*/
export function resolveThemeColor(color: string): string {
const cssColor = computeCssVariableName(color);
if (cssColor.startsWith("--")) {
const resolved = getComputedStyle(document.body)
.getPropertyValue(cssColor)
.trim();
return resolved || color;
}
return cssColor;
}
/**
* Validates if a string is a valid color.
* Accepts: hex colors (#xxx, #xxxxxx), theme colors, and valid CSS color names.
+12 -30
View File
@@ -1,6 +1,5 @@
import colors from "color-name";
import { expandHex } from "./hex";
import { resolveThemeColor } from "./compute-color";
const rgb_hex = (component: number): string => {
const hex = Math.round(Math.min(Math.max(component, 0), 255)).toString(16);
@@ -131,43 +130,26 @@ export const rgb2hs = (rgb: [number, number, number]): [number, number] =>
export const hs2rgb = (hs: [number, number]): [number, number, number] =>
hsv2rgb([hs[0], hs[1], 255]);
/**
* Attempt to get a HEX color from a color defined in different formats:
* HEX, rgb/rgba, named color
* @param color - Color (HEX, rgb/rgba, named color) to be converted to HEX
* @returns HEX color
*/
export function theme2hex(color: string): string {
// Attempting to find a HEX pattern in the input string
if (color.startsWith("#")) {
if (color.length === 4 || color.length === 5) {
const c = color;
export function theme2hex(themeColor: string): string {
if (themeColor.startsWith("#")) {
if (themeColor.length === 4 || themeColor.length === 5) {
const c = themeColor;
// Convert short-form hex (#abc) to 6 digit (#aabbcc). Ignore alpha channel.
return `#${c[1]}${c[1]}${c[2]}${c[2]}${c[3]}${c[3]}`;
}
if (color.length === 9) {
if (themeColor.length === 9) {
// Ignore alpha channel.
return color.substring(0, 7);
return themeColor.substring(0, 7);
}
return color;
return themeColor;
}
// Attempting to find a match in a HA Frontend theme colors
const themeColor = resolveThemeColor(color.toLowerCase());
if (themeColor !== color.toLowerCase()) {
// theme color is recognized, now re-attempt
return theme2hex(themeColor);
const rgbFromColorName = colors[themeColor.toLowerCase()];
if (rgbFromColorName) {
return rgb2hex(rgbFromColorName);
}
// Attempting to find a match in a web colors array
const rgbFromWebColor = colors[color.toLowerCase()];
if (rgbFromWebColor) {
// HEX color is recognized for the input named color
return rgb2hex(rgbFromWebColor);
}
// Attempting to find an RGB pattern in the input string
const rgbMatch = color.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
const rgbMatch = themeColor.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (rgbMatch) {
const [, r, g, b] = rgbMatch.map(Number);
return rgb2hex([r, g, b]);
@@ -176,5 +158,5 @@ export function theme2hex(color: string): string {
// We have a named color, and there's nothing in the table,
// so nothing further we can do with it.
// Compare/border/background color will all be the same.
return color;
return themeColor;
}
-11
View File
@@ -1,5 +1,4 @@
import { wcagLuminance, wcagContrast } from "culori";
import { theme2hex } from "./convert-color";
/**
* Calculates the luminosity of an RGB color.
@@ -49,13 +48,3 @@ export const getRGBContrastRatio = (
rgb1: [number, number, number],
rgb2: [number, number, number]
) => Math.round((rgbContrast(rgb1, rgb2) + Number.EPSILON) * 100) / 100;
/**
* Returns a contrasted color (black or white) based on the luminance of another color
* @param color - Color (HEX, rgb/rgba, named color) to calculate a contrasted color
* @returns HEX color ("#000000" for dark backgrounds, "#ffffff" for light backgrounds)
*/
export const getContrastedColorHex = (color: string): string => {
const lum = wcagLuminance(theme2hex(color));
return lum > 0.5 ? "#000000" : "#ffffff";
};
+5 -26
View File
@@ -1,9 +1,6 @@
import { listenMediaQuery } from "../dom/media_query";
import type { HomeAssistant } from "../../types";
import type {
Condition,
ConditionContext,
} from "../../panels/lovelace/common/validate-condition";
import type { Condition } from "../../panels/lovelace/common/validate-condition";
import { checkConditionsMet } from "../../panels/lovelace/common/validate-condition";
import { extractMediaQueries, extractTimeConditions } from "./extract";
import { calculateNextTimeUpdate } from "./time-calculator";
@@ -22,8 +19,7 @@ export function setupMediaQueryListeners(
conditions: Condition[],
hass: HomeAssistant,
addListener: (unsub: () => void) => void,
onUpdate: (conditionsMet: boolean) => void,
getContext?: () => ConditionContext
onUpdate: (conditionsMet: boolean) => void
): void {
const mediaQueries = extractMediaQueries(conditions);
@@ -40,8 +36,7 @@ export function setupMediaQueryListeners(
if (hasOnlyMediaQuery) {
onUpdate(matches);
} else {
const context = getContext?.() ?? {};
const conditionsMet = checkConditionsMet(conditions, hass, context);
const conditionsMet = checkConditionsMet(conditions, hass);
onUpdate(conditionsMet);
}
});
@@ -56,8 +51,7 @@ export function setupTimeListeners(
conditions: Condition[],
hass: HomeAssistant,
addListener: (unsub: () => void) => void,
onUpdate: (conditionsMet: boolean) => void,
getContext?: () => ConditionContext
onUpdate: (conditionsMet: boolean) => void
): void {
const timeConditions = extractTimeConditions(conditions);
@@ -76,8 +70,7 @@ export function setupTimeListeners(
timeoutId = setTimeout(() => {
if (delay <= MAX_TIMEOUT_DELAY) {
const context = getContext?.() ?? {};
const conditionsMet = checkConditionsMet(conditions, hass, context);
const conditionsMet = checkConditionsMet(conditions, hass);
onUpdate(conditionsMet);
}
scheduleUpdate();
@@ -94,17 +87,3 @@ export function setupTimeListeners(
scheduleUpdate();
});
}
/**
* Sets up all condition listeners (media query, time) for conditional visibility.
*/
export function setupConditionListeners(
conditions: Condition[],
hass: HomeAssistant,
addListener: (unsub: () => void) => void,
onUpdate: (conditionsMet: boolean) => void,
getContext?: () => ConditionContext
): void {
setupMediaQueryListeners(conditions, hass, addListener, onUpdate, getContext);
setupTimeListeners(conditions, hass, addListener, onUpdate, getContext);
}
+2 -2
View File
@@ -14,7 +14,7 @@ export const isLoadedIntegration = (
) =>
!page.component ||
ensureArray(page.component).some((integration) =>
isComponentLoaded(hass.config, integration)
isComponentLoaded(hass, integration)
);
export const isNotLoadedIntegration = (
@@ -23,7 +23,7 @@ export const isNotLoadedIntegration = (
) =>
!page.not_component ||
!ensureArray(page.not_component).some((integration) =>
isComponentLoaded(hass.config, integration)
isComponentLoaded(hass, integration)
);
export const isCore = (page: PageNavigation) => page.core;
+2 -2
View File
@@ -2,6 +2,6 @@ import type { HomeAssistant } from "../../types";
/** Return if a component is loaded. */
export const isComponentLoaded = (
hassConfig: HomeAssistant["config"],
hass: HomeAssistant,
component: string
): boolean => hassConfig && hassConfig.components.includes(component);
): boolean => hass && hass.config.components.includes(component);
+29 -35
View File
@@ -1,17 +1,17 @@
import {
addDays,
subHours,
endOfDay,
endOfMonth,
endOfQuarter,
endOfWeek,
endOfYear,
startOfDay,
startOfMonth,
startOfQuarter,
startOfWeek,
startOfYear,
startOfQuarter,
endOfQuarter,
subDays,
subHours,
subMonths,
} from "date-fns";
import type { HomeAssistant } from "../../types";
@@ -33,89 +33,83 @@ export type DateRange =
| "now-24h";
export const calcDateRange = (
locale: HomeAssistant["locale"],
hassConfig: HomeAssistant["config"],
hass: HomeAssistant,
range: DateRange
): [Date, Date] => {
const today = new Date();
const weekStartsOn = firstWeekdayIndex(locale);
const weekStartsOn = firstWeekdayIndex(hass.locale);
switch (range) {
case "today":
return [
calcDate(today, startOfDay, locale, hassConfig, {
calcDate(today, startOfDay, hass.locale, hass.config, {
weekStartsOn,
}),
calcDate(today, endOfDay, locale, hassConfig, {
calcDate(today, endOfDay, hass.locale, hass.config, {
weekStartsOn,
}),
];
case "yesterday":
return [
calcDate(addDays(today, -1), startOfDay, locale, hassConfig, {
calcDate(addDays(today, -1), startOfDay, hass.locale, hass.config, {
weekStartsOn,
}),
calcDate(addDays(today, -1), endOfDay, locale, hassConfig, {
calcDate(addDays(today, -1), endOfDay, hass.locale, hass.config, {
weekStartsOn,
}),
];
case "this_week":
return [
calcDate(today, startOfWeek, locale, hassConfig, {
calcDate(today, startOfWeek, hass.locale, hass.config, {
weekStartsOn,
}),
calcDate(today, endOfWeek, locale, hassConfig, {
calcDate(today, endOfWeek, hass.locale, hass.config, {
weekStartsOn,
}),
];
case "this_month":
return [
calcDate(today, startOfMonth, locale, hassConfig),
calcDate(today, endOfMonth, locale, hassConfig),
calcDate(today, startOfMonth, hass.locale, hass.config),
calcDate(today, endOfMonth, hass.locale, hass.config),
];
case "this_quarter":
return [
calcDate(today, startOfQuarter, locale, hassConfig),
calcDate(today, endOfQuarter, locale, hassConfig),
calcDate(today, startOfQuarter, hass.locale, hass.config),
calcDate(today, endOfQuarter, hass.locale, hass.config),
];
case "this_year":
return [
calcDate(today, startOfYear, locale, hassConfig),
calcDate(today, endOfYear, locale, hassConfig),
calcDate(today, startOfYear, hass.locale, hass.config),
calcDate(today, endOfYear, hass.locale, hass.config),
];
case "now-7d":
return [
calcDate(today, subDays, locale, hassConfig, 7),
calcDate(today, subDays, locale, hassConfig, 0),
calcDate(today, subDays, hass.locale, hass.config, 7),
calcDate(today, subDays, hass.locale, hass.config, 0),
];
case "now-30d":
return [
calcDate(today, subDays, locale, hassConfig, 30),
calcDate(today, subDays, locale, hassConfig, 0),
calcDate(today, subDays, hass.locale, hass.config, 30),
calcDate(today, subDays, hass.locale, hass.config, 0),
];
case "now-12m":
return [
calcDate(
today,
(date) => subMonths(startOfMonth(date), 11),
locale,
hassConfig
),
calcDate(today, endOfMonth, locale, hassConfig),
calcDate(today, subMonths, hass.locale, hass.config, 12),
calcDate(today, subMonths, hass.locale, hass.config, 0),
];
case "now-1h":
return [
calcDate(today, subHours, locale, hassConfig, 1),
calcDate(today, subHours, locale, hassConfig, 0),
calcDate(today, subHours, hass.locale, hass.config, 1),
calcDate(today, subHours, hass.locale, hass.config, 0),
];
case "now-12h":
return [
calcDate(today, subHours, locale, hassConfig, 12),
calcDate(today, subHours, locale, hassConfig, 0),
calcDate(today, subHours, hass.locale, hass.config, 12),
calcDate(today, subHours, hass.locale, hass.config, 0),
];
case "now-24h":
return [
calcDate(today, subHours, locale, hassConfig, 24),
calcDate(today, subHours, locale, hassConfig, 0),
calcDate(today, subHours, hass.locale, hass.config, 24),
calcDate(today, subHours, hass.locale, hass.config, 0),
];
}
return [today, today];
-48
View File
@@ -166,21 +166,6 @@ const formatDateMonthMem = memoizeOne(
})
);
// Aug
export const formatDateMonthShort = (
dateObj: Date,
locale: FrontendLocaleData,
config: HassConfig
) => formatDateMonthShortMem(locale, config.time_zone).format(dateObj);
const formatDateMonthShortMem = memoizeOne(
(locale: FrontendLocaleData, serverTimeZone: string) =>
new Intl.DateTimeFormat(locale.language, {
month: "short",
timeZone: resolveTimeZone(locale.time_zone, serverTimeZone),
})
);
// 2021
export const formatDateYear = (
dateObj: Date,
@@ -261,36 +246,3 @@ const formatDateWeekdayShortDateMem = memoizeOne(
timeZone: resolveTimeZone(locale.time_zone, serverTimeZone),
})
);
/**
* Format a date as YYYY-MM-DD. Uses "en-CA" because it's the only
* Intl locale that natively outputs ISO 8601 date format.
* Locale/config are only used to resolve the time zone.
*/
export const formatISODateOnly = (
dateObj: Date,
locale: FrontendLocaleData,
config: HassConfig
) => {
const timeZone = resolveTimeZone(locale.time_zone, config.time_zone);
const formatter = new Intl.DateTimeFormat("en-CA", {
year: "numeric",
month: "2-digit",
day: "2-digit",
timeZone,
});
return formatter.format(dateObj);
};
// 2026-08-10/2026-08-15
export const formatCallyDateRange = (
start: Date,
end: Date,
locale: FrontendLocaleData,
config: HassConfig
) => {
const startDate = formatISODateOnly(start, locale, config);
const endDate = formatISODateOnly(end, locale, config);
return `${startDate}/${endDate}`;
};
+6 -7
View File
@@ -14,25 +14,24 @@ export const computeDeviceName = (
export const computeDeviceNameDisplay = (
device: DeviceRegistryEntry,
localize: HomeAssistant["localize"],
hassStates: HomeAssistant["states"],
hass: HomeAssistant,
entities?: EntityRegistryEntry[] | EntityRegistryDisplayEntry[] | string[]
) =>
computeDeviceName(device) ||
(entities && fallbackDeviceName(hassStates, entities)) ||
localize("ui.panel.config.devices.unnamed_device", {
type: localize(
(entities && fallbackDeviceName(hass, entities)) ||
hass.localize("ui.panel.config.devices.unnamed_device", {
type: hass.localize(
`ui.panel.config.devices.type.${device.entry_type || "device"}`
),
});
export const fallbackDeviceName = (
hassStates: HomeAssistant["states"],
hass: HomeAssistant,
entities: EntityRegistryEntry[] | EntityRegistryDisplayEntry[] | string[]
) => {
for (const entity of entities || []) {
const entityId = typeof entity === "string" ? entity : entity.entity_id;
const stateObj = hassStates[entityId];
const stateObj = hass.states[entityId];
if (stateObj) {
return computeStateName(stateObj);
}
@@ -5,7 +5,6 @@ import { computeAreaName } from "./compute_area_name";
import { computeDeviceName } from "./compute_device_name";
import { computeEntityName, entityUseDeviceName } from "./compute_entity_name";
import { computeFloorName } from "./compute_floor_name";
import { computeStateName } from "./compute_state_name";
import { getEntityContext } from "./context/get_entity_context";
const DEFAULT_SEPARATOR = " ";
@@ -30,23 +29,14 @@ export interface EntityNameOptions {
export const computeEntityNameDisplay = (
stateObj: HassEntity,
name: string | EntityNameItem | EntityNameItem[] | undefined,
name: EntityNameItem | EntityNameItem[] | undefined,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"],
options?: EntityNameOptions
) => {
if (typeof name === "string") {
return name;
}
// If no name config is provided, fall back to the friendly name
if (!name) {
return computeStateName(stateObj);
}
let items = ensureArray(name);
let items = ensureArray(name || DEFAULT_ENTITY_NAME);
const separator = options?.separator ?? DEFAULT_SEPARATOR;
+1 -4
View File
@@ -142,8 +142,6 @@ const computeStateToPartsFromEntityAttributes = (
group: "value",
decimal: "value",
fraction: "value",
minusSign: "value",
plusSign: "value",
literal: "literal",
currency: "unit",
};
@@ -154,7 +152,7 @@ const computeStateToPartsFromEntityAttributes = (
const type = TYPE_MAP[part.type];
if (!type) continue;
const last = valueParts[valueParts.length - 1];
// Merge consecutive value parts (e.g. "-" + "12" + "." + "00" → "-12.00")
// Merge consecutive numeric parts (e.g. "1" + "," + "234" + "." + "56" → "1,234.56")
if (type === "value" && last?.type === "value") {
last.value += part.value;
} else {
@@ -255,7 +253,6 @@ const computeStateToPartsFromEntityAttributes = (
"conversation",
"event",
"image",
"infrared",
"input_button",
"notify",
"scene",
@@ -1,11 +1,26 @@
import type { AreaRegistryEntry } from "../../../data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import type { FloorRegistryEntry } from "../../../data/floor_registry";
import type { HomeAssistant } from "../../../types";
export const getDeviceArea = (
interface DeviceContext {
device: DeviceRegistryEntry;
area: AreaRegistryEntry | null;
floor: FloorRegistryEntry | null;
}
export const getDeviceContext = (
device: DeviceRegistryEntry,
areas: HomeAssistant["areas"]
): AreaRegistryEntry | undefined => {
hass: HomeAssistant
): DeviceContext => {
const areaId = device.area_id;
return areaId ? areas[areaId] : undefined;
const area = areaId ? hass.areas[areaId] : undefined;
const floorId = area?.floor_id;
const floor = floorId ? hass.floors[floorId] : undefined;
return {
device: device,
area: area || null,
floor: floor || null,
};
};
+2 -2
View File
@@ -27,7 +27,7 @@ export const isDeletableEntity = (
const entityRegEntry = entityRegistry.find((e) => e.entity_id === entity_id);
if (isHelperDomain(domain)) {
return !!(
isComponentLoaded(hass.config, domain) &&
isComponentLoaded(hass, domain) &&
entityRegEntry &&
fetchedHelpers.some((e) => e.id === entityRegEntry.unique_id)
);
@@ -56,7 +56,7 @@ export const deleteEntity = (
const domain = computeDomain(entity_id);
const entityRegEntry = entityRegistry.find((e) => e.entity_id === entity_id);
if (isHelperDomain(domain)) {
if (isComponentLoaded(hass.config, domain)) {
if (isComponentLoaded(hass, domain)) {
if (
entityRegEntry &&
fetchedHelpers.some((e) => e.id === entityRegEntry.unique_id)
-3
View File
@@ -29,7 +29,6 @@ export const FIXED_DOMAIN_STATES = {
device_tracker: ["home", "not_home"],
fan: ["on", "off"],
humidifier: ["on", "off"],
infrared: [],
input_boolean: ["on", "off"],
input_button: [],
lawn_mower: ["error", "paused", "mowing", "returning", "docked"],
@@ -271,8 +270,6 @@ export const getStates = (
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":
+1 -3
View File
@@ -6,9 +6,7 @@ export function stateActive(stateObj: HassEntity, state?: string): boolean {
const domain = computeDomain(stateObj.entity_id);
const compareState = state !== undefined ? state : stateObj?.state;
if (
["button", "event", "infrared", "input_button", "scene"].includes(domain)
) {
if (["button", "event", "input_button", "scene"].includes(domain)) {
return compareState !== UNAVAILABLE;
}
@@ -1,8 +0,0 @@
/**
* Indicates whether the current browser has native ElementInternals support.
*/
export const nativeElementInternalsSupported =
Boolean(globalThis.ElementInternals) &&
globalThis.HTMLElement?.prototype.attachInternals
?.toString()
.includes("[native code]");
@@ -1,11 +0,0 @@
/**
* Indicates whether the current browser supports the Popover API.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Popover_API
*/
export const popoverSupported = globalThis?.HTMLElement?.prototype
? Object.prototype.hasOwnProperty.call(
globalThis.HTMLElement.prototype,
"popover"
)
: false;
@@ -41,7 +41,7 @@ export const protocolIntegrationPicked = async (
).filter((e) => !e.disabled_by);
if (
!isComponentLoaded(hass.config, "zwave_js") ||
!isComponentLoaded(hass, "zwave_js") ||
(!options?.config_entry && !entries?.length)
) {
// If the component isn't loaded, ask them to load the integration first
@@ -90,7 +90,7 @@ export const protocolIntegrationPicked = async (
).filter((e) => !e.disabled_by);
if (
!isComponentLoaded(hass.config, "zha") ||
!isComponentLoaded(hass, "zha") ||
(!options?.config_entry && !entries?.length)
) {
// If the component isn't loaded, ask them to load the integration first
@@ -139,7 +139,7 @@ export const protocolIntegrationPicked = async (
})
).filter((e) => !e.disabled_by);
if (
!isComponentLoaded(hass.config, domain) ||
!isComponentLoaded(hass, domain) ||
(!options?.config_entry && !entries?.length)
) {
// If the component isn't loaded, ask them to load the integration first
+7
View File
@@ -71,6 +71,13 @@ export const formatNumberToParts = (
? numberFormatToLocale(localeOptions)
: undefined;
// Polyfill for Number.isNaN, which is more reliable than the global isNaN()
Number.isNaN =
Number.isNaN ||
function isNaN(input) {
return typeof input === "number" && isNaN(input);
};
if (
localeOptions?.number_format !== NumberFormat.none &&
!Number.isNaN(Number(num))
+20 -1
View File
@@ -1,5 +1,24 @@
import { deepActiveElement } from "../dom/deep-active-element";
const getClipboardFallbackRoot = (): HTMLElement => {
const activeElement = deepActiveElement();
if (activeElement instanceof HTMLElement) {
let root: Node = activeElement.getRootNode();
let host: HTMLElement | null = null;
while (root instanceof ShadowRoot && root.host instanceof HTMLElement) {
host = root.host;
root = root.host.getRootNode();
}
if (host) {
return host;
}
}
return document.body;
};
export const copyToClipboard = async (str, rootEl?: HTMLElement) => {
if (navigator.clipboard) {
try {
@@ -10,7 +29,7 @@ export const copyToClipboard = async (str, rootEl?: HTMLElement) => {
}
}
const root = rootEl || deepActiveElement()?.getRootNode() || document.body;
const root = rootEl || getClipboardFallbackRoot();
const el = document.createElement("textarea");
el.value = str;
-29
View File
@@ -5,41 +5,12 @@ import {
formatDateMonthYear,
formatDateVeryShort,
formatDateWeekdayShort,
formatDateYear,
} from "../../common/datetime/format_date";
import {
formatTime,
formatTimeWithSeconds,
} from "../../common/datetime/format_time";
export function getPeriodicAxisLabelConfig(
period: string,
locale: FrontendLocaleData,
config: HassConfig
):
| {
formatter: (value: number) => string;
}
| undefined {
if (period === "month") {
return {
formatter: (value: number) => {
const date = new Date(value);
return date.getMonth() === 0
? `{bold|${formatDateMonthYear(date, locale, config)}}`
: formatDateMonth(date, locale, config);
},
};
}
if (period === "year") {
return {
formatter: (value: number) =>
formatDateYear(new Date(value), locale, config),
};
}
return undefined;
}
export function formatTimeLabel(
value: number | Date,
locale: FrontendLocaleData,
+25 -193
View File
@@ -44,7 +44,6 @@ export type CustomLegendOption = ECOption["legend"] & {
id?: string;
secondaryIds?: string[]; // Other dataset IDs that should be controlled by this legend item.
name: string;
value?: string; // Current value to display next to the name in the legend.
itemStyle?: Record<string, any>;
}[];
};
@@ -91,10 +90,6 @@ export class HaChartBase extends LitElement {
private _lastTapTime?: number;
private _longPressTimer?: ReturnType<typeof setTimeout>;
private _longPressTriggered = false;
private _shouldResizeChart = false;
private _resizeAnimationDuration?: number;
@@ -132,7 +127,6 @@ export class HaChartBase extends LitElement {
public disconnectedCallback() {
super.disconnectedCallback();
this._legendPointerCancel();
this._pendingSetup = false;
while (this._listeners.length) {
this._listeners.pop()!();
@@ -285,53 +279,39 @@ export class HaChartBase extends LitElement {
<div class="chart"></div>
</div>
${this._renderLegend()}
<div class="top-controls ${classMap({ small: this.smallControls })}">
<slot name="search"></slot>
<div
class="chart-controls ${classMap({ small: this.smallControls })}"
>
${this._isZoomed && !this.hideResetButton
? html`<ha-icon-button
class="zoom-reset"
.path=${mdiRestart}
@click=${this._handleZoomReset}
title=${this.hass.localize(
"ui.components.history_charts.zoom_reset"
)}
></ha-icon-button>`
: nothing}
<slot name="button"></slot>
</div>
<div class="chart-controls ${classMap({ small: this.smallControls })}">
${this._isZoomed && !this.hideResetButton
? html`<ha-icon-button
class="zoom-reset"
.path=${mdiRestart}
@click=${this._handleZoomReset}
title=${this.hass.localize(
"ui.components.history_charts.zoom_reset"
)}
></ha-icon-button>`
: nothing}
<slot name="button"></slot>
</div>
</div>
`;
}
private _getLegendItems() {
private _renderLegend() {
if (!this.options?.legend || !this.data) {
return undefined;
return nothing;
}
const legend = ensureArray(this.options.legend).find(
(l) => l.show && l.type === "custom"
) as CustomLegendOption | undefined;
if (!legend) {
return undefined;
return nothing;
}
const datasets = ensureArray(this.data);
return (
const items =
legend.data ||
datasets
.filter((d) => (d.data as any[])?.length && (d.id || d.name))
.map((d) => ({ id: d.id, name: d.name }))
);
}
private _renderLegend() {
const items = this._getLegendItems();
if (!items) {
return nothing;
}
const datasets = ensureArray(this.data!);
.map((d) => ({ id: d.id, name: d.name }));
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
@@ -353,14 +333,12 @@ export class HaChartBase extends LitElement {
let itemStyle: Record<string, any> = {};
let name = "";
let id = "";
let value = "";
if (typeof item === "string") {
name = item;
id = item;
} else {
name = item.name ?? "";
id = item.id ?? name;
value = item.value ?? "";
itemStyle = item.itemStyle ?? {};
}
const dataset =
@@ -376,11 +354,6 @@ export class HaChartBase extends LitElement {
return html`<li
.id=${id}
@click=${this._legendClick}
@pointerdown=${this._legendPointerDown}
@pointerup=${this._legendPointerCancel}
@pointerleave=${this._legendPointerCancel}
@pointercancel=${this._legendPointerCancel}
@contextmenu=${this._legendContextMenu}
class=${classMap({ hidden: this._hiddenDatasets.has(id) })}
.title=${name}
>
@@ -392,7 +365,6 @@ export class HaChartBase extends LitElement {
})}
></div>
<div class="label">${name}</div>
${value ? html`<div class="value">${value}</div>` : nothing}
</li>`;
})}
${items.length > overflowLimit
@@ -606,7 +578,7 @@ export class HaChartBase extends LitElement {
id: "dataZoom",
type: "inside",
orient: "horizontal",
filterMode: this._getDataZoomFilterMode() as any,
filterMode: "none",
xAxisIndex: 0,
moveOnMouseMove: !this._isTouchDevice || this._isZoomed,
preventDefaultMouseMove: !this._isTouchDevice || this._isZoomed,
@@ -614,23 +586,6 @@ export class HaChartBase extends LitElement {
};
}
// "boundaryFilter" is a custom mode added via axis-proxy-patch.ts.
// It rescales the Y-axis to the visible data while keeping one point
// just outside each boundary to avoid line gaps at the zoom edges.
// Use "filter" for bar charts since boundaryFilter causes rendering issues.
// Use "weakFilter" for other types (e.g. custom/timeline) so bars
// spanning the visible range boundary are kept.
private _getDataZoomFilterMode(): string {
const series = ensureArray(this.data);
if (series.every((s) => s.type === "line")) {
return "boundaryFilter";
}
if (series.some((s) => s.type === "bar")) {
return "filter";
}
return "weakFilter";
}
private _createOptions(): ECOption {
let xAxis = this.options?.xAxis;
if (xAxis) {
@@ -665,7 +620,7 @@ export class HaChartBase extends LitElement {
hideOverlap: true,
...axis.axisLabel,
},
minInterval: axis.minInterval ?? minInterval,
minInterval,
} as XAXisOption;
});
}
@@ -1055,52 +1010,11 @@ export class HaChartBase extends LitElement {
fireEvent(this, "chart-zoom", { start, end });
}
// Long-press to solo on touch/pen devices (500ms, consistent with action-handler-directive)
private _legendPointerDown(ev: PointerEvent) {
// Mouse uses Ctrl/Cmd+click instead
if (ev.pointerType === "mouse") {
return;
}
const id = (ev.currentTarget as HTMLElement)?.id;
if (!id) {
return;
}
this._longPressTriggered = false;
this._longPressTimer = setTimeout(() => {
this._longPressTriggered = true;
this._longPressTimer = undefined;
this._soloLegend(id);
}, 500);
}
private _legendPointerCancel() {
if (this._longPressTimer) {
clearTimeout(this._longPressTimer);
this._longPressTimer = undefined;
}
}
private _legendContextMenu(ev: Event) {
if (this._longPressTimer || this._longPressTriggered) {
ev.preventDefault();
}
}
private _legendClick(ev: MouseEvent) {
private _legendClick(ev: any) {
if (!this.chart) {
return;
}
if (this._longPressTriggered) {
this._longPressTriggered = false;
return;
}
const id = (ev.currentTarget as HTMLElement)?.id;
// Cmd+click on Mac (Ctrl+click is right-click there), Ctrl+click elsewhere
const soloModifier = isMac ? ev.metaKey : ev.ctrlKey;
if (soloModifier) {
this._soloLegend(id);
return;
}
const id = ev.currentTarget?.id;
if (this._hiddenDatasets.has(id)) {
this._getAllIdsFromLegend(this.options, id).forEach((i) =>
this._hiddenDatasets.delete(i)
@@ -1115,60 +1029,6 @@ export class HaChartBase extends LitElement {
this.requestUpdate("_hiddenDatasets");
}
private _soloLegend(id: string) {
const allIds = this._getAllLegendIds();
const clickedIds = this._getAllIdsFromLegend(this.options, id);
const otherIds = allIds.filter((i) => !clickedIds.includes(i));
const clickedIsOnlyVisible =
clickedIds.every((i) => !this._hiddenDatasets.has(i)) &&
otherIds.every((i) => this._hiddenDatasets.has(i));
if (clickedIsOnlyVisible) {
// Already solo'd on this item — restore all series to visible
for (const hiddenId of [...this._hiddenDatasets]) {
this._hiddenDatasets.delete(hiddenId);
fireEvent(this, "dataset-unhidden", { id: hiddenId });
}
} else {
// Solo: hide every other series, unhide clicked if it was hidden
for (const otherId of otherIds) {
if (!this._hiddenDatasets.has(otherId)) {
this._hiddenDatasets.add(otherId);
fireEvent(this, "dataset-hidden", { id: otherId });
}
}
for (const clickedId of clickedIds) {
if (this._hiddenDatasets.has(clickedId)) {
this._hiddenDatasets.delete(clickedId);
fireEvent(this, "dataset-unhidden", { id: clickedId });
}
}
}
this.requestUpdate("_hiddenDatasets");
}
private _getAllLegendIds(): string[] {
const items = this._getLegendItems();
if (!items) {
return [];
}
const allIds = new Set<string>();
for (const item of items) {
const primaryId =
typeof item === "string"
? item
: ((item.id as string) ?? (item.name as string) ?? "");
for (const expandedId of this._getAllIdsFromLegend(
this.options,
primaryId
)) {
allIds.add(expandedId);
}
}
return [...allIds];
}
private _toggleExpandedLegend() {
this.expandLegend = !this.expandLegend;
setTimeout(() => {
@@ -1249,35 +1109,16 @@ export class HaChartBase extends LitElement {
height: 100%;
width: 100%;
}
.top-controls {
position: absolute;
top: var(--ha-space-4);
inset-inline-start: var(--ha-space-4);
inset-inline-end: var(--ha-space-1);
display: flex;
align-items: flex-start;
gap: var(--ha-space-2);
z-index: 1;
pointer-events: none;
}
::slotted([slot="search"]) {
flex: 1 1 250px;
min-width: 0;
max-width: 250px;
pointer-events: auto;
}
.chart-controls {
position: absolute;
top: 16px;
right: 4px;
display: flex;
flex-direction: column;
gap: var(--ha-space-1);
margin-inline-start: auto;
flex-shrink: 0;
pointer-events: auto;
}
.top-controls.small {
top: 0;
}
.chart-controls.small {
top: 0;
flex-direction: row;
}
.chart-controls ha-icon-button,
@@ -1325,9 +1166,6 @@ export class HaChartBase extends LitElement {
.chart-legend.multiple-items li {
max-width: 220px;
}
.chart-legend.multiple-items li:has(.value) {
max-width: 300px;
}
.chart-legend .hidden {
color: var(--secondary-text-color);
}
@@ -1336,12 +1174,6 @@ export class HaChartBase extends LitElement {
white-space: nowrap;
overflow: hidden;
}
.chart-legend .value {
color: var(--secondary-text-color);
margin-inline-start: var(--ha-space-1);
flex-shrink: 0;
white-space: nowrap;
}
.chart-legend .bullet {
border-width: 1px;
border-style: solid;
+6 -140
View File
@@ -1,9 +1,7 @@
import type { EChartsType } from "echarts/core";
import type { GraphSeriesOption } from "echarts/charts";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state, query } from "lit/decorators";
import type {
CallbackDataParams,
TopLevelFormatterParams,
@@ -65,8 +63,6 @@ export interface NetworkData {
categories?: { name: string; symbol: string }[];
}
const PHYSICS_DISABLE_THRESHOLD = 512;
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports
let GraphChart: typeof import("echarts/lib/chart/graph/install");
@@ -80,23 +76,11 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
params: TopLevelFormatterParams
) => string;
/**
* Optional callback that returns additional searchable strings for a node.
* These are matched against the search filter in addition to the node's name and context.
*/
@property({ attribute: false }) public searchableAttributes?: (
nodeId: string
) => string[];
@property({ attribute: false }) public searchFilter = "";
public hass!: HomeAssistant;
@state() private _highlightedNodes?: Set<string>;
@state() private _reducedMotion = false;
@state() private _physicsEnabled?: boolean;
@state() private _physicsEnabled = true;
@state() private _showLabels = true;
@@ -124,14 +108,6 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
];
}
protected willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties);
if (this._physicsEnabled === undefined && this.data?.nodes?.length > 1) {
this._physicsEnabled =
this.data.nodes.length <= PHYSICS_DISABLE_THRESHOLD;
}
}
protected render() {
if (!GraphChart || !this.data.nodes?.length) {
return nothing;
@@ -141,24 +117,19 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
const hasHighlightedNodes =
this._highlightedNodes && this._highlightedNodes.size > 0;
return html`<ha-chart-base
.hass=${this.hass}
.data=${this._getSeries(
this.data,
this._physicsEnabled ?? false,
this._physicsEnabled,
this._reducedMotion,
this._showLabels,
isMobile,
hasHighlightedNodes
isMobile
)}
.options=${this._createOptions(this.data?.categories)}
height="100%"
.extraComponents=${[GraphChart]}
>
<slot name="search" slot="search"></slot>
<slot name="button" slot="button"></slot>
<ha-icon-button
slot="button"
@@ -194,7 +165,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
...category,
icon: category.symbol,
})),
bottom: 8,
top: 8,
},
dataZoom: {
type: "inside",
@@ -204,56 +175,13 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
deepEqual
);
protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (changedProperties.has("searchFilter")) {
const filter = this.searchFilter;
if (!filter) {
this._highlightedNodes = undefined;
} else {
const lowerFilter = filter.toLowerCase();
const matchingIds = new Set<string>();
for (const node of this.data.nodes) {
if (this._nodeMatchesFilter(node, lowerFilter)) {
matchingIds.add(node.id);
}
}
this._highlightedNodes = matchingIds;
}
this._applyHighlighting();
this._updateMouseoverHandler();
}
}
private _nodeMatchesFilter(node: NetworkNode, lowerFilter: string): boolean {
if (node.name?.toLowerCase().includes(lowerFilter)) {
return true;
}
if (node.context?.toLowerCase().includes(lowerFilter)) {
return true;
}
if (node.id?.toLowerCase().includes(lowerFilter)) {
return true;
}
if (this.searchableAttributes) {
const extraValues = this.searchableAttributes(node.id);
for (const value of extraValues) {
if (value?.toLowerCase().includes(lowerFilter)) {
return true;
}
}
}
return false;
}
private _getSeries = memoizeOne(
(
data: NetworkData,
physicsEnabled: boolean,
reducedMotion: boolean,
showLabels: boolean,
isMobile: boolean,
hasHighlightedNodes?: boolean
isMobile: boolean
) => ({
id: "network",
type: "graph",
@@ -286,7 +214,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
},
},
emphasis: {
focus: hasHighlightedNodes ? "self" : isMobile ? "none" : "adjacency",
focus: isMobile ? "none" : "adjacency",
},
force: {
repulsion: [400, 600],
@@ -434,68 +362,6 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
});
}
private _applyHighlighting() {
const chart = this._baseChart?.chart;
if (!chart) {
return;
}
// Reset all nodes to normal opacity first
chart.dispatchAction({ type: "downplay" });
const highlighted = this._highlightedNodes;
if (!highlighted || highlighted.size === 0) {
return;
}
const dataIndices: number[] = [];
this.data.nodes.forEach((node, index) => {
if (highlighted.has(node.id)) {
dataIndices.push(index);
}
});
if (dataIndices.length > 0) {
chart.dispatchAction({ type: "highlight", dataIndex: dataIndices });
}
}
private _emphasisGuardHandler?: () => void;
private _updateMouseoverHandler() {
const chart = this._baseChart?.chart;
if (!chart) {
return;
}
// When there are highlighted nodes, re-apply highlighting on hover
// and mouseout to prevent hover from overriding the search state
if (this._highlightedNodes && this._highlightedNodes.size > 0) {
if (this._emphasisGuardHandler) {
// Guard already set
return;
}
this._emphasisGuardHandler = () => {
this._applyHighlighting();
};
chart.on("mouseover", this._emphasisGuardHandler);
chart.on("mouseout", this._emphasisGuardHandler);
} else {
if (!this._emphasisGuardHandler) {
return;
}
chart.off("mouseover", this._emphasisGuardHandler);
chart.off("mouseout", this._emphasisGuardHandler);
this._emphasisGuardHandler = undefined;
}
}
public disconnectedCallback(): void {
super.disconnectedCallback();
if (this._emphasisGuardHandler) {
this._baseChart?.chart?.off("mouseover", this._emphasisGuardHandler);
this._baseChart?.chart?.off("mouseout", this._emphasisGuardHandler);
this._emphasisGuardHandler = undefined;
}
}
private _togglePhysics() {
this._saveNodePositions();
this._physicsEnabled = !this._physicsEnabled;
@@ -28,13 +28,6 @@ const safeParseFloat = (value) => {
return isFinite(parsed) ? parsed : null;
};
const CLIMATE_MODE_CONFIGS = [
{ mode: "heat", action: "heating", cssVar: "--state-climate-heat-color" },
{ mode: "cool", action: "cooling", cssVar: "--state-climate-cool-color" },
{ mode: "dry", action: "drying", cssVar: "--state-climate-dry-color" },
{ mode: "fan_only", action: "fan", cssVar: "--state-climate-fan_only-color" },
] as const;
@customElement("state-history-chart-line")
export class StateHistoryChartLine extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -246,9 +239,7 @@ export class StateHistoryChartLine extends LitElement {
changedProps.has("fitYData") ||
changedProps.has("paddingYAxis") ||
changedProps.has("_visualMap") ||
changedProps.has("_yWidth") ||
(changedProps.has("hass") &&
this._hasEntityStatesChanged(changedProps.get("hass")))
changedProps.has("_yWidth")
) {
const rtl = computeRTL(this.hass);
let minYAxis: number | ((values: { min: number }) => number) | undefined =
@@ -305,19 +296,6 @@ export class StateHistoryChartLine extends LitElement {
legend: {
type: "custom",
show: this.showNames,
data: this._chartData
.map((d, i) => ({ dataset: d, entityId: this._entityIds[i] }))
.filter((item) => !(item.dataset as LineSeriesOption).areaStyle)
.map((item) => {
const stateObj = this.hass.states[item.entityId];
return {
id: item.dataset.id as string,
name: item.dataset.name as string,
value: stateObj
? this.hass.formatEntityState(stateObj)
: undefined,
};
}),
},
grid: {
top: 15,
@@ -338,13 +316,6 @@ export class StateHistoryChartLine extends LitElement {
}
}
private _hasEntityStatesChanged(oldHass: HomeAssistant): boolean {
return this._entityIds.some(
(entityId) =>
this.hass.states[entityId]?.state !== oldHass.states[entityId]?.state
);
}
private _generateData() {
let colorIndex = 0;
const computedStyles = getComputedStyle(this);
@@ -436,18 +407,23 @@ export class StateHistoryChartLine extends LitElement {
(entityState) => entityState.attributes?.hvac_action
);
const activeModes = CLIMATE_MODE_CONFIGS.map(
({ mode, action, cssVar }) => {
const isActive =
domain === "climate" && hasHvacAction
? (entityState: LineChartState) =>
CLIMATE_HVAC_ACTION_TO_MODE[
entityState.attributes?.hvac_action
] === mode
: (entityState: LineChartState) => entityState.state === mode;
return { action, cssVar, isActive };
}
).filter(({ isActive }) => states.states.some(isActive));
const isHeating =
domain === "climate" && hasHvacAction
? (entityState: LineChartState) =>
CLIMATE_HVAC_ACTION_TO_MODE[
entityState.attributes?.hvac_action
] === "heat"
: (entityState: LineChartState) => entityState.state === "heat";
const isCooling =
domain === "climate" && hasHvacAction
? (entityState: LineChartState) =>
CLIMATE_HVAC_ACTION_TO_MODE[
entityState.attributes?.hvac_action
] === "cool"
: (entityState: LineChartState) => entityState.state === "cool";
const hasHeat = states.states.some(isHeating);
const hasCool = states.states.some(isCooling);
// We differentiate between thermostats that have a target temperature
// range versus ones that have just a target temperature
@@ -468,19 +444,33 @@ export class StateHistoryChartLine extends LitElement {
"component.climate.entity_component._.state_attributes.current_temperature.name"
)
);
for (const { action, cssVar } of activeModes) {
if (hasHeat) {
addDataSet(
`${states.entity_id}-${action}`,
states.entity_id + "-heating",
this.showNames
? this.hass.localize(`ui.card.climate.${action}`, {
name: name,
})
? this.hass.localize("ui.card.climate.heating", { name: name })
: this.hass.localize(
`component.climate.entity_component._.state_attributes.hvac_action.state.${action}`
"component.climate.entity_component._.state_attributes.hvac_action.state.heating"
),
computedStyles.getPropertyValue(cssVar),
computedStyles.getPropertyValue("--state-climate-heat-color"),
true
);
// The "heating" series uses steppedArea to shade the area below the current
// temperature when the thermostat is calling for heat.
}
if (hasCool) {
addDataSet(
states.entity_id + "-cooling",
this.showNames
? this.hass.localize("ui.card.climate.cooling", { name: name })
: this.hass.localize(
"component.climate.entity_component._.state_attributes.hvac_action.state.cooling"
),
computedStyles.getPropertyValue("--state-climate-cool-color"),
true
);
// The "cooling" series uses steppedArea to shade the area below the current
// temperature when the thermostat is calling for heat.
}
if (hasTargetRange) {
@@ -528,8 +518,11 @@ export class StateHistoryChartLine extends LitElement {
entityState.attributes.current_temperature
);
const series = [curTemp];
for (const { isActive } of activeModes) {
series.push(isActive(entityState) ? curTemp : null);
if (hasHeat) {
series.push(isHeating(entityState) ? curTemp : null);
}
if (hasCool) {
series.push(isCooling(entityState) ? curTemp : null);
}
if (hasTargetRange) {
const targetHigh = safeParseFloat(
+1 -1
View File
@@ -105,7 +105,7 @@ export class StateHistoryCharts extends LitElement {
@restoreScroll(".container") private _savedScrollPos?: number;
protected render() {
if (!isComponentLoaded(this.hass.config, "history")) {
if (!isComponentLoaded(this.hass, "history")) {
return html`<div class="info">
${this.hass.localize("ui.components.history_charts.history_disabled")}
</div>`;
+28 -55
View File
@@ -27,12 +27,10 @@ import {
getDisplayUnit,
getStatisticLabel,
getStatisticMetadata,
isExternalStatistic,
statisticsHaveType,
} from "../../data/recorder";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant } from "../../types";
import { getPeriodicAxisLabelConfig } from "./axis-label";
import type { CustomLegendOption } from "./ha-chart-base";
import "./ha-chart-base";
@@ -149,7 +147,7 @@ export class StatisticsChart extends LitElement {
}
protected render(): TemplateResult {
if (!isComponentLoaded(this.hass.config, "history")) {
if (!isComponentLoaded(this.hass, "history")) {
return html`<div class="info">
${this.hass.localize("ui.components.history_charts.history_disabled")}
</div>`;
@@ -294,22 +292,6 @@ export class StatisticsChart extends LitElement {
type: "time",
min: startTime,
max: this.endTime,
...(this.period === "month" && {
minInterval: 28 * 24 * 3600 * 1000,
axisLabel: getPeriodicAxisLabelConfig(
"month",
this.hass.locale,
this.hass.config
),
}),
...(this.period === "year" && {
minInterval: 365 * 24 * 3600 * 1000,
axisLabel: getPeriodicAxisLabelConfig(
"year",
this.hass.locale,
this.hass.config
),
}),
},
{
id: "hiddenAxis",
@@ -416,31 +398,7 @@ export class StatisticsChart extends LitElement {
endTime = new Date();
}
// Check if we need to display most recent data. Allow 10m of leeway for "now",
// because stats are 5 minute aggregated.
// Use same now point for all statistics even if processing time means the
// state value is actually from a slightly later time. Otherwise the points
// end up separated slightly and disappear from the tooltips.
const now = new Date();
const displayCurrentState = now.getTime() - endTime.getTime() <= 600000;
// Try to determine chart unit if it has not already been set explicitly
if (!this.unit) {
let unit: string | undefined | null;
statisticsData.forEach(([statistic_id, _stats]) => {
const meta = statisticsMetaData?.[statistic_id];
const statisticUnit = getDisplayUnit(this.hass, statistic_id, meta);
if (unit === undefined) {
unit = statisticUnit;
} else if (unit !== null && unit !== statisticUnit) {
// Clear unit if not all statistics have same unit
unit = null;
}
});
if (unit) {
this.unit = unit;
}
}
let unit: string | undefined | null;
const names = this.names || {};
statisticsData.forEach(([statistic_id, stats]) => {
@@ -450,6 +408,18 @@ export class StatisticsChart extends LitElement {
name = getStatisticLabel(this.hass, statistic_id, meta);
}
if (!this.unit) {
if (unit === undefined) {
unit = getDisplayUnit(this.hass, statistic_id, meta);
} else if (
unit !== null &&
unit !== getDisplayUnit(this.hass, statistic_id, meta)
) {
// Clear unit if not all statistics have same unit
unit = null;
}
}
// array containing [value1, value2, etc]
let prevValues: (number | null)[][] | null = null;
let prevEndTime: Date | undefined;
@@ -573,7 +543,7 @@ export class StatisticsChart extends LitElement {
(series as LineSeriesOption).areaStyle = undefined;
} else {
series.stackOrder = "seriesAsc";
if (type === bandTop) {
if (drawBands && type === bandTop) {
(series as LineSeriesOption).areaStyle = {
color: color + "3F",
};
@@ -642,10 +612,10 @@ export class StatisticsChart extends LitElement {
}
});
// For line charts, close out the last stat segment at prevEndTime
// Close out the last stat segment at prevEndTime
const lastEndTime = prevEndTime;
const lastValues = prevValues;
if (this.chartType === "line" && lastEndTime && lastValues) {
if (lastEndTime && lastValues) {
statDataSets.forEach((d, i) => {
d.data!.push(
this._transformDataValue([lastEndTime, ...lastValues[i]!])
@@ -653,14 +623,13 @@ export class StatisticsChart extends LitElement {
});
}
// Show current state if required, and units match (or are unknown)
const statisticUnit = getDisplayUnit(this.hass, statistic_id, meta);
if (
displayCurrentState &&
(!this.unit || !statisticUnit || this.unit === statisticUnit)
) {
// Skip external statistics
if (!isExternalStatistic(statistic_id)) {
// Append current state if viewing recent data
const now = new Date();
// allow 10m of leeway for "now", because stats are 5 minute aggregated
const isUpToNow = now.getTime() - endTime.getTime() <= 600000;
if (isUpToNow) {
// Skip external statistics (they have ":" in the ID)
if (!statistic_id.includes(":")) {
const stateObj = this.hass.states[statistic_id];
if (stateObj) {
const currentValue = parseFloat(stateObj.state);
@@ -701,6 +670,10 @@ export class StatisticsChart extends LitElement {
Array.prototype.push.apply(legendData, statLegendData);
});
if (unit) {
this.unit = unit;
}
legendData.forEach(({ id, name, color, borderColor }) => {
// Add an empty series for the legend
totalDataSets.push({
@@ -2,6 +2,7 @@ import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { computeCssColor } from "../../common/color/compute-color";
import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { stringCompare } from "../../common/string/compare";
@@ -52,15 +53,16 @@ class HaDataTableLabels extends LitElement {
}
private _renderLabel(label: LabelRegistryEntry, clickAction: boolean) {
const color = label?.color ? computeCssColor(label.color) : undefined;
return html`
<ha-label
dense
role="button"
tabindex="0"
.color=${label.color}
.item=${label}
@click=${clickAction ? this._labelClicked : undefined}
@keydown=${clickAction ? this._labelClicked : undefined}
style=${color ? `--color: ${color}` : ""}
.description=${label.description}
>
${label?.icon
@@ -100,6 +102,10 @@ class HaDataTableLabels extends LitElement {
position: fixed;
flex-wrap: nowrap;
}
ha-label {
--ha-label-background-color: var(--color, var(--grey-color));
--ha-label-background-opacity: 0.5;
}
.plus {
--ha-label-background-color: transparent;
border: 1px solid var(--divider-color);
+31 -34
View File
@@ -1,4 +1,3 @@
import { consume, type ContextType } from "@lit/context";
import { mdiArrowDown, mdiArrowUp, mdiChevronUp } from "@mdi/js";
import deepClone from "deep-clone-simple";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
@@ -22,14 +21,13 @@ 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 { localeContext, localizeContext } from "../../data/context";
import type { FrontendLocaleData } from "../../data/translation";
import { haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types";
import "../ha-checkbox";
import type { HaCheckbox } from "../ha-checkbox";
import "../ha-svg-icon";
import "../input/ha-input-search";
import "../search-input";
import { filterData, sortData } from "./sort-filter";
export interface RowClickedEvent {
@@ -106,13 +104,9 @@ const UNDEFINED_GROUP_KEY = "zzzzz_undefined";
@customElement("ha-data-table")
export class HaDataTable extends LitElement {
@state()
@consume({ context: localizeContext, subscribe: true })
private _localize?: ContextType<typeof localizeContext>;
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consume({ context: localeContext, subscribe: true })
private _locale?: ContextType<typeof localeContext>;
@property({ attribute: false }) public localizeFunc?: LocalizeFunc;
@property({ type: Boolean }) public narrow = false;
@@ -384,6 +378,8 @@ export class HaDataTable extends LitElement {
);
protected render() {
const localize = this.localizeFunc || this.hass.localize;
const columns = this._sortedColumns(this.columns, this.columnOrder);
const renderRow = (row: DataTableRowData, index: number) =>
@@ -395,11 +391,11 @@ export class HaDataTable extends LitElement {
${this._filterable
? html`
<div class="table-header">
<ha-input-search
appearance="outlined"
@input=${this._handleSearchChange}
.placeholder=${this.searchLabel}
></ha-input-search>
<search-input
.hass=${this.hass}
@value-changed=${this._handleSearchChange}
.label=${this.searchLabel}
></search-input>
</div>
`
: ""}
@@ -507,8 +503,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._localize?.("ui.components.data-table.no-data") ||
"No data"}
localize("ui.components.data-table.no-data")}
</div>
</div>
</div>
@@ -520,8 +515,7 @@ export class HaDataTable extends LitElement {
@scroll=${this._saveScrollPos}
.items=${this._groupData(
this._filteredData,
this._localize,
this._locale,
localize,
this.appendRow,
this.groupColumn,
this.groupOrder,
@@ -691,7 +685,7 @@ export class HaDataTable extends LitElement {
this._sortColumns[this.sortColumn],
this.sortDirection,
this.sortColumn,
this._locale?.language
this.hass.locale.language
)
: filteredData;
@@ -717,8 +711,7 @@ export class HaDataTable extends LitElement {
private _groupData = memoizeOne(
(
data: DataTableRowData[],
localize: LocalizeFunc | undefined,
locale: FrontendLocaleData | undefined,
localize: LocalizeFunc,
appendRow,
groupColumn: string | undefined,
groupOrder: string[] | undefined,
@@ -742,7 +735,11 @@ export class HaDataTable extends LitElement {
)
.sort((a, b) => {
if (!groupOrder && isGroupSortColumn) {
const comparison = stringCompare(a, b, locale?.language);
const comparison = stringCompare(
a,
b,
this.hass.locale.language
);
if (sortDirection === "asc") {
return comparison;
}
@@ -763,7 +760,7 @@ export class HaDataTable extends LitElement {
return stringCompare(
["", "-", "—"].includes(a) ? "zzz" : a,
["", "-", "—"].includes(b) ? "zzz" : b,
locale?.language
this.hass.locale.language
);
})
.reduce(
@@ -790,15 +787,14 @@ export class HaDataTable extends LitElement {
>
<ha-icon-button
.path=${mdiChevronUp}
.label=${localize?.(
.label=${this.hass.localize(
`ui.components.data-table.${collapsed ? "expand" : "collapse"}`
) || (collapsed ? "Expand" : "Collapse")}
)}
class=${collapsed ? "collapsed" : ""}
>
</ha-icon-button>
${groupName === UNDEFINED_GROUP_KEY
? localize?.("ui.components.data-table.ungrouped") ||
"Ungrouped"
? localize("ui.components.data-table.ungrouped")
: groupName || ""}
</div>`,
});
@@ -867,8 +863,7 @@ export class HaDataTable extends LitElement {
const groupedData = this._groupData(
this._filteredData,
this._localize,
this._locale,
this.localizeFunc || this.hass.localize,
this.appendRow,
this.groupColumn,
this.groupOrder,
@@ -975,12 +970,12 @@ export class HaDataTable extends LitElement {
});
}
private _handleSearchChange(ev: InputEvent): void {
private _handleSearchChange(ev: CustomEvent): void {
if (this.filter) {
return;
}
this._lastSelectedRowId = null;
this._debounceSearch((ev.target as HTMLInputElement).value);
this._debounceSearch(ev.detail.value);
}
private async _calcTableHeight() {
@@ -1393,9 +1388,11 @@ export class HaDataTable extends LitElement {
.table-header {
border-bottom: 1px solid var(--divider-color);
}
ha-input-search {
search-input {
display: block;
flex: 1;
padding: var(--ha-space-3);
--mdc-text-field-fill-color: var(--sidebar-background-color);
--mdc-text-field-idle-line-color: transparent;
}
slot[name="header"] {
display: block;
@@ -1,417 +0,0 @@
import { TZDate } from "@date-fns/tz";
import { consume, type ContextType } from "@lit/context";
import type { ActionDetail } from "@material/mwc-list";
import { mdiCalendarToday } from "@mdi/js";
import "cally";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, queryAll, state } from "lit/decorators";
import { firstWeekdayIndex } from "../../common/datetime/first_weekday";
import {
formatCallyDateRange,
formatDateMonth,
formatDateYear,
formatISODateOnly,
} from "../../common/datetime/format_date";
import { fireEvent } from "../../common/dom/fire_event";
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 { ValueChangedEvent } from "../../types";
import "../chips/ha-chip-set";
import "../chips/ha-filter-chip";
import type { HaFilterChip } from "../chips/ha-filter-chip";
import type { HaBaseTimeInput } from "../ha-base-time-input";
import "../ha-icon-button";
import "../ha-icon-button-next";
import "../ha-icon-button-prev";
import "../ha-list";
import "../ha-list-item";
import "../ha-time-input";
import type { HaTimeInput } from "../ha-time-input";
import type { DateRangePickerRanges } from "./ha-date-range-picker";
import { datePickerStyles, dateRangePickerStyles } from "./styles";
@customElement("date-range-picker")
export class DateRangePicker extends MobileAwareMixin(LitElement) {
@property({ attribute: false }) public ranges?: DateRangePickerRanges | false;
@property({ attribute: false }) public startDate?: Date;
@property({ attribute: false }) public endDate?: Date;
@property({ attribute: "time-picker", type: Boolean })
public timePicker = false;
@state()
@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 })
private hassConfig!: ContextType<typeof configContext>;
/** used to show month in calendar-range header */
@state() private _pickerMonth?: string;
/** used to show year in calendar-date header */
@state() private _pickerYear?: string;
/** used for today to navigate focus in calendar-range */
@state() private _focusDate?: string;
@state() private _dateValue?: string;
@state() private _timeValue = {
from: { hours: 0, minutes: 0 },
to: { hours: 23, minutes: 59 },
};
@queryAll("ha-time-input") private _timeInputs?: NodeListOf<HaTimeInput>;
public connectedCallback() {
super.connectedCallback();
const date = this.startDate || new Date();
this._dateValue =
this.startDate && this.endDate
? formatCallyDateRange(
this.startDate,
this.endDate,
this.locale,
this.hassConfig
)
: undefined;
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 = {
from: {
hours: this.startDate.getHours(),
minutes: this.startDate.getMinutes(),
},
to: {
hours: this.endDate.getHours(),
minutes: this.endDate.getMinutes(),
},
};
}
}
private _renderRanges() {
if (this._isMobileSize) {
return html`
<ha-chip-set class="ha-scrollbar">
${Object.entries(this.ranges!).map(
([name, range], index) => html`
<ha-filter-chip
.index=${index}
.range=${range}
@click=${this._clickDateRangeChip}
>
${name}
</ha-filter-chip>
`
)}
</ha-chip-set>
`;
}
return html`
<ha-list @action=${this._setDateRange} activatable>
${Object.keys(this.ranges!).map(
(name) => html`<ha-list-item>${name}</ha-list-item>`
)}
</ha-list>
`;
}
render() {
return html`<div class="picker">
${this.ranges !== false && this.ranges
? html`<div class="date-range-ranges">${this._renderRanges()}</div>`
: nothing}
<div class="range">
<calendar-range
.value=${this._dateValue}
.locale=${this.locale.language}
.focusedDate=${this._focusDate}
@focusday=${this._focusChanged}
@change=${this._handleChange}
show-outside-days
.firstDayOfWeek=${firstWeekdayIndex(this.locale)}
>
<ha-icon-button-prev
tabindex="-1"
slot="previous"
></ha-icon-button-prev>
<div class="heading" slot="heading">
<span class="month-year"
>${this._pickerMonth} ${this._pickerYear}</span
>
<ha-icon-button
@click=${this._focusToday}
.path=${mdiCalendarToday}
.label=${this.localize("ui.dialogs.date-picker.today")}
></ha-icon-button>
</div>
<ha-icon-button-next
tabindex="-1"
slot="next"
></ha-icon-button-next>
<calendar-month></calendar-month>
</calendar-range>
${this.timePicker
? html`
<div class="times">
<ha-time-input
.value=${`${this._timeValue.from.hours}:${this._timeValue.from.minutes}`}
.locale=${this.locale}
@value-changed=${this._handleChangeTime}
.label=${this.localize(
"ui.components.date-range-picker.time_from"
)}
id="from"
placeholder-labels
auto-validate
></ha-time-input>
<ha-time-input
.value=${`${this._timeValue.to.hours}:${this._timeValue.to.minutes}`}
.locale=${this.locale}
@value-changed=${this._handleChangeTime}
.label=${this.localize(
"ui.components.date-range-picker.time_to"
)}
id="to"
placeholder-labels
auto-validate
></ha-time-input>
</div>
`
: nothing}
</div>
</div>
<div class="footer">
<ha-button appearance="plain" @click=${this._cancel}
>${this.localize("ui.common.cancel")}</ha-button
>
<ha-button .disabled=${!this._dateValue} @click=${this._save}
>${this.localize("ui.components.date-range-picker.select")}</ha-button
>
</div>`;
}
private _focusToday() {
const date = new Date();
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() {
fireEvent(this, "cancel-date-picker");
}
private _save() {
if (!this._dateValue) {
return;
}
const dates = this._dateValue.split("/");
let startDate = new Date(`${dates[0]}T00:00:00`);
let endDate = new Date(`${dates[1]}T23:59:00`);
if (this.timePicker) {
const timeInputs = this._timeInputs;
if (
timeInputs &&
![...timeInputs].every((input) => input.reportValidity())
) {
// If we have time inputs, and they don't all report valid, don't save
return;
}
startDate.setHours(this._timeValue.from.hours);
startDate.setMinutes(this._timeValue.from.minutes);
endDate.setHours(this._timeValue.to.hours);
endDate.setMinutes(this._timeValue.to.minutes);
startDate.setSeconds(0);
startDate.setMilliseconds(0);
endDate.setSeconds(0);
endDate.setMilliseconds(0);
if (endDate <= startDate) {
endDate.setDate(startDate.getDate() + 1);
}
}
if (this.locale.time_zone === TimeZone.server) {
startDate = new Date(
new TZDate(startDate, this.hassConfig.time_zone).getTime()
);
endDate = new Date(
new TZDate(endDate, this.hassConfig.time_zone).getTime()
);
}
if (
startDate.getHours() !== this._timeValue.from.hours ||
startDate.getMinutes() !== this._timeValue.from.minutes ||
endDate.getHours() !== this._timeValue.to.hours ||
endDate.getMinutes() !== this._timeValue.to.minutes
) {
this._timeValue.from.hours = startDate.getHours();
this._timeValue.from.minutes = startDate.getMinutes();
this._timeValue.to.hours = endDate.getHours();
this._timeValue.to.minutes = endDate.getMinutes();
}
fireEvent(this, "value-changed", {
value: {
startDate,
endDate,
},
});
}
private _focusChanged(ev: CustomEvent<Date>) {
const date = ev.detail;
this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig);
this._pickerYear = formatDateYear(date, this.locale, this.hassConfig);
this._focusDate = undefined;
}
private _handleChange(ev: CustomEvent) {
const dateElement = ev.target as HTMLElementTagNameMap["calendar-range"];
this._dateValue = dateElement.value;
this._focusDate = undefined;
}
private _clickDateRangeChip(ev: Event) {
const chip = ev.target as HaFilterChip & {
index: number;
range: [Date, Date];
};
this._saveDateRangePreset(chip.range, chip.index);
}
private _setDateRange(ev: CustomEvent<ActionDetail>) {
const dateRange: [Date, Date] = Object.values(this.ranges!)[
ev.detail.index
];
this._saveDateRangePreset(dateRange, ev.detail.index);
}
private _saveDateRangePreset(range: [Date, Date], index: number) {
fireEvent(this, "value-changed", {
value: {
startDate: range[0],
endDate: range[1],
},
});
fireEvent(this, "preset-selected", {
index,
});
}
private _handleChangeTime(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const time = ev.detail.value;
const target = ev.target as HaBaseTimeInput;
const type = target.id;
if (time) {
if (!this._timeValue) {
this._timeValue = {
from: { hours: 0, minutes: 0 },
to: { hours: 23, minutes: 59 },
};
}
const [hours, minutes] = time.split(":").map(Number);
this._timeValue[type].hours = hours;
this._timeValue[type].minutes = minutes;
}
}
static styles = [
datePickerStyles,
dateRangePickerStyles,
haStyleScrollbar,
css`
.picker {
display: flex;
flex-direction: row;
}
.date-range-ranges {
border-right: var(--ha-border-width-sm) solid var(--divider-color);
min-width: 140px;
flex: 0 1 30%;
}
.range {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
padding: var(--ha-space-3);
overflow-x: hidden;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
.picker {
flex-direction: column;
}
.date-range-ranges {
border-bottom: 1px solid var(--divider-color);
margin-top: var(--ha-space-5);
overflow: visible;
}
ha-chip-set {
padding: var(--ha-space-3);
flex-wrap: nowrap;
overflow-x: auto;
}
.range {
flex-basis: fit-content;
}
}
.times {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.footer {
display: flex;
justify-content: flex-end;
padding: var(--ha-space-2);
border-top: 1px solid var(--divider-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"date-range-picker": DateRangePicker;
}
interface HASSDomEvents {
"cancel-date-picker": undefined;
"preset-selected": { index: number };
}
}
@@ -1,406 +0,0 @@
import "@home-assistant/webawesome/dist/components/popover/popover";
import { consume, type ContextType } from "@lit/context";
import { mdiCalendar } from "@mdi/js";
import "cally";
import { isThisYear } from "date-fns";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { tinykeys } from "tinykeys";
import { shiftDateRange } from "../../common/datetime/calc_date";
import type { DateRange } from "../../common/datetime/calc_date_range";
import { calcDateRange } from "../../common/datetime/calc_date_range";
import {
formatShortDateTime,
formatShortDateTimeWithYear,
} from "../../common/datetime/format_date_time";
import { fireEvent } from "../../common/dom/fire_event";
import {
configContext,
localeContext,
localizeContext,
} from "../../data/context";
import "../ha-bottom-sheet";
import "../ha-icon-button";
import "../ha-icon-button-next";
import "../ha-icon-button-prev";
import "../ha-textarea";
import "./date-range-picker";
export type DateRangePickerRanges = Record<string, [Date, Date]>;
const RANGE_KEYS: DateRange[] = ["today", "yesterday", "this_week"];
const EXTENDED_RANGE_KEYS: DateRange[] = [
"this_month",
"this_year",
"now-1h",
"now-12h",
"now-24h",
"now-7d",
"now-30d",
];
@customElement("ha-date-range-picker")
export class HaDateRangePicker extends LitElement {
@state()
@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 })
private hassConfig!: ContextType<typeof configContext>;
@property({ attribute: false }) public startDate!: Date;
@property({ attribute: false }) public endDate!: Date;
@property({ attribute: false }) public ranges?: DateRangePickerRanges | false;
@state() private _ranges?: DateRangePickerRanges;
@property({ attribute: "time-picker", type: Boolean })
public timePicker = false;
@property({ type: Boolean, reflect: true })
public backdrop = false;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public minimal = false;
@property({ attribute: "extended-presets", type: Boolean })
public extendedPresets = false;
@property({ attribute: "popover-placement" })
public popoverPlacement:
| "bottom"
| "top"
| "left"
| "right"
| "top-start"
| "top-end"
| "right-start"
| "right-end"
| "bottom-start"
| "bottom-end"
| "left-start"
| "left-end" = "bottom-start";
@state() private _opened = false;
@state() private _pickerWrapperOpen = false;
@state() private _openedNarrow = false;
@state() private _popoverWidth = 0;
@query(".container") private _containerElement?: HTMLDivElement;
private _narrow = false;
private _unsubscribeTinyKeys?: () => void;
public connectedCallback() {
super.connectedCallback();
this._handleResize();
window.addEventListener("resize", this._handleResize);
const rangeKeys = this.extendedPresets
? [...RANGE_KEYS, ...EXTENDED_RANGE_KEYS]
: RANGE_KEYS;
this._ranges = {};
rangeKeys.forEach((key) => {
this._ranges![
this.localize(`ui.components.date-range-picker.ranges.${key}`)
] = calcDateRange(this.locale, this.hassConfig, key);
});
}
public open(): void {
this._openPicker();
}
protected render(): TemplateResult {
return html`
<div class="container">
<div class="date-range-inputs">
${!this.minimal
? html`<ha-textarea
id="field"
mobile-multiline
@click=${this._openPicker}
@keydown=${this._handleKeydown}
.value=${(isThisYear(this.startDate)
? formatShortDateTime(
this.startDate,
this.locale,
this.hassConfig
)
: formatShortDateTimeWithYear(
this.startDate,
this.locale,
this.hassConfig
)) +
(window.innerWidth >= 459 ? " - " : " - \n") +
(isThisYear(this.endDate)
? formatShortDateTime(
this.endDate,
this.locale,
this.hassConfig
)
: formatShortDateTimeWithYear(
this.endDate,
this.locale,
this.hassConfig
))}
.label=${this.localize(
"ui.components.date-range-picker.start_date"
) +
" - " +
this.localize("ui.components.date-range-picker.end_date")}
.disabled=${this.disabled}
readonly
></ha-textarea>
<ha-icon-button-prev
.label=${this.localize("ui.common.previous")}
@click=${this._handlePrev}
>
</ha-icon-button-prev>
<ha-icon-button-next
.label=${this.localize("ui.common.next")}
@click=${this._handleNext}
>
</ha-icon-button-next>`
: html`<ha-icon-button
@click=${this._openPicker}
.disabled=${this.disabled}
id="field"
.label=${this.localize(
"ui.components.date-range-picker.select_date_range"
)}
.path=${mdiCalendar}
></ha-icon-button>`}
</div>
${this._pickerWrapperOpen || this._opened
? this._openedNarrow
? html`
<ha-bottom-sheet
flexcontent
.open=${this._pickerWrapperOpen}
@wa-after-show=${this._dialogOpened}
@closed=${this._hidePicker}
>
${this._renderPicker()}
</ha-bottom-sheet>
`
: html`
<wa-popover
.open=${this._pickerWrapperOpen}
style="--body-width: ${this._popoverWidth}px;"
class=${this._opened ? "open" : ""}
without-arrow
distance="0"
.placement=${this.popoverPlacement}
for="field"
auto-size="vertical"
auto-size-padding="16"
@wa-after-show=${this._dialogOpened}
@wa-hide=${this._handlePopoverHide}
@wa-after-hide=${this._hidePicker}
trap-focus
>
${this._renderPicker()}
</wa-popover>
`
: nothing}
</div>
`;
}
private _renderPicker() {
if (!this._opened) {
return nothing;
}
return html`
<date-range-picker
.ranges=${this.ranges === false ? false : this.ranges || this._ranges}
.startDate=${this.startDate}
.endDate=${this.endDate}
.timePicker=${this.timePicker}
@cancel-date-picker=${this._closePicker}
@value-changed=${this._closePicker}
>
</date-range-picker>
`;
}
private _hidePicker(ev: Event) {
ev.stopPropagation();
this._opened = false;
this._pickerWrapperOpen = false;
this._unsubscribeTinyKeys?.();
fireEvent(this, "picker-closed");
}
public disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("resize", this._handleResize);
this._unsubscribeTinyKeys?.();
}
private _handleResize = () => {
this._narrow =
window.matchMedia("(max-width: 870px)").matches ||
window.matchMedia("(max-height: 500px)").matches;
if (!this._openedNarrow && this._pickerWrapperOpen) {
this._popoverWidth = this._containerElement?.offsetWidth || 250;
}
};
private _dialogOpened = () => {
this._opened = true;
this._setTextareaFocusStyle(true);
};
private _handlePopoverHide = () => {
this._opened = false;
};
private _handleNext(ev: MouseEvent): void {
if (ev && ev.stopPropagation) ev.stopPropagation();
this._shift(true);
}
private _handlePrev(ev: MouseEvent): void {
if (ev && ev.stopPropagation) ev.stopPropagation();
this._shift(false);
}
private _shift(forward: boolean) {
if (!this.startDate) return;
const { start, end } = shiftDateRange(
this.startDate,
this.endDate,
forward,
this.locale,
this.hassConfig
);
this.startDate = start;
this.endDate = end;
fireEvent(this, "value-changed", {
value: {
startDate: this.startDate,
endDate: this.endDate,
},
});
}
private _closePicker() {
this._pickerWrapperOpen = false;
}
private _openPicker(ev?: Event) {
if (this.disabled) {
return;
}
if (this._pickerWrapperOpen) {
ev?.stopImmediatePropagation();
return;
}
this._openedNarrow = this._narrow;
this._popoverWidth = this._containerElement?.offsetWidth || 250;
this._pickerWrapperOpen = true;
this._unsubscribeTinyKeys = tinykeys(this, {
Escape: this._handleEscClose,
});
}
private _handleKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.stopPropagation();
this._openPicker(ev);
}
}
private _handleEscClose = (ev: KeyboardEvent) => {
ev.stopPropagation();
};
private _setTextareaFocusStyle(focused: boolean) {
const textarea = this.renderRoot.querySelector("ha-textarea");
if (textarea) {
const foundation = (textarea as any).mdcFoundation;
if (foundation) {
if (focused) {
foundation.activateFocus();
} else {
foundation.deactivateFocus();
}
}
}
}
static styles = [
css`
ha-icon-button {
direction: var(--direction);
}
.date-range-inputs {
display: flex;
align-items: center;
gap: var(--ha-space-2);
}
ha-textarea {
display: inline-block;
width: 340px;
}
@media only screen and (max-width: 460px) {
ha-textarea {
width: 100%;
}
}
wa-popover {
--wa-space-l: 0;
}
wa-popover::part(dialog)::backdrop {
opacity: 0;
transition: opacity var(--ha-animation-duration-normal) ease-out;
}
wa-popover.open::part(dialog)::backdrop {
opacity: 1;
}
:host(:not([backdrop])) wa-popover::part(dialog)::backdrop {
background: none;
}
wa-popover::part(body) {
min-width: max(var(--body-width), 250px);
max-width: calc(
100vw - var(--safe-area-inset-left) - var(
--safe-area-inset-right
) - var(--ha-space-8)
);
overflow: hidden;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-date-range-picker": HaDateRangePicker;
}
}
@@ -1,220 +0,0 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import { consume, type ContextType } from "@lit/context";
import { mdiBackspace, mdiCalendarToday } from "@mdi/js";
import "cally";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import {
formatDateMonth,
formatDateShort,
formatDateYear,
formatISODateOnly,
} from "../../common/datetime/format_date";
import {
configContext,
localeContext,
localizeContext,
} from "../../data/context";
import { DialogMixin } from "../../dialogs/dialog-mixin";
import "../ha-button";
import type { DatePickerDialogParams } from "../ha-date-input";
import "../ha-dialog";
import "../ha-dialog-footer";
import "../ha-icon-button";
import "../ha-icon-button-next";
import "../ha-icon-button-prev";
import { datePickerStyles } from "./styles";
type CalendarDate = HTMLElementTagNameMap["calendar-date"];
/**
* A date picker dialog component that displays a calendar for selecting dates.
* Uses the `cally` library for calendar rendering and supports localization,
* min/max date constraints, and optional clearing of the selected date.
*
* @element ha-dialog-date-picker
* Uses {@link DialogMixin} with {@link DatePickerDialogParams} to manage dialog state and parameters.
*/
@customElement("ha-dialog-date-picker")
export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
LitElement
) {
@state()
@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 })
private hassConfig!: ContextType<typeof configContext>;
@state() private _value?: {
year: string;
title: string;
dateString: string;
};
/** used to show month in calendar-date header */
@state() private _pickerMonth?: string;
/** used to show year in calendar-date header */
@state() private _pickerYear?: string;
/** used for today to navigate focus in cally-calendar-date */
@state() private _focusDate?: string;
public connectedCallback() {
super.connectedCallback();
if (this.params) {
const date = this.params.value
? new Date(`${this.params.value.split("T")[0]}T00:00:00`)
: new Date();
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.locale, this.hassConfig),
dateString: formatISODateOnly(date, this.locale, this.hassConfig),
}
: undefined;
}
}
render() {
if (!this.params) {
return nothing;
}
return html`<ha-dialog
open
width="small"
.headerTitle=${this._value?.title ||
this.localize("ui.dialogs.date-picker.title")}
.headerSubtitle=${this._value?.year}
header-subtitle-position="above"
>
${this.params.canClear
? html`
<ha-icon-button
.path=${mdiBackspace}
.label=${this.localize("ui.dialogs.date-picker.clear")}
slot="headerActionItems"
@click=${this._clear}
></ha-icon-button>
`
: nothing}
<wa-divider></wa-divider>
<calendar-date
.value=${this._value?.dateString}
.min=${this.params.min}
.max=${this.params.max}
.locale=${this.params.locale}
.firstDayOfWeek=${this.params.firstWeekday}
.focusedDate=${this._focusDate}
@change=${this._valueChanged}
@focusday=${this._focusChanged}
>
<ha-icon-button-prev
tabindex="-1"
slot="previous"
></ha-icon-button-prev>
<div class="heading" slot="heading">
<span class="month-year"
>${this._pickerMonth} ${this._pickerYear}</span
>
<ha-icon-button
@click=${this._setToday}
.path=${mdiCalendarToday}
.label=${this.localize("ui.dialogs.date-picker.today")}
></ha-icon-button>
</div>
<ha-icon-button-next tabindex="-1" slot="next"></ha-icon-button-next>
<calendar-month></calendar-month>
</calendar-date>
<ha-dialog-footer slot="footer">
<ha-button
appearance="plain"
slot="secondaryAction"
@click=${this.closeDialog}
>
${this.localize("ui.common.cancel")}
</ha-button>
<ha-button slot="primaryAction" @click=${this._setValue}>
${this.localize("ui.common.ok")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>`;
}
private _valueChanged(ev: Event) {
const dateElement = ev.target as CalendarDate;
if (dateElement.value) {
this._updateValue(dateElement.value);
}
}
private _updateValue(value?: string, setFocusDay = false) {
const date = value
? new Date(`${value.split("T")[0]}T00:00:00`)
: new Date();
this._value = {
year: formatDateYear(date, this.locale, this.hassConfig),
title: formatDateShort(date, this.locale, this.hassConfig),
dateString:
value || formatISODateOnly(date, this.locale, this.hassConfig),
};
if (setFocusDay) {
this._focusDate = this._value.dateString;
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.locale, this.hassConfig);
this._pickerYear = formatDateYear(date, this.locale, this.hassConfig);
this._focusDate = undefined;
}
private _clear() {
this.params?.onChange(undefined);
this.closeDialog();
}
private _setToday() {
this._updateValue(undefined, true);
}
private _setValue() {
if (!this._value) {
// Date picker opens to today if value is undefined. If user click OK
// without changing the date, should return todays date, not undefined.
this._setToday();
}
this.params?.onChange(this._value?.dateString);
this.closeDialog();
}
static styles = [
datePickerStyles,
css`
ha-dialog {
--dialog-content-padding: 0;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-dialog-date-picker": HaDialogDatePicker;
}
}
-117
View File
@@ -1,117 +0,0 @@
import { css } from "lit";
export const datePickerStyles = css`
calendar-range,
calendar-date {
width: 100%;
min-width: 300px;
}
calendar-date::part(button),
calendar-range::part(button) {
border: none;
background-color: unset;
border-radius: var(--ha-border-radius-circle);
outline-offset: -2px;
outline-color: var(--ha-color-neutral-60);
}
calendar-month {
width: calc(40px * 7);
margin: 0 auto;
min-height: calc(42px * 7);
}
calendar-month::part(heading) {
display: none;
}
calendar-month::part(day) {
color: var(--disabled-text-color);
font-size: var(--ha-font-size-m);
font-family: var(--ha-font-body);
}
calendar-month::part(button) {
color: var(--primary-text-color);
height: 32px;
width: 32px;
margin: var(--ha-space-1);
border-radius: var(--ha-border-radius-circle);
}
calendar-month::part(button):focus-visible {
background-color: inherit;
outline: 2px solid var(--accent-color);
outline-offset: 2px;
}
calendar-month::part(button):hover {
background-color: var(--ha-color-fill-primary-quiet-hover);
}
calendar-month::part(today) {
color: var(--primary-color);
}
calendar-month::part(range-inner),
calendar-month::part(range-start),
calendar-month::part(range-end),
calendar-month::part(selected),
calendar-month::part(selected):hover {
color: var(--text-primary-color);
background-color: var(--primary-color);
height: 40px;
width: 40px;
margin: 0;
}
calendar-month::part(selected):focus-visible {
background-color: var(--primary-color);
color: var(--text-primary-color);
}
calendar-month::part(outside) {
cursor: pointer;
}
.heading {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
}
.month-year {
flex: 1;
text-align: center;
margin-left: 48px;
}
`;
export const dateRangePickerStyles = css`
calendar-month::part(selected):focus-visible {
background-color: var(--primary-color);
color: var(--text-primary-color);
}
calendar-month::part(range-inner),
calendar-month::part(range-start),
calendar-month::part(range-end),
calendar-month::part(range-inner):hover,
calendar-month::part(range-start):hover,
calendar-month::part(range-end):hover {
color: var(--text-primary-color);
background-color: var(--primary-color);
border-radius: var(--ha-border-radius-square);
display: block;
margin: 0;
}
calendar-month::part(range-start),
calendar-month::part(range-start):hover {
border-top-left-radius: var(--ha-border-radius-circle);
border-bottom-left-radius: var(--ha-border-radius-circle);
}
calendar-month::part(range-end),
calendar-month::part(range-end):hover {
border-top-right-radius: var(--ha-border-radius-circle);
border-bottom-right-radius: var(--ha-border-radius-circle);
}
calendar-month::part(range-start):hover,
calendar-month::part(range-end):hover,
calendar-month::part(range-inner):hover {
color: var(--primary-text-color);
}
`;
+359
View File
@@ -0,0 +1,359 @@
import wrap from "@vue/web-component-wrapper";
import { customElement } from "lit/decorators";
import Vue from "vue";
import DateRangePicker from "vue2-daterange-picker";
// @ts-ignore
import dateRangePickerStyles from "vue2-daterange-picker/dist/vue2-daterange-picker.css";
import {
localizeMonths,
localizeWeekdays,
} from "../common/datetime/localize_date";
import { fireEvent } from "../common/dom/fire_event";
import { mainWindow } from "../common/dom/get_main_window";
// eslint-disable-next-line @typescript-eslint/naming-convention
const CustomDateRangePicker = Vue.extend({
mixins: [DateRangePicker],
methods: {
// Set the current date to the left picker instead of the right picker because the right is hidden
selectMonthDate() {
const dt: Date = this.end || new Date();
// @ts-ignore
this.changeLeftMonth({
year: dt.getFullYear(),
month: dt.getMonth() + 1,
});
},
// Fix the start/end date calculation when selecting a date range. The
// original code keeps track of the first clicked date (in_selection) but it
// never sets it to either the start or end date variables, so if the
// in_selection date is between the start and end date that were set by the
// hover the selection will enter a broken state that's counter-intuitive
// when hovering between weeks and leads to a random date when selecting a
// range across months. This bug doesn't seem to be present on v0.6.7 of the
// lib
hoverDate(value: Date) {
if (this.readonly) return;
if (this.in_selection) {
const pickA = this.in_selection as Date;
const pickB = value;
this.start = this.normalizeDatetime(
Math.min(pickA.valueOf(), pickB.valueOf()),
this.start
);
this.end = this.normalizeDatetime(
Math.max(pickA.valueOf(), pickB.valueOf()),
this.end
);
}
this.$emit("hover-date", value);
},
},
});
// eslint-disable-next-line @typescript-eslint/naming-convention
const Component = Vue.extend({
props: {
timePicker: {
type: Boolean,
default: true,
},
twentyfourHours: {
type: Boolean,
default: true,
},
openingDirection: {
type: String,
default: "right",
},
disabled: {
type: Boolean,
default: false,
},
ranges: {
type: Boolean,
default: true,
},
startDate: {
type: [String, Date],
default() {
return new Date();
},
},
endDate: {
type: [String, Date],
default() {
return new Date();
},
},
firstDay: {
type: Number,
default: 1,
},
autoApply: {
type: Boolean,
default: false,
},
language: {
type: String,
default: "en",
},
opensVertical: {
type: String,
default: undefined,
},
},
render(createElement) {
// @ts-expect-error
return createElement(CustomDateRangePicker, {
props: {
"time-picker": this.timePicker,
"auto-apply": this.autoApply,
opens: this.openingDirection,
"show-dropdowns": false,
"time-picker24-hour": this.twentyfourHours,
disabled: this.disabled,
ranges: this.ranges ? {} : false,
"locale-data": {
firstDay: this.firstDay,
daysOfWeek: localizeWeekdays(this.language, true),
monthNames: localizeMonths(this.language, false),
},
},
model: {
value: {
startDate: this.startDate,
endDate: this.endDate,
},
callback: (value) => {
fireEvent(this.$el as HTMLElement, "change", value);
},
expression: "dateRange",
},
on: {
toggle: (open: boolean) => {
fireEvent(this.$el as HTMLElement, "toggle", { open });
},
},
scopedSlots: {
input() {
return createElement("slot", {
domProps: { name: "input" },
});
},
header() {
return createElement("slot", {
domProps: { name: "header" },
});
},
ranges() {
return createElement("slot", {
domProps: { name: "ranges" },
});
},
footer() {
return createElement("slot", {
domProps: { name: "footer" },
});
},
},
});
},
});
// Assertion corrects HTMLElement type from package
// eslint-disable-next-line @typescript-eslint/naming-convention
const WrappedElement = wrap(
Vue,
Component
) as unknown as CustomElementConstructor;
@customElement("date-range-picker")
class DateRangePickerElement extends WrappedElement {
constructor() {
super();
const style = document.createElement("style");
style.innerHTML = `
${dateRangePickerStyles}
.calendars {
display: flex;
flex-wrap: nowrap !important;
}
.daterangepicker {
top: auto;
box-shadow: var(--ha-card-box-shadow, none);
background-color: var(--card-background-color);
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
border-width: var(--ha-card-border-width, 1px);
border-style: solid;
border-color: var(
--ha-card-border-color,
var(--divider-color, #e0e0e0)
);
color: var(--primary-text-color);
min-width: initial !important;
max-height: var(--date-range-picker-max-height);
overflow-y: auto;
}
.daterangepicker:before {
display: none;
}
.daterangepicker:after {
border-bottom: 6px solid var(--card-background-color);
}
.daterangepicker .calendar-table {
background-color: var(--card-background-color);
border: none;
}
.daterangepicker .calendar-table td,
.daterangepicker .calendar-table th {
background-color: transparent;
color: var(--secondary-text-color);
border-radius: var(--ha-border-radius-square);
outline: none;
min-width: 32px;
height: 32px;
}
.daterangepicker td.off,
.daterangepicker td.off.end-date,
.daterangepicker td.off.in-range,
.daterangepicker td.off.start-date {
background-color: var(--secondary-background-color);
color: var(--disabled-text-color);
}
.daterangepicker td.in-range {
background-color: var(--light-primary-color);
color: var(--text-light-primary-color, var(--primary-text-color));
}
.daterangepicker td.active,
.daterangepicker td.active:hover {
background-color: var(--primary-color);
color: var(--text-primary-color);
}
.daterangepicker td.start-date.end-date {
border-radius: var(--ha-border-radius-circle);
}
.daterangepicker td.start-date {
border-radius: var(--ha-border-radius-circle) var(--ha-border-radius-square) var(--ha-border-radius-square) var(--ha-border-radius-circle);
}
.daterangepicker td.end-date {
border-radius: var(--ha-border-radius-square) var(--ha-border-radius-circle) var(--ha-border-radius-circle) var(--ha-border-radius-square);
}
.reportrange-text {
background: none !important;
padding: 0 !important;
border: none !important;
}
.daterangepicker .calendar-table .next span,
.daterangepicker .calendar-table .prev span {
border: solid var(--primary-text-color);
border-width: 0 2px 2px 0;
}
.daterangepicker .ranges li {
outline: none;
}
.daterangepicker .ranges li:hover {
background-color: var(--secondary-background-color);
}
.daterangepicker .ranges li.active {
background-color: var(--primary-color);
color: var(--text-primary-color);
}
.daterangepicker select.ampmselect,
.daterangepicker select.hourselect,
.daterangepicker select.minuteselect,
.daterangepicker select.secondselect {
background: var(--card-background-color);
border: 1px solid var(--divider-color);
color: var(--primary-color);
}
.daterangepicker .drp-buttons .btn {
border: 1px solid var(--primary-color);
background-color: transparent;
color: var(--primary-color);
border-radius: var(--ha-border-radius-sm);
padding: 8px;
cursor: pointer;
}
.calendars-container {
flex-direction: column;
align-items: center;
}
.drp-calendar.col.right .calendar-table {
display: none;
}
.daterangepicker.show-ranges .drp-calendar.left {
border-left: 0px;
}
.daterangepicker .drp-calendar.left {
padding: 8px;
width: unset;
max-width: unset;
min-width: 270px;
}
.daterangepicker.show-calendar .ranges {
margin-top: 0;
padding-top: 8px;
border-right: 1px solid var(--divider-color);
}
@media only screen and (max-width: 800px) {
.calendars {
flex-direction: column;
}
}
.calendar-table {
padding: 0 !important;
}
.calendar-time {
direction: ltr;
}
.daterangepicker.ltr {
direction: var(--direction);
text-align: var(--float-start);
}
.vue-daterange-picker{
min-width: unset !important;
display: block !important;
}
:host([opens-vertical="up"]) .daterangepicker {
bottom: 100%;
top: auto !important;
}
`;
if (mainWindow.document.dir === "rtl") {
style.innerHTML += `
.daterangepicker .calendar-table .next span {
transform: rotate(135deg);
-webkit-transform: rotate(135deg);
}
.daterangepicker .calendar-table .prev span {
transform: rotate(-45deg);
-webkit-transform: rotate(-45deg);
}
.daterangepicker td.start-date {
border-radius: var(--ha-border-radius-square) var(--ha-border-radius-circle) var(--ha-border-radius-circle) var(--ha-border-radius-square);
}
.daterangepicker td.end-date {
border-radius: var(--ha-border-radius-circle) var(--ha-border-radius-square) var(--ha-border-radius-square) var(--ha-border-radius-circle);
}
`;
}
const shadowRoot = this.shadowRoot!;
shadowRoot.appendChild(style);
// Stop click events from reaching the document, otherwise it will close the picker immediately.
shadowRoot.addEventListener("click", (ev) => ev.stopPropagation());
}
}
declare global {
interface HTMLElementTagNameMap {
"date-range-picker": DateRangePickerElement;
}
interface HASSDomEvents {
toggle: { open: boolean };
}
}
+3 -3
View File
@@ -6,7 +6,7 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { getDeviceArea } from "../../common/entity/context/get_device_context";
import { getDeviceContext } from "../../common/entity/context/get_device_context";
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
import {
deviceComboBoxKeys,
@@ -14,11 +14,11 @@ import {
type DevicePickerItem,
} from "../../data/device/device_picker";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
export type HaDevicePickerDeviceFilterFunc = (
device: DeviceRegistryEntry
@@ -154,7 +154,7 @@ export class HaDevicePicker extends LitElement {
return html`<span slot="headline">${deviceId}</span>`;
}
const area = getDeviceArea(device, this.hass.areas);
const { area } = getDeviceContext(device, this.hass);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
+216 -321
View File
@@ -1,8 +1,7 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import type { PropertyValues } from "lit";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, query } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
@@ -15,14 +14,12 @@ import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../chips/ha-assist-chip";
import "../chips/ha-chip-set";
import "../chips/ha-input-chip";
import "../ha-button-toggle-group";
import "../ha-combo-box-item";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-input-helper-text";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
import "../ha-sortable";
import "../input/ha-input";
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
<ha-combo-box-item type="button" compact>
@@ -73,291 +70,10 @@ export class HaEntityNamePicker extends LitElement {
@property({ type: Boolean, reflect: true }) public disabled = false;
@state() private _mode?: "composed" | "custom";
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@query("ha-generic-picker", true) private _picker?: HaGenericPicker;
private _editIndex?: number;
connectedCallback(): void {
super.connectedCallback();
if (this.hasUpdated) {
const items = this._toItems(this.value);
this._mode =
items.length === 1 && items[0].type === "text" ? "custom" : "composed";
}
}
protected willUpdate(_changedProperties: PropertyValues): void {
if (this._mode === undefined) {
const items = this._toItems(this.value);
this._mode =
items.length === 1 && items[0].type === "text" ? "custom" : "composed";
}
}
protected render() {
const modeButtons = [
{
label: this.hass.localize(
"ui.components.entity.entity-name-picker.mode_composed"
),
value: "composed",
},
{
label: this.hass.localize(
"ui.components.entity.entity-name-picker.mode_custom"
),
value: "custom",
},
];
return html`
<div class="container">
<div class="header">
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-button-toggle-group
size="small"
.buttons=${modeButtons}
.active=${this._mode}
.disabled=${this.disabled}
@value-changed=${this._modeChanged}
></ha-button-toggle-group>
</div>
<div class="content">
${this._mode === "custom"
? this._renderTextInput()
: this._renderPicker()}
</div>
</div>
${this.helper
? html`
<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>
`
: nothing}
`;
}
private _renderTextInput() {
const items = this._items;
const value =
items.length === 1 && items[0].type === "text" ? items[0].text || "" : "";
return html`
<ha-input
.disabled=${this.disabled}
.required=${this.required}
.value=${value}
@input=${this._textInputChanged}
></ha-input>
`;
}
private _renderPicker() {
const value = this._items;
const validTypes = this._validTypes(this.entityId);
return html`
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.getItems=${this._getFilteredItems}
.rowRenderer=${rowRenderer}
.value=${this._getPickerValue()}
allow-custom-value
.customValueLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.custom_name"
)}
@value-changed=${this._pickerValueChanged}
.searchFn=${this._searchFn}
.searchLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.search"
)}
>
<div slot="field" class="field">
<ha-sortable
no-style
@item-moved=${this._moveItem}
.disabled=${this.disabled}
handle-selector="button.primary.action"
filter=".add"
>
<ha-chip-set>
${repeat(
this._items,
(item) => item,
(item: EntityNameItem, idx) => {
const label = this._formatItem(item);
const isValid = validTypes.has(item.type);
return html`
<ha-input-chip
data-idx=${idx}
@remove=${this._removeItem}
@click=${this._editItem}
.label=${label}
.selected=${!this.disabled}
.disabled=${this.disabled}
class=${!isValid ? "invalid" : ""}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
<span>${label}</span>
</ha-input-chip>
`;
}
)}
${this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this.hass.localize(
"ui.components.entity.entity-name-picker.add"
)}
class="add"
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-assist-chip>
`}
</ha-chip-set>
</ha-sortable>
</div>
</ha-generic-picker>
`;
}
private _modeChanged(ev: CustomEvent) {
ev.stopPropagation();
this._mode = ev.detail.value as "composed" | "custom";
}
private _textInputChanged(ev: Event) {
const value = (ev.target as HTMLInputElement).value;
const newValue: EntityNameItem[] = value
? [{ type: "text", text: value }]
: [];
this._setValue(newValue);
}
private async _addItem(ev: Event) {
ev.stopPropagation();
this._editIndex = undefined;
await this.updateComplete;
await this._picker?.open();
}
private async _editItem(ev: Event) {
ev.stopPropagation();
const idx = parseInt(
(ev.currentTarget as HTMLElement).dataset.idx || "",
10
);
this._editIndex = idx;
await this.updateComplete;
await this._picker?.open();
const value = this._items[idx];
// Pre-fill the field value when editing a text item
if (value.type === "text" && value.text) {
this._picker?.setFieldValue(value.text);
}
}
private async _moveItem(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const value = this._items;
const newValue = value.concat();
const element = newValue.splice(oldIndex, 1)[0];
newValue.splice(newIndex, 0, element);
this._setValue(newValue);
}
private async _removeItem(ev: Event) {
ev.stopPropagation();
const value = [...this._items];
const idx = parseInt((ev.target as HTMLElement).dataset.idx || "", 10);
value.splice(idx, 1);
this._setValue(value);
}
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
ev.stopPropagation();
const value = ev.detail.value;
if (this.disabled || !value) {
return;
}
const item: EntityNameItem = parseOptionValue(value);
const newValue = [...this._items];
if (this._editIndex != null) {
newValue[this._editIndex] = item;
this._editIndex = undefined;
} else {
newValue.push(item);
}
this._setValue(newValue);
if (this._picker) {
this._picker.value = undefined;
}
}
private _setValue(value: EntityNameItem[]) {
const newValue = this._toValue(value);
this.value = newValue;
fireEvent(this, "value-changed", {
value: newValue,
});
}
private get _items(): EntityNameItem[] {
return this._toItems(this.value);
}
private _toItems = memoizeOne((value?: typeof this.value) => {
if (typeof value === "string") {
if (value === "") {
return [];
}
return [{ type: "text", text: value } satisfies EntityNameItem];
}
return value ? ensureArray(value) : [];
});
private _toValue = memoizeOne(
(items: EntityNameItem[]): typeof this.value => {
if (items.length === 0) {
return undefined;
}
if (items.length === 1) {
const item = items[0];
return item.type === "text" ? item.text : item;
}
return items;
}
);
private _formatItem = (item: EntityNameItem) => {
if (item.type === "text") {
return `"${item.text}"`;
}
if (KNOWN_TYPES.has(item.type)) {
return this.hass.localize(
`ui.components.entity.entity-name-picker.types.${item.type as EntityNameType}`
);
}
return item.type;
};
private _validTypes = memoizeOne((entityId?: string) => {
const options = new Set<string>(["text"]);
if (!entityId) {
@@ -442,6 +158,157 @@ export class HaEntityNamePicker extends LitElement {
})
);
private _formatItem = (item: EntityNameItem) => {
if (item.type === "text") {
return `"${item.text}"`;
}
if (KNOWN_TYPES.has(item.type)) {
return this.hass.localize(
`ui.components.entity.entity-name-picker.types.${item.type as EntityNameType}`
);
}
return item.type;
};
protected render() {
const value = this._items;
const validTypes = this._validTypes(this.entityId);
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.getItems=${this._getFilteredItems}
.rowRenderer=${rowRenderer}
.value=${this._getPickerValue()}
allow-custom-value
.customValueLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.custom_name"
)}
@value-changed=${this._pickerValueChanged}
.searchFn=${this._searchFn}
.searchLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.search"
)}
>
<div slot="field" class="container">
<ha-sortable
no-style
@item-moved=${this._moveItem}
.disabled=${this.disabled}
handle-selector="button.primary.action"
filter=".add"
>
<ha-chip-set>
${repeat(
this._items,
(item) => item,
(item: EntityNameItem, idx) => {
const label = this._formatItem(item);
const isValid = validTypes.has(item.type);
return html`
<ha-input-chip
data-idx=${idx}
@remove=${this._removeItem}
@click=${this._editItem}
.label=${label}
.selected=${!this.disabled}
.disabled=${this.disabled}
class=${!isValid ? "invalid" : ""}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
<span>${label}</span>
</ha-input-chip>
`;
}
)}
${this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this.hass.localize(
"ui.components.entity.entity-name-picker.add"
)}
class="add"
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-assist-chip>
`}
</ha-chip-set>
</ha-sortable>
</div>
</ha-generic-picker>
${this._renderHelper()}
`;
}
private _renderHelper() {
return this.helper
? html`
<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>
`
: nothing;
}
private async _addItem(ev: Event) {
ev.stopPropagation();
this._editIndex = undefined;
await this.updateComplete;
await this._picker?.open();
}
private async _editItem(ev: Event) {
ev.stopPropagation();
const idx = parseInt(
(ev.currentTarget as HTMLElement).dataset.idx || "",
10
);
this._editIndex = idx;
await this.updateComplete;
await this._picker?.open();
const value = this._items[idx];
// Pre-fill the field value when editing a text item
if (value.type === "text" && value.text) {
this._picker?.setFieldValue(value.text);
}
}
private get _items(): EntityNameItem[] {
return this._toItems(this.value);
}
private _toItems = memoizeOne((value?: typeof this.value) => {
if (typeof value === "string") {
if (value === "") {
return [];
}
return [{ type: "text", text: value } satisfies EntityNameItem];
}
return value ? ensureArray(value) : [];
});
private _toValue = memoizeOne(
(items: EntityNameItem[]): typeof this.value => {
if (items.length === 0) {
return undefined;
}
if (items.length === 1) {
const item = items[0];
return item.type === "text" ? item.text : item;
}
return items;
}
);
private _getPickerValue(): string | undefined {
if (this._editIndex != null) {
const item = this._items[this._editIndex];
@@ -492,6 +359,58 @@ export class HaEntityNamePicker extends LitElement {
return filteredItems;
};
private async _moveItem(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const value = this._items;
const newValue = value.concat();
const element = newValue.splice(oldIndex, 1)[0];
newValue.splice(newIndex, 0, element);
this._setValue(newValue);
}
private async _removeItem(ev: Event) {
ev.stopPropagation();
const value = [...this._items];
const idx = parseInt((ev.target as HTMLElement).dataset.idx || "", 10);
value.splice(idx, 1);
this._setValue(value);
}
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
ev.stopPropagation();
const value = ev.detail.value;
if (this.disabled || !value) {
return;
}
const item: EntityNameItem = parseOptionValue(value);
const newValue = [...this._items];
if (this._editIndex != null) {
newValue[this._editIndex] = item;
this._editIndex = undefined;
} else {
newValue.push(item);
}
this._setValue(newValue);
if (this._picker) {
this._picker.value = undefined;
}
}
private _setValue(value: EntityNameItem[]) {
const newValue = this._toValue(value);
this.value = newValue;
fireEvent(this, "value-changed", {
value: newValue,
});
}
static styles = css`
:host {
position: relative;
@@ -499,42 +418,13 @@ export class HaEntityNamePicker extends LitElement {
}
.container {
--ha-input-padding-bottom: 0;
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
label {
display: block;
font-weight: 500;
}
.content {
display: flex;
gap: var(--ha-space-2);
align-items: flex-end;
}
ha-generic-picker,
ha-input {
width: 100%;
}
.field {
position: relative;
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-radius: var(--ha-border-radius-sm);
border-end-end-radius: var(--ha-border-radius-square);
border-end-start-radius: var(--ha-border-radius-square);
}
.field:after {
.container:after {
display: block;
content: "";
position: absolute;
@@ -552,25 +442,30 @@ export class HaEntityNamePicker extends LitElement {
height 180ms ease-in-out,
background-color 180ms ease-in-out;
}
:host([disabled]) .field:after {
:host([disabled]) .container:after {
background-color: var(
--mdc-text-field-disabled-line-color,
rgba(0, 0, 0, 0.42)
);
}
.field:focus-within:after {
.container:focus-within:after {
height: 2px;
background-color: var(--mdc-theme-primary);
}
ha-chip-set {
padding: var(--ha-space-3);
label {
display: block;
margin: 0 0 var(--ha-space-2);
}
.add {
order: 1;
}
ha-chip-set {
padding: var(--ha-space-2) var(--ha-space-2);
}
.invalid {
text-decoration: line-through;
}
+11 -9
View File
@@ -156,15 +156,17 @@ export class HaStatisticPicker extends LitElement {
this.value
);
private _getAdditionalItems = (): StatisticComboBoxItem[] => [
{
id: MISSING_ID,
primary: this.hass.localize(
"ui.components.statistic-picker.missing_entity"
),
icon_path: mdiHelpCircleOutline,
},
];
private _getAdditionalItems(): StatisticComboBoxItem[] {
return [
{
id: MISSING_ID,
primary: this.hass.localize(
"ui.components.statistic-picker.missing_entity"
),
icon_path: mdiHelpCircleOutline,
},
];
}
private _getStatisticsItems = memoizeOne(
(
+2
View File
@@ -15,6 +15,7 @@ import { iconColorCSS } from "../../common/style/icon_color_css";
import { cameraUrlWithWidthHeight } from "../../data/camera";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import type { HomeAssistant } from "../../types";
import { addBrandsAuth } from "../../util/brands-url";
import "../ha-state-icon";
@customElement("state-badge")
@@ -140,6 +141,7 @@ export class StateBadge extends LitElement {
if (this.hass) {
imageUrl = this.hass.hassUrl(imageUrl);
}
imageUrl = addBrandsAuth(imageUrl, this.hass?.auth.data.hassUrl);
if (domain === "camera") {
imageUrl = cameraUrlWithWidthHeight(imageUrl, 80, 80);
}
+3 -2
View File
@@ -1,10 +1,11 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { computeStateName } from "../../common/entity/compute_state_name";
import type { HomeAssistant } from "../../types";
import "../ha-relative-time";
import "../ha-tooltip";
import "./state-badge";
import "../ha-tooltip";
@customElement("state-info")
class StateInfo extends LitElement {
@@ -21,7 +22,7 @@ class StateInfo extends LitElement {
return nothing;
}
const name = this.hass.formatEntityName(this.stateObj, { type: "entity" });
const name = computeStateName(this.stateObj);
return html`<state-badge
.hass=${this.hass}
+1 -1
View File
@@ -94,7 +94,7 @@ class HaAddonPicker extends LitElement {
private async _getApps() {
try {
if (isComponentLoaded(this.hass.config, "hassio")) {
if (isComponentLoaded(this.hass, "hassio")) {
const addonsInfo = await fetchHassioAddonsInfo(this.hass);
this._addons = addonsInfo.addons
.filter((addon) => addon.version)
+1 -4
View File
@@ -8,7 +8,6 @@ import {
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { LocalizeFunc } from "../common/translations/localize";
import { fireEvent } from "../common/dom/fire_event";
import "./ha-icon-button";
import "./ha-svg-icon";
@@ -39,8 +38,6 @@ class HaAlert extends LitElement {
@property({ type: Boolean }) public dismissable = false;
@property({ attribute: false }) public localize?: LocalizeFunc;
@property({ type: Boolean }) public narrow = false;
public render() {
@@ -68,7 +65,7 @@ class HaAlert extends LitElement {
${this.dismissable
? html`<ha-icon-button
@click=${this._dismissClicked}
.label=${this.localize!("ui.common.dismiss_alert")}
label="Dismiss alert"
.path=${mdiClose}
></ha-icon-button>`
: nothing}
+6 -10
View File
@@ -2,7 +2,7 @@ import { LitElement, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import type { HomeAssistant } from "../types";
import "./input/ha-input-multi";
import "./ha-multi-textfield";
@customElement("ha-aliases-editor")
class AliasesEditor extends LitElement {
@@ -12,32 +12,28 @@ class AliasesEditor extends LitElement {
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public sortable = false;
protected render() {
if (!this.aliases) {
return nothing;
}
return html`
<ha-input-multi
<ha-multi-textfield
.hass=${this.hass}
.value=${this.aliases}
.disabled=${this.disabled}
.sortable=${this.sortable}
update-on-blur
.label=${this.hass!.localize("ui.dialogs.aliases.label")}
.removeLabel=${this.hass!.localize("ui.dialogs.aliases.remove")}
.addLabel=${this.hass!.localize("ui.dialogs.aliases.add")}
item-index
@value-changed=${this._aliasesChanged}
>
</ha-input-multi>
</ha-multi-textfield>
`;
}
private _aliasesChanged(ev: CustomEvent) {
ev.stopPropagation();
fireEvent(this, "value-changed", { value: ev.detail.value });
private _aliasesChanged(value) {
fireEvent(this, "value-changed", { value });
}
}
@@ -267,6 +267,7 @@ export class HaAreaControlsPicker extends LitElement {
: item.domain
? html`<ha-domain-icon
slot="start"
.hass=${this.hass}
.domain=${item.domain}
.deviceClass=${item.deviceClass}
></ha-domain-icon>`
@@ -9,6 +9,7 @@ import "./ha-expansion-panel";
import "./ha-items-display-editor";
import type { DisplayItem, DisplayValue } from "./ha-items-display-editor";
import "./ha-svg-icon";
import "./ha-textfield";
export interface AreasDisplayValue {
hidden?: string[];
@@ -15,6 +15,7 @@ import "./ha-floor-icon";
import "./ha-items-display-editor";
import type { DisplayItem, DisplayValue } from "./ha-items-display-editor";
import "./ha-svg-icon";
import "./ha-textfield";
export interface AreasFloorsDisplayValue {
areas_display?: {
+63 -272
View File
@@ -1,15 +1,9 @@
import {
mdiAlertCircle,
mdiChevronDown,
mdiChevronUp,
mdiCommentProcessingOutline,
mdiMicrophone,
mdiSend,
} from "@mdi/js";
import { mdiAlertCircle, mdiMicrophone, mdiSend } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { haStyleScrollbar } from "../resources/styles";
import { supportsFeature } from "../common/entity/supports-feature";
import {
runAssistPipeline,
@@ -20,28 +14,17 @@ import {
} from "../data/assist_pipeline";
import { ConversationEntityFeature } from "../data/conversation";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import { AudioRecorder } from "../util/audio-recorder";
import { documentationUrl } from "../util/documentation-url";
import "./ha-alert";
import "./ha-markdown";
import "./input/ha-input";
import type { HaInput } from "./input/ha-input";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
interface AssistMessage {
who: string;
text: string | TemplateResult;
thinking: string;
thinking_expanded?: boolean;
tool_calls: Record<
string,
{
tool_name: string;
tool_args: Record<string, unknown>;
result?: any;
}
>;
text?: string | TemplateResult;
error?: boolean;
}
@@ -57,7 +40,7 @@ export class HaAssistChat extends LitElement {
@property({ attribute: false })
public startListening?: boolean;
@query("#message-input") private _messageInput!: HaInput;
@query("#message-input") private _messageInput!: HaTextField;
@query(".message:last-child")
private _lastChatMessage!: LitElement;
@@ -87,8 +70,6 @@ export class HaAssistChat extends LitElement {
{
who: "hass",
text: this.hass.localize("ui.dialogs.voice_command.how_can_i_help"),
thinking: "",
tool_calls: {},
},
];
}
@@ -146,114 +127,29 @@ export class HaAssistChat extends LitElement {
`}
<div class="spacer"></div>
${this._conversation!.map(
(message, index) => html`
<div class="message-container ${classMap({ [message.who]: true })}">
${message.text ||
message.error ||
message.thinking ||
(message.tool_calls && Object.keys(message.tool_calls).length > 0)
? html`
<div
class="message ${classMap({
error: !!message.error,
[message.who]: true,
})}"
>
${message.thinking ||
(message.tool_calls &&
Object.keys(message.tool_calls).length > 0)
? html`
<div
class="thinking-wrapper ${classMap({
expanded: !!message.thinking_expanded,
})}"
>
<button
class="thinking-header"
.index=${index}
@click=${this._handleToggleThinking}
aria-expanded=${message.thinking_expanded
? "true"
: "false"}
>
<ha-svg-icon
.path=${mdiCommentProcessingOutline}
></ha-svg-icon>
<span class="thinking-label">
${this.hass.localize(
"ui.dialogs.voice_command.show_details"
)}
</span>
<ha-svg-icon
.path=${message.thinking_expanded
? mdiChevronUp
: mdiChevronDown}
></ha-svg-icon>
</button>
<div class="thinking-content">
${message.thinking
? html`<ha-markdown
.content=${message.thinking}
></ha-markdown>`
: nothing}
${message.tool_calls &&
Object.keys(message.tool_calls).length > 0
? html`
<div class="tool-calls">
${Object.values(message.tool_calls).map(
(toolCall) => html`
<div class="tool-call">
<div class="tool-name">
${toolCall.tool_name}
</div>
<div class="tool-data">
<pre>
${JSON.stringify(toolCall.tool_args, null, 2)}</pre
>
</div>
${toolCall.result
? html`
<div class="tool-data">
<pre>
${JSON.stringify(toolCall.result, null, 2)}</pre
>
</div>
`
: nothing}
</div>
`
)}
</div>
`
: nothing}
</div>
</div>
`
: nothing}
${message.text
? html`
<ha-markdown
breaks
cache
.content=${message.text}
></ha-markdown>
`
: nothing}
</div>
`
: nothing}
</div>
(message) => html`
<ha-markdown
class="message ${classMap({
error: !!message.error,
[message.who]: true,
})}"
breaks
cache
.content=${message.text}
>
</ha-markdown>
`
)}
</div>
<div class="input" slot="primaryAction">
<ha-input
<ha-textfield
id="message-input"
@keyup=${this._handleKeyUp}
@input=${this._handleInput}
.label=${this.hass.localize(`ui.dialogs.voice_command.input_label`)}
.iconTrailing=${true}
>
<div slot="end">
<div slot="trailingIcon">
${this._showSendButton || !supportsSTT
? html`
<ha-icon-button
@@ -298,7 +194,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
</div>
`}
</div>
</ha-input>
</ha-textfield>
</div>
`;
}
@@ -328,7 +224,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
}
private _handleKeyUp(ev: KeyboardEvent) {
const input = ev.target as HaInput;
const input = ev.target as HaTextField;
if (!this._processing && ev.key === "Enter" && input.value) {
this._processText(input.value);
input.value = "";
@@ -337,7 +233,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
}
private _handleInput(ev: InputEvent) {
const value = (ev.target as HaInput).value;
const value = (ev.target as HaTextField).value;
if (value && !this._showSendButton) {
this._showSendButton = true;
} else if (!value && this._showSendButton) {
@@ -372,15 +268,6 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
}
}
private _handleToggleThinking(ev: Event) {
const index = (ev.currentTarget as any).index;
this._conversation[index] = {
...this._conversation[index],
thinking_expanded: !this._conversation[index].thinking_expanded,
};
this.requestUpdate("_conversation");
}
private _addMessage(message: AssistMessage) {
this._conversation = [...this._conversation!, message];
}
@@ -409,9 +296,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
"ui.dialogs.voice_command.not_supported_microphone_documentation_link"
)}</a>`,
}
)}`,
thinking: "",
tool_calls: {},
)}`,
});
}
@@ -432,8 +317,6 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
const userMessage: AssistMessage = {
who: "user",
text: "…",
thinking: "",
tool_calls: {},
};
await this._audioRecorder.start();
@@ -565,7 +448,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
private async _processText(text: string) {
this._unloadAudio();
this._processing = true;
this._addMessage({ who: "user", text, thinking: "", tool_calls: {} });
this._addMessage({ who: "user", text });
const hassMessageProcesser = this._createAddHassMessageProcessor();
hassMessageProcesser.addMessage();
try {
@@ -604,23 +487,17 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
let currentDeltaRole = "";
const progressToNextMessage = () => {
if (
progress.hassMessage.text === "…" &&
!progress.hassMessage.thinking &&
(!progress.hassMessage.tool_calls ||
Object.keys(progress.hassMessage.tool_calls).length === 0)
) {
if (progress.hassMessage.text === "…") {
return;
}
if (progress.hassMessage.text?.endsWith("…")) {
progress.hassMessage.text = progress.hassMessage.text.slice(0, -1);
}
progress.hassMessage.text = progress.hassMessage.text.substring(
0,
progress.hassMessage.text.length - 1
);
progress.hassMessage = {
who: "hass",
text: "…",
thinking: "",
tool_calls: {},
error: false,
};
this._addMessage(progress.hassMessage);
@@ -636,13 +513,16 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
): _delta is ConversationChatLogToolResultDelta =>
currentDeltaRole === "tool_result";
const tools: Record<
string,
ConversationChatLogAssistantDelta["tool_calls"][0]
> = {};
const progress = {
continueConversation: false,
hassMessage: {
who: "hass",
text: "…",
thinking: "",
tool_calls: {},
error: false,
},
addMessage: () => {
@@ -660,37 +540,29 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
// new message
if (delta.role) {
progressToNextMessage();
currentDeltaRole = delta.role;
}
if (isAssistantDelta(delta)) {
if (delta.content) {
if (progress.hassMessage.text.endsWith("…")) {
progress.hassMessage.text =
progress.hassMessage.text.substring(
0,
progress.hassMessage.text.length - 1
) +
delta.content +
"…";
} else {
progress.hassMessage.text += delta.content + "…";
}
}
if (delta.thinking_content) {
progress.hassMessage.thinking += delta.thinking_content;
progress.hassMessage.text =
progress.hassMessage.text.substring(
0,
progress.hassMessage.text.length - 1
) +
delta.content +
"…";
this.requestUpdate("_conversation");
}
if (delta.tool_calls) {
for (const toolCall of delta.tool_calls) {
progress.hassMessage.tool_calls[toolCall.id] = toolCall;
tools[toolCall.id] = toolCall;
}
}
this.requestUpdate("_conversation");
} else if (isToolResult(delta)) {
if (progress.hassMessage.tool_calls[delta.tool_call_id]) {
progress.hassMessage.tool_calls[delta.tool_call_id].result =
delta.tool_result;
this.requestUpdate("_conversation");
if (tools[delta.tool_call_id]) {
delete tools[delta.tool_call_id];
}
}
} else if (event.type === "intent-end") {
@@ -727,10 +599,9 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
ha-alert {
margin-bottom: var(--ha-space-2);
}
#message-input::part(wa-base) {
padding-right: var(--ha-space-1);
ha-textfield {
display: block;
}
.messages {
flex: 1 1 400px;
display: block;
@@ -748,17 +619,6 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
.spacer {
flex: 1;
}
.message-container {
display: flex;
flex-direction: column;
margin: var(--ha-space-2) 0;
}
.message-container.user {
align-self: flex-end;
}
.message-container.hass {
align-self: flex-start;
}
.message {
font-size: var(--ha-font-size-l);
clear: both;
@@ -806,89 +666,6 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
background-color: var(--error-color);
color: var(--text-primary-color);
}
.thinking-wrapper {
margin: calc(var(--ha-space-2) * -1) calc(var(--ha-space-2) * -1) 0
calc(var(--ha-space-2) * -1);
overflow: hidden;
}
.thinking-wrapper:last-child {
margin-bottom: calc(var(--ha-space-2) * -1);
}
.thinking-header {
display: flex;
align-items: center;
gap: var(--ha-space-2);
width: 100%;
background: none;
border: none;
padding: var(--ha-space-2);
cursor: pointer;
text-align: left;
color: var(--secondary-text-color);
transition: color 0.2s;
}
.thinking-header:hover,
.thinking-header:focus {
outline: none;
color: var(--primary-text-color);
}
.thinking-label {
font-size: var(--ha-font-size-m);
display: flex;
align-items: center;
gap: var(--ha-space-2);
}
.thinking-header ha-svg-icon {
--mdc-icon-size: 16px;
}
.thinking-content {
max-height: 0;
overflow: hidden;
transition:
max-height 0.3s ease-in-out,
padding 0.3s;
padding: 0 var(--ha-space-2);
font-size: var(--ha-font-size-m);
color: var(--secondary-text-color);
}
.thinking-wrapper.expanded .thinking-content {
max-height: 500px;
padding: var(--ha-space-2);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.tool-calls {
display: flex;
flex-direction: column;
gap: var(--ha-space-1);
}
.tool-call {
padding: var(--ha-space-1) var(--ha-space-2);
border-left: 2px solid var(--divider-color);
margin-bottom: var(--ha-space-1);
}
.tool-name {
font-weight: bold;
display: flex;
align-items: center;
gap: var(--ha-space-1);
}
.tool-data {
font-family: var(--code-font-family, monospace);
font-size: 0.9em;
background: var(--markdown-code-background-color);
padding: var(--ha-space-1);
border-radius: var(--ha-border-radius-s);
margin-top: var(--ha-space-1);
overflow-x: auto;
}
.tool-data pre {
margin: 0;
white-space: pre-wrap;
word-break: break-all;
}
ha-markdown {
--markdown-image-border-radius: calc(var(--ha-border-radius-xl) / 2);
--markdown-table-border-color: var(--divider-color);
@@ -944,6 +721,20 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
}
}
.listening-icon {
position: relative;
color: var(--secondary-text-color);
margin-right: -24px;
margin-inline-end: -24px;
margin-inline-start: initial;
direction: var(--direction);
transform: scaleX(var(--scale-direction));
}
.listening-icon[active] {
color: var(--primary-color);
}
.unsupported {
color: var(--error-color);
position: absolute;
+1 -4
View File
@@ -56,10 +56,7 @@ class HaAttributeValue extends LitElement {
this.stateObj!,
this.attribute
);
return parts
.filter((part) => part.type === "value")
.map((part) => part.value)
.join("");
return parts.find((part) => part.type === "value")?.value;
}
return this.hass.formatEntityAttributeValue(this.stateObj!, this.attribute);
+82 -123
View File
@@ -4,13 +4,12 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, queryAll } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import "./ha-icon-button";
import "./ha-input-helper-text";
import "./ha-select";
import type { HaSelectSelectEvent } from "./ha-select";
import "./input/ha-input";
import type { HaInput } from "./input/ha-input";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
export interface TimeChangedEvent {
days?: number;
@@ -134,10 +133,7 @@ export class HaBaseTimeInput extends LitElement {
@property({ type: Boolean, reflect: true }) public clearable?: boolean;
@property({ attribute: "placeholder-labels", type: Boolean })
public placeholderLabels = false;
@queryAll("ha-input") private _inputs?: NodeListOf<HaInput>;
@queryAll("ha-textfield") private _inputs?: HaTextField[];
static shadowRootOptions = {
...LitElement.shadowRootOptions,
@@ -145,9 +141,7 @@ export class HaBaseTimeInput extends LitElement {
};
public reportValidity(): boolean {
const inputs = this._inputs;
if (!inputs) return true;
return [...inputs].every((input) => input.reportValidity());
return this._inputs?.every((input) => input.reportValidity()) ?? true;
}
protected render(): TemplateResult {
@@ -159,104 +153,99 @@ export class HaBaseTimeInput extends LitElement {
<div class="time-input-wrap">
${this.enableDay
? html`
<ha-input
<ha-textfield
id="day"
type="number"
inputmode="numeric"
.value=${this.days.toFixed()}
.label=${!this.placeholderLabels ? this.dayLabel : ""}
.placeholder=${this.placeholderLabels ? this.dayLabel : ""}
.label=${this.dayLabel}
name="days"
@change=${this._valueChanged}
@focusin=${this._onFocus}
without-spin-buttons
no-spinner
.required=${this.required}
.autoValidate=${this.autoValidate}
min="0"
.disabled=${this.disabled}
suffix=":"
class="hasSuffix"
>
</ha-input>
<div class="time-separator">:</div>
</ha-textfield>
`
: nothing}
<ha-input
<ha-textfield
id="hour"
type="number"
inputmode="numeric"
.value=${this.hours.toFixed()}
.label=${!this.placeholderLabels ? this.hourLabel : ""}
.placeholder=${this.placeholderLabels ? this.hourLabel : ""}
.label=${this.hourLabel}
name="hours"
@change=${this._valueChanged}
@focusin=${this._onFocus}
without-spin-buttons
no-spinner
.required=${this.required}
.autoValidate=${this.autoValidate}
maxlength="2"
max=${ifDefined(this._hourMax)}
min="0"
.disabled=${this.disabled}
suffix=":"
class="hasSuffix"
>
</ha-input>
<div class="time-separator">:</div>
<ha-input
</ha-textfield>
<ha-textfield
id="min"
type="number"
inputmode="numeric"
.value=${this._formatValue(this.minutes)}
.label=${!this.placeholderLabels ? this.minLabel : ""}
.placeholder=${this.placeholderLabels ? this.minLabel : ""}
.label=${this.minLabel}
@change=${this._valueChanged}
@focusin=${this._onFocus}
name="minutes"
without-spin-buttons
no-spinner
.required=${this.required}
.autoValidate=${this.autoValidate}
maxlength="2"
max="59"
min="0"
.disabled=${this.disabled}
.suffix=${this.enableSecond ? ":" : ""}
class=${this.enableSecond ? "has-suffix" : ""}
>
</ha-input>
</ha-textfield>
${this.enableSecond
? html`<div class="time-separator">:</div>`
: nothing}
${this.enableSecond
? html`<ha-input
id="sec"
type="number"
inputmode="decimal"
step="any"
.value=${this._formatValue(this.seconds)}
.label=${!this.placeholderLabels ? this.secLabel : ""}
.placeholder=${this.placeholderLabels ? this.secLabel : ""}
@change=${this._valueChanged}
@focusin=${this._onFocus}
name="seconds"
without-spin-buttons
.required=${this.required}
.autoValidate=${this.autoValidate}
max="59"
min="0"
.disabled=${this.disabled}
>
</ha-input>
${this.enableMillisecond
? html`<div class="time-separator">:</div>`
: nothing}`
? html`<ha-textfield
id="sec"
type="number"
inputmode="decimal"
step="any"
.value=${this._formatValue(this.seconds)}
.label=${this.secLabel}
@change=${this._valueChanged}
@focusin=${this._onFocus}
name="seconds"
no-spinner
.required=${this.required}
.autoValidate=${this.autoValidate}
max="59"
min="0"
.disabled=${this.disabled}
.suffix=${this.enableMillisecond ? ":" : ""}
class=${this.enableMillisecond ? "has-suffix" : ""}
>
</ha-textfield>`
: nothing}
${this.enableMillisecond
? html`<ha-input
? html`<ha-textfield
id="millisec"
type="number"
.value=${this._formatValue(this.milliseconds, 3)}
.label=${!this.placeholderLabels ? this.millisecLabel : ""}
.placeholder=${this.placeholderLabels ? this.millisecLabel : ""}
.label=${this.millisecLabel}
@change=${this._valueChanged}
@focusin=${this._onFocus}
name="milliseconds"
without-spin-buttons
no-spinner
.required=${this.required}
.autoValidate=${this.autoValidate}
maxlength="3"
@@ -264,21 +253,8 @@ export class HaBaseTimeInput extends LitElement {
min="0"
.disabled=${this.disabled}
>
</ha-input>`
</ha-textfield>`
: nothing}
${this.format === 24
? nothing
: html`<ha-select
.required=${this.required}
.value=${this.amPm}
.disabled=${this.disabled}
.name=${"amPm"}
@selected=${this._valueChanged}
@wa-after-hide=${stopPropagation}
@wa-hide=${stopPropagation}
.options=${["AM", "PM"]}
>
</ha-select>`}
${this.clearable && !this.required && !this.disabled
? html`<ha-icon-button
label="clear"
@@ -287,6 +263,18 @@ export class HaBaseTimeInput extends LitElement {
></ha-icon-button>`
: nothing}
</div>
${this.format === 24
? nothing
: html`<ha-select
.required=${this.required}
.value=${this.amPm}
.disabled=${this.disabled}
.name=${"amPm"}
@selected=${this._valueChanged}
.options=${["AM", "PM"]}
>
</ha-select>`}
</div>
${this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
@@ -301,8 +289,8 @@ export class HaBaseTimeInput extends LitElement {
}
private _valueChanged(ev: InputEvent | HaSelectSelectEvent): void {
const textField = ev.currentTarget as HaInput;
this[textField.name || ""] =
const textField = ev.currentTarget as HaTextField;
this[textField.name] =
textField.name === "amPm"
? (ev as HaSelectSelectEvent).detail.value
: Number(textField.value);
@@ -324,7 +312,7 @@ export class HaBaseTimeInput extends LitElement {
}
private _onFocus(ev: FocusEvent) {
(ev.currentTarget as HaInput).select();
(ev.currentTarget as HaTextField).select();
}
/**
@@ -366,74 +354,45 @@ export class HaBaseTimeInput extends LitElement {
direction: ltr;
padding-right: 3px;
}
ha-input {
height: 56px;
padding: 0;
ha-textfield {
width: 60px;
flex-grow: 1;
text-align: center;
--mdc-shape-small: 0;
--text-field-appearance: none;
--text-field-padding-top: 0;
--text-field-padding-bottom: 0;
--text-field-padding-start: 4px;
--text-field-padding-end: 4px;
--text-field-suffix-padding-left: 2px;
--text-field-suffix-padding-right: 0;
--text-field-text-align: center;
}
ha-input:first-child {
ha-textfield.hasSuffix {
--text-field-padding: 0 0 0 4px;
}
ha-textfield:first-child {
--text-field-border-top-left-radius: var(--mdc-shape-medium);
}
ha-input:last-child {
ha-textfield:last-child {
--text-field-border-top-right-radius: var(--mdc-shape-medium);
}
ha-input::part(wa-base) {
padding: var(--ha-space-1);
}
ha-input:first-child::part(wa-base) {
padding-inline-start: var(--ha-space-4);
}
ha-input:last-child::part(wa-base) {
padding-inline-end: var(--ha-space-4);
}
ha-input::part(wa-hint) {
height: 0;
}
ha-input::part(wa-input) {
text-align: center;
}
.time-separator,
ha-icon-button {
background-color: var(--ha-color-form-background);
color: var(--ha-color-text-secondary);
border-bottom: 1px solid var(--ha-color-border-neutral-loud);
box-sizing: border-box;
height: 56px;
margin-inline-start: calc(var(--ha-space-1) * -1);
}
.time-separator {
width: 12px;
margin-inline-end: calc(var(--ha-space-1) * -1);
display: flex;
align-items: center;
justify-content: center;
}
:host([clearable]) .mdc-select__anchor {
padding-inline-end: var(--select-selected-text-padding-end, 12px);
}
ha-icon-button {
position: relative;
--ha-icon-button-size: 36px;
border-start-end-radius: var(--ha-border-radius-sm);
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
direction: var(--direction);
display: flex;
align-items: center;
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-bottom-style: solid;
border-bottom-width: 1px;
}
ha-select {
margin-inline: calc(var(--ha-space-1) * -1);
}
label {
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: var(--ha-font-smoothing);
+16 -16
View File
@@ -8,6 +8,7 @@ import { SwipeGestureRecognizer } from "../common/util/swipe-gesture-recognizer"
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import { isIosApp } from "../util/is_ios";
export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300;
@@ -89,22 +90,21 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
await this.updateComplete;
requestAnimationFrame(() => {
// disabled till iOS app fix the "focus_element" implementation
// if (this.hass && isIosApp(this.hass.auth.external)) {
// const element = this.renderRoot.querySelector("[autofocus]");
// if (element !== null) {
// if (!element.id) {
// element.id = "ha-bottom-sheet-autofocus";
// }
// this.hass.auth.external?.fireMessage({
// type: "focus_element",
// payload: {
// element_id: element.id,
// },
// });
// }
// return;
// }
if (this.hass && isIosApp(this.hass.auth.external)) {
const element = this.renderRoot.querySelector("[autofocus]");
if (element !== null) {
if (!element.id) {
element.id = "ha-bottom-sheet-autofocus";
}
this.hass.auth.external?.fireMessage({
type: "focus_element",
payload: {
element_id: element.id,
},
});
}
return;
}
(
this.renderRoot.querySelector("[autofocus]") as HTMLElement | null
)?.focus();
+1 -2
View File
@@ -57,7 +57,6 @@ export class HaButton extends Button {
.button {
font-size: var(--ha-font-size-m);
line-height: 1;
-webkit-tap-highlight-color: transparent;
transition: background-color var(--ha-animation-duration-fast)
ease-out;
@@ -69,7 +68,7 @@ export class HaButton extends Button {
--ha-button-height,
var(--button-height, 32px)
);
font-size: var(--ha-font-size-m);
font-size: var(--wa-font-size-s, var(--ha-font-size-m));
--wa-form-control-padding-inline: var(--ha-space-3);
}
+21 -70
View File
@@ -8,14 +8,10 @@ import { computeCssColor, THEME_COLORS } from "../common/color/compute-color";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeKeys } from "../common/translations/localize";
import { localizeContext } from "../data/context";
import type { UiColorExtraOption } from "../data/selector";
import type { ValueChangedEvent } from "../types";
import "./ha-combo-box-item";
import "./ha-generic-picker";
import "./ha-icon";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import type { PickerValueRenderer } from "./ha-picker-field";
import "./ha-svg-icon";
@customElement("ha-color-picker")
export class HaColorPicker extends LitElement {
@@ -34,24 +30,8 @@ export class HaColorPicker extends LitElement {
@property({ type: Boolean, attribute: "include_none" })
public includeNone = false;
@property({ attribute: false })
public extraOptions?: UiColorExtraOption[];
@property({ type: Boolean }) public disabled = false;
private _extraOptionsColorMap = memoizeOne(
(extraOptions?: UiColorExtraOption[]) => {
if (!extraOptions) return undefined;
const map = new Map<string, string>();
for (const option of extraOptions) {
if (option.display_color) {
map.set(option.value, option.display_color);
}
}
return map.size > 0 ? map : undefined;
}
);
@property({ type: Boolean }) public required = false;
@state()
@@ -91,7 +71,6 @@ export class HaColorPicker extends LitElement {
const colors = this._getColors(
this.includeNone,
this.includeState,
this.extraOptions,
this.defaultColor,
this.value
);
@@ -114,7 +93,6 @@ export class HaColorPicker extends LitElement {
this._getColors(
this.includeNone,
this.includeState,
this.extraOptions,
this.defaultColor,
this.value
);
@@ -123,7 +101,6 @@ export class HaColorPicker extends LitElement {
(
includeNone: boolean,
includeState: boolean,
extraOptions: UiColorExtraOption[] | undefined,
defaultColor: string | undefined,
currentValue: string | undefined
): PickerComboBoxItem[] => {
@@ -155,19 +132,6 @@ export class HaColorPicker extends LitElement {
});
}
if (extraOptions) {
extraOptions.forEach((option) => {
items.push({
id: option.value,
primary: addDefaultSuffix(
option.label,
defaultColor === option.value
),
...(option.icon ? { icon: option.icon } : {}),
});
});
}
Array.from(THEME_COLORS).forEach((color) => {
const themeLabel =
this.localize?.(
@@ -179,11 +143,14 @@ export class HaColorPicker extends LitElement {
});
});
const knownIds = new Set(items.map((item) => item.id));
const isSpecial =
currentValue === "none" ||
currentValue === "state" ||
THEME_COLORS.has(currentValue || "");
const hasValue = currentValue && currentValue.length > 0;
if (hasValue && !knownIds.has(currentValue!)) {
if (hasValue && !isSpecial) {
items.push({
id: currentValue!,
primary: currentValue!,
@@ -194,27 +161,21 @@ export class HaColorPicker extends LitElement {
}
);
private _renderItemIcon(item: PickerComboBoxItem) {
if (item.icon_path) {
return html`<ha-svg-icon
slot="start"
.path=${item.icon_path}
></ha-svg-icon>`;
}
if (item.icon) {
return html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`;
}
const color =
this._extraOptionsColorMap(this.extraOptions)?.get(item.id) ?? item.id;
return html`<span slot="start">${this._renderColorCircle(color)}</span>`;
}
private _rowRenderer: (
item: PickerComboBoxItem,
index?: number
) => ReturnType<typeof html> = (item) => html`
<ha-combo-box-item type="button" compact>
${this._renderItemIcon(item)}
${item.id === "none"
? html`<ha-svg-icon
slot="start"
.path=${mdiInvertColorsOff}
></ha-svg-icon>`
: item.id === "state"
? html`<ha-svg-icon slot="start" .path=${mdiPalette}></ha-svg-icon>`
: html`<span slot="start">
${this._renderColorCircle(item.id)}
</span>`}
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
@@ -240,23 +201,13 @@ export class HaColorPicker extends LitElement {
`;
}
const extraOption = this.extraOptions?.find((o) => o.value === value);
const label =
extraOption?.label ||
this.localize?.(
`ui.components.color-picker.colors.${value}` as LocalizeKeys
) ||
value;
const color =
this._extraOptionsColorMap(this.extraOptions)?.get(value) ?? value;
const startSlot = extraOption?.icon
? html`<ha-icon slot="start" .icon=${extraOption.icon}></ha-icon>`
: html`<span slot="start">${this._renderColorCircle(color)}</span>`;
return html`
${startSlot}
<span slot="headline">${label}</span>
<span slot="start">${this._renderColorCircle(value)}</span>
<span slot="headline">
${this.localize?.(
`ui.components.color-picker.colors.${value}` as LocalizeKeys
) || value}
</span>
`;
};
+2
View File
@@ -79,6 +79,7 @@ class HaConfigEntryPicker extends LitElement {
<span slot="supporting-text">${item.secondary}</span>
<ha-domain-icon
slot="start"
.hass=${this.hass}
.domain=${item.icon!}
brand-fallback
></ha-domain-icon>
@@ -114,6 +115,7 @@ class HaConfigEntryPicker extends LitElement {
slot="headline"
>${item?.icon
? html`<ha-domain-icon
.hass=${this.hass}
.domain=${item.icon!}
brand-fallback
></ha-domain-icon>`
+2 -3
View File
@@ -11,7 +11,6 @@ import "./ha-svg-icon";
export interface ControlSelectOption {
value: string;
label?: string;
ariaLabel?: string;
icon?: TemplateResult;
path?: string;
}
@@ -162,8 +161,8 @@ export class HaControlSelect extends LitElement {
tabindex=${isSelected ? "0" : "-1"}
.value=${option.value}
aria-checked=${isSelected ? "true" : "false"}
aria-label=${ifDefined(option.ariaLabel ?? option.label)}
title=${ifDefined(option.ariaLabel ?? option.label)}
aria-label=${ifDefined(option.label)}
title=${ifDefined(option.label)}
@click=${this._handleOptionClick}
@focus=${this._handleOptionFocus}
@mousedown=${this._handleOptionMouseDown}
+24 -33
View File
@@ -43,6 +43,30 @@ export class HaConversationAgentPicker extends LitElement {
return nothing;
}
let value = this.value;
if (!value && this.required) {
// Select Home Assistant conversation agent if it supports the language
for (const agent of this._agents) {
if (
agent.id === "conversation.home_assistant" &&
agent.supported_languages.includes(this.language!)
) {
value = agent.id;
break;
}
}
if (!value) {
// Select the first agent that supports the language
for (const agent of this._agents) {
if (
agent.supported_languages === "*" &&
agent.supported_languages.includes(this.language!)
) {
value = agent.id;
break;
}
}
}
}
if (!value) {
value = NONE;
}
@@ -146,39 +170,6 @@ export class HaConversationAgentPicker extends LitElement {
this._agents = agents;
if (!this.value && this.required) {
let defaultValue: string | undefined;
// Select Home Assistant conversation agent if it supports the language
for (const agent of this._agents) {
if (
agent.id === "conversation.home_assistant" &&
(!this.language ||
agent.supported_languages === "*" ||
agent.supported_languages.includes(this.language))
) {
defaultValue = agent.id;
break;
}
}
if (!defaultValue) {
// Select the first agent that supports the language
for (const agent of this._agents) {
if (
agent.supported_languages === "*" ||
!this.language ||
agent.supported_languages.includes(this.language)
) {
defaultValue = agent.id;
break;
}
}
}
if (defaultValue) {
this.value = defaultValue;
fireEvent(this, "value-changed", { value: this.value });
}
}
if (!this.value) {
return;
}
+110
View File
@@ -0,0 +1,110 @@
import { mdiContentCopy, mdiEye, mdiEyeOff } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { copyToClipboard } from "../common/util/copy-clipboard";
import type { HomeAssistant } from "../types";
import { showToast } from "../util/toast";
import "./ha-button";
import "./ha-icon-button";
import "./ha-svg-icon";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
@customElement("ha-copy-textfield")
export class HaCopyTextfield extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "value" }) public value!: string;
@property({ attribute: "masked-value" }) public maskedValue?: string;
@property({ attribute: "label" }) public label?: string;
@state() private _showMasked = true;
public render() {
return html`
<div class="container">
<div class="textfield-container">
<ha-textfield
.value=${this._showMasked && this.maskedValue
? this.maskedValue
: this.value}
readonly
.suffix=${this.maskedValue
? html`<div style="width: 24px"></div>`
: nothing}
@click=${this._focusInput}
></ha-textfield>
${this.maskedValue
? html`<ha-icon-button
class="toggle-unmasked"
.label=${this.hass.localize(
`ui.common.${this._showMasked ? "show" : "hide"}`
)}
@click=${this._toggleMasked}
.path=${this._showMasked ? mdiEye : mdiEyeOff}
></ha-icon-button>`
: nothing}
</div>
<ha-button @click=${this._copy} appearance="plain" size="small">
<ha-svg-icon slot="start" .path=${mdiContentCopy}></ha-svg-icon>
${this.label || this.hass.localize("ui.common.copy")}
</ha-button>
</div>
`;
}
private _focusInput(ev) {
const inputElement = ev.currentTarget as HaTextField;
inputElement.select();
}
private _toggleMasked(): void {
this._showMasked = !this._showMasked;
}
private async _copy(): Promise<void> {
await copyToClipboard(this.value);
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
});
}
static styles = css`
.container {
display: flex;
align-items: center;
gap: var(--ha-space-2);
margin-top: 8px;
}
.textfield-container {
position: relative;
flex: 1;
}
.textfield-container ha-textfield {
display: block;
}
.toggle-unmasked {
position: absolute;
top: 8px;
right: 8px;
inset-inline-start: initial;
inset-inline-end: 8px;
--ha-icon-button-size: 40px;
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
direction: var(--direction);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-copy-textfield": HaCopyTextfield;
}
}
+14 -13
View File
@@ -8,11 +8,10 @@ import { fireEvent } from "../common/dom/fire_event";
import { TimeZone } from "../data/translation";
import type { HomeAssistant } from "../types";
import "./ha-svg-icon";
import "./input/ha-input";
import type { HaInput } from "./input/ha-input";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
const loadDatePickerDialog = () =>
import("./date-picker/ha-dialog-date-picker");
const loadDatePickerDialog = () => import("./ha-dialog-date-picker");
export interface DatePickerDialogParams {
value?: string;
@@ -54,17 +53,19 @@ export class HaDateInput extends LitElement {
@property({ attribute: "can-clear", type: Boolean }) public canClear = false;
@query("ha-input", true) private _input?: HaInput;
@query("ha-textfield", true) private _input?: HaTextField;
public reportValidity(): boolean {
return this._input?.reportValidity() ?? true;
}
render() {
return html`<ha-input
.label=${this.label ?? ""}
.hint=${this.helper ?? ""}
return html`<ha-textfield
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
iconTrailing
helperPersistent
readonly
@click=${this._openDialog}
@keydown=${this._keyDown}
@@ -80,8 +81,8 @@ export class HaDateInput extends LitElement {
: ""}
.required=${this.required}
>
<ha-svg-icon slot="end" .path=${mdiCalendar}></ha-svg-icon>
</ha-input>`;
<ha-svg-icon slot="trailingIcon" .path=${mdiCalendar}></ha-svg-icon>
</ha-textfield>`;
}
private _openDialog() {
@@ -123,12 +124,12 @@ export class HaDateInput extends LitElement {
}
static styles = css`
:host {
min-width: 0px;
}
ha-svg-icon {
color: var(--secondary-text-color);
}
ha-textfield {
display: block;
}
`;
}
declare global {
+422
View File
@@ -0,0 +1,422 @@
import type { ActionDetail } from "@material/mwc-list/mwc-list-foundation";
import { mdiCalendar } from "@mdi/js";
import { isThisYear } from "date-fns";
import { TZDate } from "@date-fns/tz";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { shiftDateRange } from "../common/datetime/calc_date";
import type { DateRange } from "../common/datetime/calc_date_range";
import { calcDateRange } from "../common/datetime/calc_date_range";
import { firstWeekdayIndex } from "../common/datetime/first_weekday";
import {
formatShortDateTime,
formatShortDateTimeWithYear,
} from "../common/datetime/format_date_time";
import { useAmPm } from "../common/datetime/use_am_pm";
import { fireEvent } from "../common/dom/fire_event";
import { TimeZone } from "../data/translation";
import type { HomeAssistant } from "../types";
import "./date-range-picker";
import "./ha-button";
import "./ha-icon-button";
import "./ha-icon-button-next";
import "./ha-icon-button-prev";
import "./ha-list";
import "./ha-list-item";
import "./ha-textarea";
export type DateRangePickerRanges = Record<string, [Date, Date]>;
declare global {
interface HASSDomEvents {
"preset-selected": { index: number };
}
}
const RANGE_KEYS: DateRange[] = ["today", "yesterday", "this_week"];
const EXTENDED_RANGE_KEYS: DateRange[] = [
"this_month",
"this_year",
"now-1h",
"now-12h",
"now-24h",
"now-7d",
"now-30d",
];
@customElement("ha-date-range-picker")
export class HaDateRangePicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public startDate!: Date;
@property({ attribute: false }) public endDate!: Date;
@property({ attribute: false }) public ranges?: DateRangePickerRanges | false;
@state() private _ranges?: DateRangePickerRanges;
@property({ attribute: "auto-apply", type: Boolean })
public autoApply = false;
@property({ attribute: "time-picker", type: Boolean })
public timePicker = false;
public open(): void {
this._openPicker();
}
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public minimal = false;
@state() private _hour24format = false;
@property({ attribute: "extended-presets", type: Boolean })
public extendedPresets = false;
@property({ attribute: "vertical-opening-direction" })
public verticalOpeningDirection?: "up" | "down";
@property({ attribute: false }) public openingDirection?:
| "right"
| "left"
| "center"
| "inline";
@state() private _calcedOpeningDirection?:
| "right"
| "left"
| "center"
| "inline";
@state() private _calcedVerticalOpeningDirection?: "up" | "down";
protected willUpdate(changedProps: PropertyValues) {
if (
(!this.hasUpdated && this.ranges === undefined) ||
(changedProps.has("hass") &&
this.hass?.localize !== changedProps.get("hass")?.localize)
) {
const rangeKeys = this.extendedPresets
? [...RANGE_KEYS, ...EXTENDED_RANGE_KEYS]
: RANGE_KEYS;
this._ranges = {};
rangeKeys.forEach((key) => {
this._ranges![
this.hass.localize(`ui.components.date-range-picker.ranges.${key}`)
] = calcDateRange(this.hass, key);
});
}
}
protected updated(changedProps: PropertyValues) {
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || oldHass.locale !== this.hass.locale) {
this._hour24format = !useAmPm(this.hass.locale);
}
}
}
protected render(): TemplateResult {
return html`
<date-range-picker
?disabled=${this.disabled}
?auto-apply=${this.autoApply}
time-picker=${this.timePicker}
twentyfour-hours=${this._hour24format}
start-date=${this._formatDate(this.startDate)}
end-date=${this._formatDate(this.endDate)}
?ranges=${this.ranges !== false}
opening-direction=${ifDefined(
this.openingDirection || this._calcedOpeningDirection
)}
opens-vertical=${ifDefined(
this.verticalOpeningDirection || this._calcedVerticalOpeningDirection
)}
first-day=${firstWeekdayIndex(this.hass.locale)}
language=${this.hass.locale.language}
@change=${this._handleChange}
>
<div slot="input" class="date-range-inputs" @click=${this._handleClick}>
${!this.minimal
? html`<ha-textarea
mobile-multiline
.value=${(isThisYear(this.startDate)
? formatShortDateTime(
this.startDate,
this.hass.locale,
this.hass.config
)
: formatShortDateTimeWithYear(
this.startDate,
this.hass.locale,
this.hass.config
)) +
(window.innerWidth >= 459 ? " - " : " - \n") +
(isThisYear(this.endDate)
? formatShortDateTime(
this.endDate,
this.hass.locale,
this.hass.config
)
: formatShortDateTimeWithYear(
this.endDate,
this.hass.locale,
this.hass.config
))}
.label=${this.hass.localize(
"ui.components.date-range-picker.start_date"
) +
" - " +
this.hass.localize(
"ui.components.date-range-picker.end_date"
)}
.disabled=${this.disabled}
@click=${this._handleInputClick}
readonly
></ha-textarea>
<ha-icon-button-prev
.label=${this.hass.localize("ui.common.previous")}
@click=${this._handlePrev}
>
</ha-icon-button-prev>
<ha-icon-button-next
.label=${this.hass.localize("ui.common.next")}
@click=${this._handleNext}
>
</ha-icon-button-next>`
: html`<ha-icon-button
.label=${this.hass.localize(
"ui.components.date-range-picker.select_date_range"
)}
.path=${mdiCalendar}
></ha-icon-button>`}
</div>
${this.ranges !== false && (this.ranges || this._ranges)
? html`<div slot="ranges" class="date-range-ranges">
<ha-list @action=${this._setDateRange} activatable>
${Object.keys(this.ranges || this._ranges!).map(
(name) => html`<ha-list-item>${name}</ha-list-item>`
)}
</ha-list>
</div>`
: nothing}
<div slot="footer" class="date-range-footer">
<ha-button appearance="plain" @click=${this._cancelDateRange}
>${this.hass.localize("ui.common.cancel")}</ha-button
>
<ha-button @click=${this._applyDateRange}
>${this.hass.localize(
"ui.components.date-range-picker.select"
)}</ha-button
>
</div>
</date-range-picker>
`;
}
private _handleNext(ev: MouseEvent): void {
if (ev && ev.stopPropagation) ev.stopPropagation();
this._shift(true);
}
private _handlePrev(ev: MouseEvent): void {
if (ev && ev.stopPropagation) ev.stopPropagation();
this._shift(false);
}
private _shift(forward: boolean) {
if (!this.startDate) return;
const { start, end } = shiftDateRange(
this.startDate,
this.endDate,
forward,
this.hass.locale,
this.hass.config
);
this.startDate = start;
this.endDate = end;
const dateRange = [start, end];
const dateRangePicker = this._dateRangePicker;
dateRangePicker.clickRange(dateRange);
dateRangePicker.clickedApply();
}
private _setDateRange(ev: CustomEvent<ActionDetail>) {
const dateRange = Object.values(this.ranges || this._ranges!)[
ev.detail.index
];
fireEvent(this, "preset-selected", {
index: ev.detail.index,
});
const dateRangePicker = this._dateRangePicker;
dateRangePicker.clickRange(dateRange);
dateRangePicker.clickedApply();
}
private _cancelDateRange() {
this._dateRangePicker.clickCancel();
}
private _applyDateRange() {
let start = new Date(this._dateRangePicker.start);
let end = new Date(this._dateRangePicker.end);
if (this.timePicker) {
start.setSeconds(0);
start.setMilliseconds(0);
end.setSeconds(0);
end.setMilliseconds(0);
if (
end.getHours() === 0 &&
end.getMinutes() === 0 &&
start.getFullYear() === end.getFullYear() &&
start.getMonth() === end.getMonth() &&
start.getDate() === end.getDate()
) {
end.setDate(end.getDate() + 1);
}
}
if (this.hass.locale.time_zone === TimeZone.server) {
start = new Date(new TZDate(start, this.hass.config.time_zone).getTime());
end = new Date(new TZDate(end, this.hass.config.time_zone).getTime());
}
if (
start.getTime() !== this._dateRangePicker.start.getTime() ||
end.getTime() !== this._dateRangePicker.end.getTime()
) {
this._dateRangePicker.clickRange([start, end]);
}
this._dateRangePicker.clickedApply();
}
private _formatDate(date: Date): string {
if (this.hass.locale.time_zone === TimeZone.server) {
return new TZDate(date, this.hass.config.time_zone).toISOString();
}
return date.toISOString();
}
private get _dateRangePicker() {
const dateRangePicker = this.shadowRoot!.querySelector(
"date-range-picker"
) as any;
return dateRangePicker.vueComponent.$children[0];
}
private _openPicker() {
if (!this._dateRangePicker.open) {
const datePicker = this.shadowRoot!.querySelector(
"date-range-picker div.date-range-inputs"
) as any;
datePicker?.click();
}
}
private _handleInputClick() {
// close the date picker, so it will open again on the click event
if (this._dateRangePicker.open) {
this._dateRangePicker.open = false;
}
}
private _handleClick() {
// calculate opening direction if not set
if (!this._dateRangePicker.open) {
if (!this.openingDirection) {
const datePickerPosition = this.getBoundingClientRect().x;
let opens: "right" | "left" | "center" | "inline";
if (datePickerPosition > (2 * window.innerWidth) / 3) {
opens = "left";
} else if (datePickerPosition < window.innerWidth / 3) {
opens = "right";
} else {
opens = "center";
}
this._calcedOpeningDirection = opens;
}
if (!this.verticalOpeningDirection) {
const rect = this.getBoundingClientRect();
this._calcedVerticalOpeningDirection =
rect.top > window.innerHeight / 2 ? "up" : "down";
}
}
}
private _handleChange(ev: CustomEvent) {
ev.stopPropagation();
const startDate = ev.detail.startDate;
const endDate = ev.detail.endDate;
fireEvent(this, "value-changed", {
value: { startDate, endDate },
});
}
static styles = css`
ha-icon-button {
direction: var(--direction);
}
.date-range-inputs {
display: flex;
align-items: center;
gap: var(--ha-space-2);
}
.date-range-ranges {
border-right: 1px solid var(--divider-color);
}
.date-range-footer {
display: flex;
justify-content: flex-end;
padding: 8px;
border-top: 1px solid var(--divider-color);
}
ha-textarea {
display: inline-block;
width: 340px;
}
@media only screen and (max-width: 460px) {
ha-textarea {
width: 100%;
}
}
@media only screen and (max-width: 800px) {
.date-range-ranges {
border-right: none;
border-bottom: 1px solid var(--divider-color);
}
}
@media only screen and (max-height: 940px) and (max-width: 800px) {
.date-range-ranges {
overflow: auto;
max-height: calc(70vh - 330px);
min-height: 160px;
}
:host([header-position]) .date-range-ranges {
max-height: calc(90vh - 430px);
}
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-date-range-picker": HaDateRangePicker;
}
}
+173
View File
@@ -0,0 +1,173 @@
import "app-datepicker";
import { format } from "date-fns";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { nextRender } from "../common/util/render-status";
import { haStyleDialog } from "../resources/styles";
import type { HomeAssistant } from "../types";
import type { DatePickerDialogParams } from "./ha-date-input";
import "./ha-button";
import "./ha-dialog-footer";
import "./ha-dialog";
@customElement("ha-dialog-date-picker")
export class HaDialogDatePicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public value?: string;
@property({ type: Boolean }) public disabled = false;
@property() public label?: string;
@state() private _params?: DatePickerDialogParams;
@state() private _open = false;
@state() private _value?: string;
public async showDialog(params: DatePickerDialogParams): Promise<void> {
// app-datepicker has a bug, that it removes its handlers when disconnected, but doesn't add them back when reconnected.
// So we need to wait for the next render to make sure the element is removed and re-created so the handlers are added.
await nextRender();
this._params = params;
this._value = params.value;
this._open = true;
}
public closeDialog() {
this._open = false;
}
private _dialogClosed() {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
render() {
if (!this._params) {
return nothing;
}
return html`<ha-dialog
.hass=${this.hass}
.open=${this._open}
width="small"
without-header
@closed=${this._dialogClosed}
>
<app-datepicker
.value=${this._value}
.min=${this._params.min}
.max=${this._params.max}
.locale=${this._params.locale}
@datepicker-value-updated=${this._valueChanged}
.firstDayOfWeek=${this._params.firstWeekday}
></app-datepicker>
<div class="bottom-actions">
${this._params.canClear
? html`<ha-button
slot="secondaryAction"
@click=${this._clear}
variant="danger"
appearance="plain"
>
${this.hass.localize("ui.dialogs.date-picker.clear")}
</ha-button>`
: nothing}
<ha-button
appearance="plain"
slot="secondaryAction"
@click=${this._setToday}
>
${this.hass.localize("ui.dialogs.date-picker.today")}
</ha-button>
</div>
<ha-dialog-footer slot="footer">
<ha-button
appearance="plain"
slot="secondaryAction"
@click=${this.closeDialog}
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button slot="primaryAction" @click=${this._setValue}>
${this.hass.localize("ui.common.ok")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>`;
}
private _valueChanged(ev: CustomEvent) {
this._value = ev.detail.value;
}
private _clear() {
this._params?.onChange(undefined);
this.closeDialog();
}
private _setToday() {
const today = new Date();
this._value = format(today, "yyyy-MM-dd");
}
private _setValue() {
if (!this._value) {
// Date picker opens to today if value is undefined. If user click OK
// without changing the date, should return todays date, not undefined.
this._setToday();
}
this._params?.onChange(this._value!);
this.closeDialog();
}
static styles = [
haStyleDialog,
css`
ha-dialog {
--dialog-content-padding: 0;
}
.bottom-actions {
display: flex;
gap: var(--ha-space-4);
justify-content: center;
align-items: center;
width: 100%;
margin-bottom: var(--ha-space-1);
}
app-datepicker {
display: block;
margin-inline: auto;
--app-datepicker-accent-color: var(--primary-color);
--app-datepicker-bg-color: transparent;
--app-datepicker-color: var(--primary-text-color);
--app-datepicker-disabled-day-color: var(--disabled-text-color);
--app-datepicker-focused-day-color: var(--text-primary-color);
--app-datepicker-focused-year-bg-color: var(--primary-color);
--app-datepicker-selector-color: var(--secondary-text-color);
--app-datepicker-separator-color: var(--divider-color);
--app-datepicker-weekday-color: var(--secondary-text-color);
}
app-datepicker::part(calendar-day):focus {
outline: none;
}
app-datepicker::part(body) {
direction: ltr;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
app-datepicker {
width: 100%;
}
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-dialog-date-picker": HaDialogDatePicker;
}
}
+20 -21
View File
@@ -14,9 +14,10 @@ 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 { localizeContext } from "../data/context";
import { authContext, localizeContext } from "../data/context";
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
import { haStyleScrollbar } from "../resources/styles";
import { isIosApp } from "../util/is_ios";
import "./ha-dialog-header";
import "./ha-icon-button";
@@ -126,10 +127,9 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
@consume({ context: localizeContext, subscribe: true })
private localize!: ContextType<typeof localizeContext>;
// disabled till iOS app fix the "focus_element" implementation
// @state()
// @consume({ context: authContext, subscribe: true })
// private auth?: ContextType<typeof authContext>;
@state()
@consume({ context: authContext, subscribe: true })
private auth?: ContextType<typeof authContext>;
@state()
private _bodyScrolled = false;
@@ -221,22 +221,21 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
await this.updateComplete;
requestAnimationFrame(() => {
// disabled till iOS app fix the "focus_element" implementation
// 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.auth.external.fireMessage({
// type: "focus_element",
// payload: {
// element_id: element.id,
// },
// });
// }
// return;
// }
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.auth.external.fireMessage({
type: "focus_element",
payload: {
element_id: element.id,
},
});
}
return;
}
(this.querySelector("[autofocus]") as HTMLElement | null)?.focus();
});
};
+8 -29
View File
@@ -1,23 +1,19 @@
import { consume, type ContextType } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { until } from "lit/directives/until";
import {
authContext,
configContext,
connectionContext,
themesContext,
} from "../data/context";
import {
DEFAULT_DOMAIN_ICON,
domainIcon,
FALLBACK_DOMAIN_ICONS,
} from "../data/icons";
import type { HomeAssistant } from "../types";
import { brandsUrl } from "../util/brands-url";
import "./ha-icon";
@customElement("ha-domain-icon")
export class HaDomainIcon extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public domain?: string;
@property({ attribute: false }) public deviceClass?: string;
@@ -29,22 +25,6 @@ export class HaDomainIcon extends LitElement {
@property({ attribute: "brand-fallback", type: Boolean })
public brandFallback?: boolean;
@state()
@consume({ context: configContext, subscribe: true })
private _hassConfig?: ContextType<typeof configContext>;
@state()
@consume({ context: connectionContext, subscribe: true })
private _connection?: ContextType<typeof connectionContext>;
@state()
@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) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -54,13 +34,12 @@ export class HaDomainIcon extends LitElement {
return nothing;
}
if (!this._connection || !this._hassConfig) {
if (!this.hass) {
return this._renderFallback();
}
const icon = domainIcon(
this._connection,
this._hassConfig,
this.hass,
this.domain,
this.deviceClass,
this.state
@@ -86,9 +65,9 @@ export class HaDomainIcon extends LitElement {
{
domain: this.domain!,
type: "icon",
darkOptimized: this._themes?.darkMode,
darkOptimized: this.hass.themes?.darkMode,
},
this._auth?.data.hassUrl
this.hass.auth.data.hassUrl
);
return html`
<img
-3
View File
@@ -100,9 +100,6 @@ export class HaDropdown extends Dropdown {
#menu {
padding: var(--ha-space-1);
}
wa-popup::part(popup) {
z-index: 200;
}
`,
];
}

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